Merge pull request #4308 from eatrisno/patch-1

Add Refresh / Reload Button on rpc/Telegram
This commit is contained in:
Matthias 2021-06-19 18:50:59 +01:00 committed by GitHub
commit 204758834d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 127 additions and 64 deletions

View File

@ -178,7 +178,9 @@
"sell_fill": "on", "sell_fill": "on",
"buy_cancel": "on", "buy_cancel": "on",
"sell_cancel": "on" "sell_cancel": "on"
} },
"reload": true,
"balance_dust_level": 0.01
}, },
"api_server": { "api_server": {
"enabled": false, "enabled": false,

View File

@ -95,6 +95,7 @@ Example configuration showing the different settings:
"buy_fill": "off", "buy_fill": "off",
"sell_fill": "off" "sell_fill": "off"
}, },
"reload": true,
"balance_dust_level": 0.01 "balance_dust_level": 0.01
}, },
``` ```
@ -105,6 +106,7 @@ Example configuration showing the different settings:
`balance_dust_level` will define what the `/balance` command takes as "dust" - Currencies with a balance below this will be shown. `balance_dust_level` will define what the `/balance` command takes as "dust" - Currencies with a balance below this will be shown.
`reload` allows you to disable reload-buttons on selected messages.
## Create a custom keyboard (command shortcut buttons) ## Create a custom keyboard (command shortcut buttons)

View File

@ -275,7 +275,8 @@ CONF_SCHEMA = {
'default': 'off' 'default': 'off'
}, },
} }
} },
'reload': {'type': 'boolean'},
}, },
'required': ['enabled', 'token', 'chat_id'], 'required': ['enabled', 'token', 'chat_id'],
}, },

View File

