diff --git a/config_full.json.example b/config_full.json.example index 6aeb756f3..bc9f33f96 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -178,7 +178,9 @@ "sell_fill": "on", "buy_cancel": "on", "sell_cancel": "on" - } + }, + "reload": true, + "balance_dust_level": 0.01 }, "api_server": { "enabled": false, diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index 87ff38881..f5d9744b4 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -95,6 +95,7 @@ Example configuration showing the different settings: "buy_fill": "off", "sell_fill": "off" }, + "reload": true, "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. +`reload` allows you to disable reload-buttons on selected messages. ## Create a custom keyboard (command shortcut buttons) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 259aa0e03..013e9df41 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -275,7 +275,8 @@ CONF_SCHEMA = { 'default': 'off' }, } - } + }, + 'reload': {'type': 'boolean'}, }, 'required': ['enabled', 'token', 'chat_id'], }, diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index aee513017..6cb48aef1 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -10,13 +10,13 @@ from datetime import date, datetime, timedelta from html import escape from itertools import chain 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 from tabulate import tabulate -from telegram import (InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ParseMode, - ReplyKeyboardMarkup, Update) -from telegram.error import NetworkError, TelegramError +from telegram import (CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, + ParseMode, ReplyKeyboardMarkup, Update) +from telegram.error import BadRequest, NetworkError, TelegramError from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler, Updater 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] # 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( 'Rejected unauthorized message from: %s', update.message.chat_id @@ -91,7 +95,7 @@ class Telegram(RPCHandler): Validates the keyboard configuration from telegram config section. """ - self._keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = [ + self._keyboard: List[List[Union[str, KeyboardButton]]] = [ ['/daily', '/profit', '/balance'], ['/status', '/status table', '/performance'], ['/count', '/start', '/stop', '/help'] @@ -164,8 +168,21 @@ class Telegram(RPCHandler): CommandHandler('help', self._help), 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: self._updater.dispatcher.add_handler(handle) + + for callback in callbacks: + self._updater.dispatcher.add_handler(callback) + self._updater.start_polling( bootstrap_retries=-1, timeout=30, @@ -177,11 +194,6 @@ class Telegram(RPCHandler): [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: """ Stops all running telegram threads. @@ -409,7 +421,9 @@ class Telegram(RPCHandler): # insert separators line between Total lines = message.split("\n") message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]]) - self._send_msg(f"
{message}
", parse_mode=ParseMode.HTML) + self._send_msg(f"
{message}
", parse_mode=ParseMode.HTML, + reload_able=True, callback_path="update_status_table", + query=update.callback_query) except RPCException as e: self._send_msg(str(e)) @@ -447,7 +461,8 @@ class Telegram(RPCHandler): ], tablefmt='simple') message = f'Daily Profit over the last {timescale} days:\n
{stats_tab}
' - 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: self._send_msg(str(e)) @@ -519,7 +534,8 @@ class Telegram(RPCHandler): if stats['closed_trade_count'] > 0: markdown_msg += (f"\n*Avg. Duration:* `{avg_duration}`\n" 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 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['symbol']}: " 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: self._send_msg(str(e)) @@ -713,10 +730,10 @@ class Telegram(RPCHandler): self._forcebuy_action(pair, price) else: whitelist = self._rpc._rpc_whitelist()['whitelist'] - pairs = [InlineKeyboardButton(pair, callback_data=pair) for pair in whitelist] - self._send_inline_msg("Which pair?", - keyboard=self._layout_inline_keyboard(pairs), - callback_query_handler='forcebuy') + pairs = [InlineKeyboardButton(text=pair, callback_data=pair) for pair in whitelist] + + self._send_msg(msg="Which pair?", + keyboard=self._layout_inline_keyboard(pairs)) @authorized_only def _trades(self, update: Update, context: CallbackContext) -> None: @@ -800,7 +817,9 @@ class Telegram(RPCHandler): else: 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: self._send_msg(str(e)) @@ -820,7 +839,9 @@ class Telegram(RPCHandler): tablefmt='simple') message = "
{}
".format(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: self._send_msg(str(e)) @@ -1052,29 +1073,42 @@ class Telegram(RPCHandler): f"*Current state:* `{val['state']}`" ) - def _send_inline_msg(self, msg: str, callback_query_handler, - parse_mode: str = ParseMode.MARKDOWN, disable_notification: bool = False, - keyboard: List[List[InlineKeyboardButton]] = None, ) -> None: - """ - Send given markdown message - :param msg: message - :param bot: alternative bot - :param parse_mode: telegram parse mode - :return: None - """ - if self._current_callback_query_handler: - self._updater.dispatcher.remove_handler(self._current_callback_query_handler) - self._current_callback_query_handler = self._callback_query_handlers[callback_query_handler] - self._updater.dispatcher.add_handler(self._current_callback_query_handler) + def _update_msg(self, query: CallbackQuery, msg: str, callback_path: str = "", + reload_able: bool = False, parse_mode: str = ParseMode.MARKDOWN) -> None: + if reload_able: + reply_markup = InlineKeyboardMarkup([ + [InlineKeyboardButton("Refresh", callback_data=callback_path)], + ]) + else: + reply_markup = InlineKeyboardMarkup([[]]) + msg += "\nUpdated: {}".format(datetime.now().ctime()) + if not query.message: + return + chat_id = query.message.chat_id + message_id = query.message.message_id - self._send_msg(msg, parse_mode, disable_notification, - cast(List[List[Union[str, KeyboardButton, InlineKeyboardButton]]], keyboard), - reply_markup=InlineKeyboardMarkup) + try: + self._updater.bot.edit_message_text( + 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, disable_notification: bool = False, - keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = None, - reply_markup=ReplyKeyboardMarkup) -> None: + keyboard: List[List[InlineKeyboardButton]] = None, + callback_path: str = "", + reload_able: bool = False, + query: Optional[CallbackQuery] = None) -> None: """ Send given markdown message :param msg: message @@ -1082,9 +1116,19 @@ class Telegram(RPCHandler): :param parse_mode: telegram parse mode :return: None """ - if keyboard is None: - keyboard = self._keyboard - reply_markup = reply_markup(keyboard, resize_keyboard=True) + reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup] + if query: + 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: self._updater.bot.send_message( diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index d091f3837..39ef6a1ab 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -13,7 +13,7 @@ from unittest.mock import ANY, MagicMock import arrow import pytest 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.constants import CANCEL_REASON @@ -25,8 +25,8 @@ from freqtrade.loggers import setup_logging from freqtrade.persistence import PairLocks, Trade from freqtrade.rpc import RPC from freqtrade.rpc.telegram import Telegram, authorized_only -from tests.conftest import (create_mock_trades, get_patched_freqtradebot, log_has, patch_exchange, - patch_get_signal, patch_whitelist) +from tests.conftest import (create_mock_trades, get_patched_freqtradebot, log_has, log_has_re, + patch_exchange, patch_get_signal, patch_whitelist) class DummyCls(Telegram): @@ -55,14 +55,6 @@ class DummyCls(Telegram): 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): msg_mock = MagicMock() if mock: @@ -920,8 +912,8 @@ def test_forcebuy_no_pair(default_conf, update, mocker) -> None: fbuy_mock = MagicMock(return_value=None) mocker.patch('freqtrade.rpc.RPC._rpc_forcebuy', fbuy_mock) - telegram, freqtradebot, _, inline_msg_mock = get_telegram_testobject_with_inline(mocker, - default_conf) + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + patch_get_signal(freqtradebot, (True, False)) context = MagicMock() @@ -929,10 +921,10 @@ def test_forcebuy_no_pair(default_conf, update, mocker) -> None: telegram._forcebuy(update=update, context=context) assert fbuy_mock.call_count == 0 - assert inline_msg_mock.call_count == 1 - assert inline_msg_mock.call_args_list[0][0][0] == 'Which pair?' - assert inline_msg_mock.call_args_list[0][1]['callback_query_handler'] == 'forcebuy' - keyboard = inline_msg_mock.call_args_list[0][1]['keyboard'] + assert msg_mock.call_count == 1 + assert msg_mock.call_args_list[0][1]['msg'] == 'Which pair?' + # assert msg_mock.call_args_list[0][1]['callback_query_handler'] == 'forcebuy' + keyboard = msg_mock.call_args_list[0][1]['keyboard'] assert reduce(lambda acc, x: acc + len(x), keyboard, 0) == 4 update = 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 -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()) bot = MagicMock() 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') 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: mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock())