diff --git a/build_helpers/schema.json b/build_helpers/schema.json index 73c06ba46..5c9844975 100644 --- a/build_helpers/schema.json +++ b/build_helpers/schema.json @@ -601,7 +601,11 @@ "type": "string" }, "chat_id": { - "description": "Telegram chat ID", + "description": "Telegram chat or group ID", + "type": "string" + }, + "topic_id": { + "description": "Telegram topic ID - only applicable for group chats", "type": "string" }, "allow_custom_messages": { diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index 3fc76d58f..43ab66cbf 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -45,15 +45,22 @@ Get your "Id", you will use it for the config parameter `chat_id`. #### Use Group id -You can use bots in telegram groups by just adding them to the group. You can find the group id by first adding a [RawDataBot](https://telegram.me/rawdatabot) to your group. The Group id is shown as id in the `"chat"` section, which the RawDataBot will send to you: +To get the group ID, you can add the bot to the group, start freqtrade, and issue a `/tg_info` command. +This will return the group id to you, without having to use some random bot. +While "chat_id" is still required, it doesn't need to be set to this particular group id for this command. + +The response will also contain the "topic_id" if necessary - both in a format ready to copy/paste into your configuration. ``` json -"chat":{ - "id":-1001332619709 + { + "enabled": true, + "token": "********", + "chat_id": "-1001332619709", + "topic_id": "122" } ``` -For the Freqtrade configuration, you can then use the full value (including `-` if it's there) as string: +For the Freqtrade configuration, you can then use the full value (including `-` ) as string: ```json "chat_id": "-1001332619709" @@ -62,6 +69,18 @@ For the Freqtrade configuration, you can then use the full value (including `-` !!! Warning "Using telegram groups" When using telegram groups, you're giving every member of the telegram group access to your freqtrade bot and to all commands possible via telegram. Please make sure that you can trust everyone in the telegram group to avoid unpleasant surprises. +##### Group Topic ID + +To use a specific topic in a group, you can use the `topic_id` parameter in the configuration. This will allow you to use the bot in a specific topic in a group. +Without this, the bot will always respond to the general channel in the group if topics are enabled for a group chat. + +```json + "chat_id": "-1001332619709", + "topic_id": "3" +``` + +Similar to the group-id - you can use `/tg_info` from the topic/thread to get the correct topic-id. + ## Control telegram noise Freqtrade provides means to control the verbosity of your telegram bot. diff --git a/freqtrade/configuration/config_schema.py b/freqtrade/configuration/config_schema.py index d31069e7b..9af3f0950 100644 --- a/freqtrade/configuration/config_schema.py +++ b/freqtrade/configuration/config_schema.py @@ -460,7 +460,11 @@ CONF_SCHEMA = { }, "token": {"description": "Telegram bot token.", "type": "string"}, "chat_id": { - "description": "Telegram chat ID", + "description": "Telegram chat or group ID", + "type": "string", + }, + "topic_id": { + "description": "Telegram topic ID - only applicable for group chats", "type": "string", }, "allow_custom_messages": { diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 1b5653b1d..70cf6fe72 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -90,6 +90,7 @@ class TimeunitMappings: def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]): """ Decorator to check if the message comes from the correct chat_id + can only be used with Telegram Class to decorate instance methods. :param command_handler: Telegram CommandHandler :return: decorated function """ @@ -102,13 +103,21 @@ def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]): # Reject unauthorized messages if update.callback_query: cchat_id = int(update.callback_query.message.chat.id) + ctopic_id = update.callback_query.message.message_thread_id else: cchat_id = int(update.message.chat_id) + ctopic_id = update.message.message_thread_id chat_id = int(self._config["telegram"]["chat_id"]) if cchat_id != chat_id: - logger.info(f"Rejected unauthorized message from: {update.message.chat_id}") - return wrapper + logger.info(f"Rejected unauthorized message from: {cchat_id}") + return None + if (topic_id := self._config["telegram"].get("topic_id")) is not None: + if str(ctopic_id) != topic_id: + # This can be quite common in multi-topic environments. + logger.debug(f"Rejected message from wrong channel: {cchat_id}, {ctopic_id}") + return None + # Rollback session to avoid getting data stored in a transaction. Trade.rollback() logger.debug("Executing handler: %s for chat_id: %s", command_handler.__name__, chat_id) @@ -291,6 +300,7 @@ class Telegram(RPCHandler): CommandHandler("marketdir", self._changemarketdir), CommandHandler("order", self._order), CommandHandler("list_custom_data", self._list_custom_data), + CommandHandler("tg_info", self._tg_info), ] callbacks = [ CallbackQueryHandler(self._status_table, pattern="update_status_table"), @@ -2054,6 +2064,7 @@ class Telegram(RPCHandler): parse_mode=parse_mode, reply_markup=reply_markup, disable_notification=disable_notification, + message_thread_id=self._config["telegram"].get("topic_id"), ) except NetworkError as network_err: # Sometimes the telegram server resets the current connection, @@ -2067,6 +2078,7 @@ class Telegram(RPCHandler): parse_mode=parse_mode, reply_markup=reply_markup, disable_notification=disable_notification, + message_thread_id=self._config["telegram"].get("topic_id"), ) except TelegramError as telegram_err: logger.warning("TelegramError: %s! Giving up on that message.", telegram_err.message) @@ -2112,3 +2124,37 @@ class Telegram(RPCHandler): "Invalid usage of command /marketdir. \n" "Usage: */marketdir [short | long | even | none]*" ) + + async def _tg_info(self, update: Update, context: CallbackContext) -> None: + """ + Intentionally unauthenticated Handler for /tg_info. + Returns information about the current telegram chat - even if chat_id does not + correspond to this chat. + + :param update: message update + :return: None + """ + if not update.message: + return + chat_id = update.message.chat_id + topic_id = update.message.message_thread_id + + msg = f"""Freqtrade Bot Info: + ```json + {{ + "enabled": true, + "token": "********", + "chat_id": "{chat_id}", + {f'"topic_id": "{topic_id}"' if topic_id else ""} + }} + ``` + """ + try: + await context.bot.send_message( + chat_id=chat_id, + text=msg, + parse_mode=ParseMode.MARKDOWN_V2, + message_thread_id=topic_id, + ) + except TelegramError as telegram_err: + logger.warning("TelegramError: %s! Giving up on that message.", telegram_err.message) diff --git a/tests/conftest.py b/tests/conftest.py index 69ea8878b..a66bd3430 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -625,7 +625,7 @@ def get_default_conf(testdatadir): "telegram": { "enabled": False, "token": "token", - "chat_id": "0", + "chat_id": "1235", "notification_settings": {}, }, "datadir": Path(testdatadir), diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index b71b3975e..5cf689da9 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -67,7 +67,7 @@ def default_conf(default_conf) -> dict: @pytest.fixture def update(): - message = Message(0, datetime.now(timezone.utc), Chat(0, 0)) + message = Message(0, datetime.now(timezone.utc), Chat(1235, 0)) _update = Update(0, message=message) return _update @@ -167,7 +167,7 @@ def test_telegram_init(default_conf, mocker, caplog) -> None: "['stopbuy', 'stopentry'], ['whitelist'], ['blacklist'], " "['bl_delete', 'blacklist_delete'], " "['logs'], ['edge'], ['health'], ['help'], ['version'], ['marketdir'], " - "['order'], ['list_custom_data']]" + "['order'], ['list_custom_data'], ['tg_info']]" ) assert log_has(message_str, caplog) @@ -224,8 +224,8 @@ async def test_authorized_only(default_conf, mocker, caplog, update) -> None: patch_get_signal(bot) await dummy.dummy_handler(update=update, context=MagicMock()) assert dummy.state["called"] is True - assert log_has("Executing handler: dummy_handler for chat_id: 0", caplog) - assert not log_has("Rejected unauthorized message from: 0", caplog) + assert log_has("Executing handler: dummy_handler for chat_id: 1235", caplog) + assert not log_has("Rejected unauthorized message from: 1235", caplog) assert not log_has("Exception occurred within Telegram module", caplog) @@ -2967,3 +2967,15 @@ def test_noficiation_settings(default_conf_usdt, mocker): assert loudness({"type": RPCMessageType.EXIT, "exit_reason": "roi"}) == "off" assert loudness({"type": RPCMessageType.EXIT, "exit_reason": "partial_exit"}) == "off" assert loudness({"type": RPCMessageType.EXIT, "exit_reason": "cust_exit112"}) == "off" + + +async def test__tg_info(default_conf_usdt, mocker, update): + (telegram, _, _) = get_telegram_testobject(mocker, default_conf_usdt) + context = AsyncMock() + + await telegram._tg_info(update, context) + + assert context.bot.send_message.call_count == 1 + content = context.bot.send_message.call_args[1]["text"] + assert "Freqtrade Bot Info:\n" in content + assert '"chat_id": "1235"' in content