@ -10,13 +10,13 @@ from datetime import date, datetime, timedelta
from html import escape from html import escape
from itertools import chain from itertools import chain
from math import isnan from math import isnan
from typing import Any, Callable, Dict, List, Optional, Union, cast from typing import Any, Callable, Dict, List, Optional, Union
import arrow import arrow
from tabulate import tabulate from tabulate import tabulate
from telegram import (InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ParseMode, from telegram import (CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton,
ReplyKeyboardMarkup, Update) ParseMode, ReplyKeyboardMarkup, Update)
from telegram.error import NetworkError, TelegramError from telegram.error import BadRequest, NetworkError, TelegramError
from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler, Updater from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler, Updater
from telegram.utils.helpers import escape_markdown from telegram.utils.helpers import escape_markdown
@ -47,9 +47,13 @@ def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]:
update = kwargs.get('update') or args[0] update = kwargs.get('update') or args[0]
# Reject unauthorized messages # Reject unauthorized messages
chat_id = int(self._config['telegram']['chat_id']) if update.callback_query:
cchat_id = int(update.callback_query.message.chat.id)
else:
cchat_id = int(update.message.chat_id)
if int(update.message.chat_id) != chat_id: chat_id = int(self._config['telegram']['chat_id'])
if cchat_id != chat_id:
logger.info( logger.info(
'Rejected unauthorized message from: %s', 'Rejected unauthorized message from: %s',
update.message.chat_id update.message.chat_id
@ -91,7 +95,7 @@ class Telegram(RPCHandler):
Validates the keyboard configuration from telegram config Validates the keyboard configuration from telegram config
section. section.
""" """
self._keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = [ self._keyboard: List[List[Union[str, KeyboardButton]]] = [
['/daily', '/profit', '/balance'], ['/daily', '/profit', '/balance'],
['/status', '/status table', '/performance'], ['/status', '/status table', '/performance'],
['/count', '/start', '/stop', '/help'] ['/count', '/start', '/stop', '/help']
@ -164,8 +168,21 @@ class Telegram(RPCHandler):
CommandHandler('help', self._help), CommandHandler('help', self._help),
CommandHandler('version', self._version), CommandHandler('version', self._version),
] ]
callbacks = [
CallbackQueryHandler(self._status_table, pattern='update_status_table'),
CallbackQueryHandler(self._daily, pattern='update_daily'),
CallbackQueryHandler(self._profit, pattern='update_profit'),
CallbackQueryHandler(self._balance, pattern='update_balance'),
CallbackQueryHandler(self._performance, pattern='update_performance'),
CallbackQueryHandler(self._count, pattern='update_count'),
CallbackQueryHandler(self._forcebuy_inline),
]
for handle in handles: for handle in handles:
self._updater.dispatcher.add_handler(handle) self._updater.dispatcher.add_handler(handle)
for callback in callbacks:
self._updater.dispatcher.add_handler(callback)
self._updater.start_polling( self._updater.start_polling(
bootstrap_retries=-1, bootstrap_retries=-1,
timeout=30, timeout=30,
@ -177,11 +194,6 @@ class Telegram(RPCHandler):
[h.command for h in handles] [h.command for h in handles]
) )
self._current_callback_query_handler: Optional[CallbackQueryHandler] = None
self._callback_query_handlers = {
'forcebuy': CallbackQueryHandler(self._forcebuy_inline)
}
def cleanup(self) -> None: def cleanup(self) -> None:
""" """
Stops all running telegram threads. Stops all running telegram threads.
@ -409,7 +421,9 @@ class Telegram(RPCHandler):
# insert separators line between Total # insert separators line between Total
lines = message.split("\n") lines = message.split("\n")
message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]]) message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]])
self._send_msg(f"<pre>{message}</pre>", parse_mode=ParseMode.HTML) self._send_msg(f"<pre>{message}</pre>", parse_mode=ParseMode.HTML,
reload_able=True, callback_path="update_status_table",
query=update.callback_query)
except RPCException as e: except RPCException as e:
self._send_msg(str(e)) self._send_msg(str(e))
@ -447,7 +461,8 @@ class Telegram(RPCHandler):
], ],
tablefmt='simple') tablefmt='simple')
message = f'<b>Daily Profit over the last {timescale} days</b>:\n<pre>{stats_tab}</pre>' message = f'<b>Daily Profit over the last {timescale} days</b>:\n<pre>{stats_tab}</pre>'
self._send_msg(message, parse_mode=ParseMode.HTML) self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
callback_path="update_daily", query=update.callback_query)
except RPCException as e: except RPCException as e:
self._send_msg(str(e)) self._send_msg(str(e))
@ -519,7 +534,8 @@ class Telegram(RPCHandler):
if stats['closed_trade_count'] > 0: if stats['closed_trade_count'] > 0:
markdown_msg += (f"\n*Avg. Duration:* `{avg_duration}`\n" markdown_msg += (f"\n*Avg. Duration:* `{avg_duration}`\n"
f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`") f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`")
self._send_msg(markdown_msg) self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit",
query=update.callback_query)
@authorized_only @authorized_only
def _stats(self, update: Update, context: CallbackContext) -> None: def _stats(self, update: Update, context: CallbackContext) -> None:
@ -606,7 +622,8 @@ class Telegram(RPCHandler):
f"\t`{result['stake']}: {result['total']: .8f}`\n" f"\t`{result['stake']}: {result['total']: .8f}`\n"
f"\t`{result['symbol']}: " f"\t`{result['symbol']}: "
f"{round_coin_value(result['value'], result['symbol'], False)}`\n") f"{round_coin_value(result['value'], result['symbol'], False)}`\n")
self._send_msg(output) self._send_msg(output, reload_able=True, callback_path="update_balance",
query=update.callback_query)
except RPCException as e: except RPCException as e:
self._send_msg(str(e)) self._send_msg(str(e))
@ -713,10 +730,10 @@ class Telegram(RPCHandler):
self._forcebuy_action(pair, price) self._forcebuy_action(pair, price)
else: else:
whitelist = self._rpc._rpc_whitelist()['whitelist'] whitelist = self._rpc._rpc_whitelist()['whitelist']
pairs = [InlineKeyboardButton(pair, callback_data=pair) for pair in whitelist] pairs = [InlineKeyboardButton(text=pair, callback_data=pair) for pair in whitelist]
self._send_inline_msg("Which pair?",
keyboard=self._layout_inline_keyboard(pairs), self._send_msg(msg="Which pair?",
callback_query_handler='forcebuy') keyboard=self._layout_inline_keyboard(pairs))
@authorized_only @authorized_only
def _trades(self, update: Update, context: CallbackContext) -> None: def _trades(self, update: Update, context: CallbackContext) -> None:
@ -800,7 +817,9 @@ class Telegram(RPCHandler):
else: else:
output += stat_line output += stat_line
self._send_msg(output, parse_mode=ParseMode.HTML) self._send_msg(output, parse_mode=ParseMode.HTML,
reload_able=True, callback_path="update_performance",
query=update.callback_query)
except RPCException as e: except RPCException as e:
self._send_msg(str(e)) self._send_msg(str(e))
@ -820,7 +839,9 @@ class Telegram(RPCHandler):
tablefmt='simple') tablefmt='simple')
message = "<pre>{}</pre>".format(message) message = "<pre>{}</pre>".format(message)
logger.debug(message) logger.debug(message)
self._send_msg(message, parse_mode=ParseMode.HTML) self._send_msg(message, parse_mode=ParseMode.HTML,
reload_able=True, callback_path="update_count",
query=update.callback_query)
except RPCException as e: except RPCException as e:
self._send_msg(str(e)) self._send_msg(str(e))
@ -1052,29 +1073,42 @@ class Telegram(RPCHandler):
f"*Current state:* `{val['state']}`" f"*Current state:* `{val['state']}`"
) )
def _send_inline_msg(self, msg: str, callback_query_handler, def _update_msg(self, query: CallbackQuery, msg: str, callback_path: str = "",
parse_mode: str = ParseMode.MARKDOWN, disable_notification: bool = False, reload_able: bool = False, parse_mode: str = ParseMode.MARKDOWN) -> None:
keyboard: List[List[InlineKeyboardButton]] = None, ) -> None: if reload_able:
""" reply_markup = InlineKeyboardMarkup([
Send given markdown message [InlineKeyboardButton("Refresh", callback_data=callback_path)],
:param msg: message ])
:param bot: alternative bot else:
:param parse_mode: telegram parse mode reply_markup = InlineKeyboardMarkup([[]])
:return: None msg += "\nUpdated: {}".format(datetime.now().ctime())
""" if not query.message:
if self._current_callback_query_handler: return
self._updater.dispatcher.remove_handler(self._current_callback_query_handler) chat_id = query.message.chat_id
self._current_callback_query_handler = self._callback_query_handlers[callback_query_handler] message_id = query.message.message_id
self._updater.dispatcher.add_handler(self._current_callback_query_handler)
self._send_msg(msg, parse_mode, disable_notification, try:
cast(List[List[Union[str, KeyboardButton, InlineKeyboardButton]]], keyboard), self._updater.bot.edit_message_text(
reply_markup=InlineKeyboardMarkup) chat_id=chat_id,
message_id=message_id,
text=msg,
parse_mode=parse_mode,
reply_markup=reply_markup
)
except BadRequest as e:
if 'not modified' in e.message.lower():
pass
else:
logger.warning('TelegramError: %s', e.message)
except TelegramError as telegram_err:
logger.warning('TelegramError: %s! Giving up on that message.', telegram_err.message)
def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN, def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN,
disable_notification: bool = False, disable_notification: bool = False,
keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = None, keyboard: List[List[InlineKeyboardButton]] = None,
reply_markup=ReplyKeyboardMarkup) -> None: callback_path: str = "",
reload_able: bool = False,
query: Optional[CallbackQuery] = None) -> None:
""" """
Send given markdown message Send given markdown message
:param msg: message :param msg: message
@ -1082,9 +1116,19 @@ class Telegram(RPCHandler):
:param parse_mode: telegram parse mode :param parse_mode: telegram parse mode
:return: None :return: None
""" """
if keyboard is None: reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]
keyboard = self._keyboard if query:
reply_markup = reply_markup(keyboard, resize_keyboard=True) self._update_msg(query=query, msg=msg, parse_mode=parse_mode,
callback_path=callback_path, reload_able=reload_able)
return
if reload_able and self._config['telegram'].get('reload', True):
reply_markup = InlineKeyboardMarkup([
[InlineKeyboardButton("Refresh", callback_data=callback_path)]])
else:
if keyboard is not None:
reply_markup = InlineKeyboardMarkup(keyboard, resize_keyboard=True)
else:
reply_markup = ReplyKeyboardMarkup(self._keyboard, resize_keyboard=True)
try: try:
try: try:
self._updater.bot.send_message( self._updater.bot.send_message(

View File

@ -13,7 +13,7 @@ from unittest.mock import ANY, MagicMock
import arrow import arrow
import pytest import pytest
from telegram import Chat, Message, ReplyKeyboardMarkup, Update from telegram import Chat, Message, ReplyKeyboardMarkup, Update
from telegram.error import NetworkError from telegram.error import BadRequest, NetworkError, TelegramError
from freqtrade import __version__ from freqtrade import __version__
from freqtrade.constants import CANCEL_REASON from freqtrade.constants import CANCEL_REASON
@ -25,8 +25,8 @@ from freqtrade.loggers import setup_logging
from freqtrade.persistence import PairLocks, Trade from freqtrade.persistence import PairLocks, Trade
from freqtrade.rpc import RPC from freqtrade.rpc import RPC
from freqtrade.rpc.telegram import Telegram, authorized_only from freqtrade.rpc.telegram import Telegram, authorized_only
from tests.conftest import (create_mock_trades, get_patched_freqtradebot, log_has, patch_exchange, from tests.conftest import (create_mock_trades, get_patched_freqtradebot, log_has, log_has_re,
patch_get_signal, patch_whitelist) patch_exchange, patch_get_signal, patch_whitelist)
class DummyCls(Telegram): class DummyCls(Telegram):
@ -55,14 +55,6 @@ class DummyCls(Telegram):
raise Exception('test') raise Exception('test')
def get_telegram_testobject_with_inline(mocker, default_conf, mock=True, ftbot=None):
inline_msg_mock = MagicMock()
telegram, ftbot, msg_mock = get_telegram_testobject(mocker, default_conf)
mocker.patch('freqtrade.rpc.telegram.Telegram._send_inline_msg', inline_msg_mock)
return telegram, ftbot, msg_mock, inline_msg_mock
def get_telegram_testobject(mocker, default_conf, mock=True, ftbot=None): def get_telegram_testobject(mocker, default_conf, mock=True, ftbot=None):
msg_mock = MagicMock() msg_mock = MagicMock()
if mock: if mock:
@ -920,8 +912,8 @@ def test_forcebuy_no_pair(default_conf, update, mocker) -> None:
fbuy_mock = MagicMock(return_value=None) fbuy_mock = MagicMock(return_value=None)
mocker.patch('freqtrade.rpc.RPC._rpc_forcebuy', fbuy_mock) mocker.patch('freqtrade.rpc.RPC._rpc_forcebuy', fbuy_mock)
telegram, freqtradebot, _, inline_msg_mock = get_telegram_testobject_with_inline(mocker, telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf)
default_conf)
patch_get_signal(freqtradebot, (True, False)) patch_get_signal(freqtradebot, (True, False))
context = MagicMock() context = MagicMock()
@ -929,10 +921,10 @@ def test_forcebuy_no_pair(default_conf, update, mocker) -> None:
telegram._forcebuy(update=update, context=context) telegram._forcebuy(update=update, context=context)
assert fbuy_mock.call_count == 0 assert fbuy_mock.call_count == 0
assert inline_msg_mock.call_count == 1 assert msg_mock.call_count == 1
assert inline_msg_mock.call_args_list[0][0][0] == 'Which pair?' assert msg_mock.call_args_list[0][1]['msg'] == 'Which pair?'
assert inline_msg_mock.call_args_list[0][1]['callback_query_handler'] == 'forcebuy' # assert msg_mock.call_args_list[0][1]['callback_query_handler'] == 'forcebuy'
keyboard = inline_msg_mock.call_args_list[0][1]['keyboard'] keyboard = msg_mock.call_args_list[0][1]['keyboard']
assert reduce(lambda acc, x: acc + len(x), keyboard, 0) == 4 assert reduce(lambda acc, x: acc + len(x), keyboard, 0) == 4
update = MagicMock() update = MagicMock()
update.callback_query = MagicMock() update.callback_query = MagicMock()
@ -1569,7 +1561,7 @@ def test__sell_emoji(default_conf, mocker, msg, expected):
assert telegram._get_sell_emoji(msg) == expected assert telegram._get_sell_emoji(msg) == expected
def test__send_msg(default_conf, mocker) -> None: def test_telegram__send_msg(default_conf, mocker, caplog) -> None:
mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock()) mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock())
bot = MagicMock() bot = MagicMock()
telegram, _, _ = get_telegram_testobject(mocker, default_conf, mock=False) telegram, _, _ = get_telegram_testobject(mocker, default_conf, mock=False)
@ -1580,6 +1572,28 @@ def test__send_msg(default_conf, mocker) -> None:
telegram._send_msg('test') telegram._send_msg('test')
assert len(bot.method_calls) == 1 assert len(bot.method_calls) == 1
# Test update
query = MagicMock()
telegram._send_msg('test', callback_path="DeadBeef", query=query, reload_able=True)
edit_message_text = telegram._updater.bot.edit_message_text
assert edit_message_text.call_count == 1
assert "Updated: " in edit_message_text.call_args_list[0][1]['text']
telegram._updater.bot.edit_message_text = MagicMock(side_effect=BadRequest("not modified"))
telegram._send_msg('test', callback_path="DeadBeef", query=query)
assert telegram._updater.bot.edit_message_text.call_count == 1
assert not log_has_re(r"TelegramError: .*", caplog)
telegram._updater.bot.edit_message_text = MagicMock(side_effect=BadRequest(""))
telegram._send_msg('test2', callback_path="DeadBeef", query=query)
assert telegram._updater.bot.edit_message_text.call_count == 1
assert log_has_re(r"TelegramError: .*", caplog)
telegram._updater.bot.edit_message_text = MagicMock(side_effect=TelegramError("DeadBEEF"))
telegram._send_msg('test3', callback_path="DeadBeef", query=query)
assert log_has_re(r"TelegramError: DeadBEEF! Giving up.*", caplog)
def test__send_msg_network_error(default_conf, mocker, caplog) -> None: def test__send_msg_network_error(default_conf, mocker, caplog) -> None:
mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock()) mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock())