diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index 965d16d87..40481684d 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -87,6 +87,41 @@ Example configuration showing the different settings: }, ``` +## Create a custom keyboard (command shortcut buttons) + +Telegram allows us to create a custom keyboard with buttons for commands. +The default custom keyboard looks like this. + +```python +[ + ["/daily", "/profit", "/balance"], # row 1, 3 commands + ["/status", "/status table", "/performance"], # row 2, 3 commands + ["/count", "/start", "/stop", "/help"] # row 3, 4 commands +] +``` + +### Usage + +You can create your own keyboard in `config.json`: + +``` json +"telegram": { + "enabled": true, + "token": "your_telegram_token", + "chat_id": "your_telegram_chat_id", + "keyboard": [ + ["/daily", "/stats", "/balance", "/profit"], + ["/status table", "/performance"], + ["/reload_config", "/count", "/logs"] + ] + }, +``` + +!!! Note "Supported Commands" + Only the following commands are allowed. Command arguments are not supported! + + `/start`, `/stop`, `/status`, `/status table`, `/trades`, `/profit`, `/performance`, `/daily`, `/stats`, `/count`, `/locks`, `/balance`, `/stopbuy`, `/reload_config`, `/show_config`, `/logs`, `/whitelist`, `/blacklist`, `/edge`, `/help`, `/version` + ## Telegram commands Per default, the Telegram bot shows predefined commands. Some commands diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 1d36b7e4d..e2985fbee 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -6,6 +6,7 @@ This module manage Telegram communication import json import logging from datetime import timedelta +from itertools import chain from typing import Any, Callable, Dict, List, Union import arrow @@ -16,6 +17,7 @@ from telegram.ext import CallbackContext, CommandHandler, Updater from telegram.utils.helpers import escape_markdown from freqtrade.__init__ import __version__ +from freqtrade.exceptions import OperationalException from freqtrade.rpc import RPC, RPCException, RPCMessageType from freqtrade.rpc.fiat_convert import CryptoToFiatConverter @@ -74,10 +76,49 @@ class Telegram(RPC): self._updater: Updater self._config = freqtrade.config + self._init_keyboard() self._init() if self._config.get('fiat_display_currency', None): self._fiat_converter = CryptoToFiatConverter() + def _init_keyboard(self) -> None: + """ + Validates the keyboard configuration from telegram config + section. + """ + self._keyboard: List[List[Union[str, KeyboardButton]]] = [ + ['/daily', '/profit', '/balance'], + ['/status', '/status table', '/performance'], + ['/count', '/start', '/stop', '/help'] + ] + # do not allow commands with mandatory arguments and critical cmds + # like /forcesell and /forcebuy + # TODO: DRY! - its not good to list all valid cmds here. But otherwise + # this needs refacoring of the whole telegram module (same + # problem in _help()). + valid_keys: List[str] = ['/start', '/stop', '/status', '/status table', + '/trades', '/profit', '/performance', '/daily', + '/stats', '/count', '/locks', '/balance', + '/stopbuy', '/reload_config', '/show_config', + '/logs', '/whitelist', '/blacklist', '/edge', + '/help', '/version'] + + # custom keyboard specified in config.json + cust_keyboard = self._config['telegram'].get('keyboard', []) + if cust_keyboard: + # check for valid shortcuts + invalid_keys = [b for b in chain.from_iterable(cust_keyboard) + if b not in valid_keys] + if len(invalid_keys): + err_msg = ('config.telegram.keyboard: Invalid commands for ' + f'custom Telegram keyboard: {invalid_keys}' + f'\nvalid commands are: {valid_keys}') + raise OperationalException(err_msg) + else: + self._keyboard = cust_keyboard + logger.info('using custom keyboard from ' + f'config.json: {self._keyboard}') + def _init(self) -> None: """ Initializes this module with the given config, @@ -861,15 +902,7 @@ class Telegram(RPC): :param parse_mode: telegram parse mode :return: None """ - - keyboard: List[List[Union[str, KeyboardButton]]] = [ - ['/daily', '/profit', '/balance'], - ['/status', '/status table', '/performance'], - ['/count', '/start', '/stop', '/help'] - ] - - reply_markup = ReplyKeyboardMarkup(keyboard) - + reply_markup = ReplyKeyboardMarkup(self._keyboard) try: try: self._updater.bot.send_message( diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index ecad05683..b8c5d8858 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -10,12 +10,13 @@ from unittest.mock import ANY, MagicMock, PropertyMock import arrow import pytest -from telegram import Chat, Message, Update +from telegram import Chat, Message, ReplyKeyboardMarkup, Update from telegram.error import NetworkError from freqtrade import __version__ from freqtrade.constants import CANCEL_REASON from freqtrade.edge import PairInfo +from freqtrade.exceptions import OperationalException from freqtrade.freqtradebot import FreqtradeBot from freqtrade.loggers import setup_logging from freqtrade.persistence import PairLocks, Trade @@ -1729,3 +1730,53 @@ def test__send_msg_network_error(default_conf, mocker, caplog) -> None: # Bot should've tried to send it twice assert len(bot.method_calls) == 2 assert log_has('Telegram NetworkError: Oh snap! Trying one more time.', caplog) + + +def test__send_msg_keyboard(default_conf, mocker, caplog) -> None: + mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock()) + bot = MagicMock() + bot.send_message = MagicMock() + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + + invalid_keys_list = [['/not_valid', '/profit'], ['/daily'], ['/alsoinvalid']] + default_keys_list = [['/daily', '/profit', '/balance'], + ['/status', '/status table', '/performance'], + ['/count', '/start', '/stop', '/help']] + default_keyboard = ReplyKeyboardMarkup(default_keys_list) + + custom_keys_list = [['/daily', '/stats', '/balance', '/profit'], + ['/count', '/start', '/reload_config', '/help']] + custom_keyboard = ReplyKeyboardMarkup(custom_keys_list) + + def init_telegram(freqtradebot): + telegram = Telegram(freqtradebot) + telegram._updater = MagicMock() + telegram._updater.bot = bot + return telegram + + # no keyboard in config -> default keyboard + freqtradebot.config['telegram']['enabled'] = True + telegram = init_telegram(freqtradebot) + telegram._send_msg('test') + used_keyboard = bot.send_message.call_args[1]['reply_markup'] + assert used_keyboard == default_keyboard + + # invalid keyboard in config -> default keyboard + freqtradebot.config['telegram']['enabled'] = True + freqtradebot.config['telegram']['keyboard'] = invalid_keys_list + err_msg = re.escape("config.telegram.keyboard: Invalid commands for custom " + "Telegram keyboard: ['/not_valid', '/alsoinvalid']" + "\nvalid commands are: ") + r"*" + with pytest.raises(OperationalException, match=err_msg): + telegram = init_telegram(freqtradebot) + + # valid keyboard in config -> custom keyboard + freqtradebot.config['telegram']['enabled'] = True + freqtradebot.config['telegram']['keyboard'] = custom_keys_list + telegram = init_telegram(freqtradebot) + telegram._send_msg('test') + used_keyboard = bot.send_message.call_args[1]['reply_markup'] + assert used_keyboard == custom_keyboard + assert log_has("using custom keyboard from config.json: " + "[['/daily', '/stats', '/balance', '/profit'], ['/count', " + "'/start', '/reload_config', '/help']]", caplog)