Merge pull request #11143 from freqtrade/feat/telegram_group_topics

Support Telegram group topics, add /tg_info command
This commit is contained in:
Matthias
2024-12-24 15:29:33 +01:00
committed by GitHub
6 changed files with 98 additions and 13 deletions

View File

@@ -601,7 +601,11 @@
"type": "string" "type": "string"
}, },
"chat_id": { "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" "type": "string"
}, },
"allow_custom_messages": { "allow_custom_messages": {

View File

@@ -45,15 +45,22 @@ Get your "Id", you will use it for the config parameter `chat_id`.
#### Use Group 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 ``` 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 ```json
"chat_id": "-1001332619709" "chat_id": "-1001332619709"
@@ -62,6 +69,18 @@ For the Freqtrade configuration, you can then use the full value (including `-`
!!! Warning "Using telegram groups" !!! 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. 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 ## Control telegram noise
Freqtrade provides means to control the verbosity of your telegram bot. Freqtrade provides means to control the verbosity of your telegram bot.

View File

@@ -460,7 +460,11 @@ CONF_SCHEMA = {
}, },
"token": {"description": "Telegram bot token.", "type": "string"}, "token": {"description": "Telegram bot token.", "type": "string"},
"chat_id": { "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", "type": "string",
}, },
"allow_custom_messages": { "allow_custom_messages": {

View File

@@ -90,6 +90,7 @@ class TimeunitMappings:
def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]): def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]):
""" """
Decorator to check if the message comes from the correct chat_id 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 :param command_handler: Telegram CommandHandler
:return: decorated function :return: decorated function
""" """
@@ -102,13 +103,21 @@ def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]):
# Reject unauthorized messages # Reject unauthorized messages
if update.callback_query: if update.callback_query:
cchat_id = int(update.callback_query.message.chat.id) cchat_id = int(update.callback_query.message.chat.id)
ctopic_id = update.callback_query.message.message_thread_id
else: else:
cchat_id = int(update.message.chat_id) cchat_id = int(update.message.chat_id)
ctopic_id = update.message.message_thread_id
chat_id = int(self._config["telegram"]["chat_id"]) chat_id = int(self._config["telegram"]["chat_id"])
if cchat_id != chat_id: if cchat_id != chat_id:
logger.info(f"Rejected unauthorized message from: {update.message.chat_id}") logger.info(f"Rejected unauthorized message from: {cchat_id}")
return wrapper 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. # Rollback session to avoid getting data stored in a transaction.
Trade.rollback() Trade.rollback()
logger.debug("Executing handler: %s for chat_id: %s", command_handler.__name__, chat_id) 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("marketdir", self._changemarketdir),
CommandHandler("order", self._order), CommandHandler("order", self._order),
CommandHandler("list_custom_data", self._list_custom_data), CommandHandler("list_custom_data", self._list_custom_data),
CommandHandler("tg_info", self._tg_info),
] ]
callbacks = [ callbacks = [
CallbackQueryHandler(self._status_table, pattern="update_status_table"), CallbackQueryHandler(self._status_table, pattern="update_status_table"),
@@ -2054,6 +2064,7 @@ class Telegram(RPCHandler):
parse_mode=parse_mode, parse_mode=parse_mode,
reply_markup=reply_markup, reply_markup=reply_markup,
disable_notification=disable_notification, disable_notification=disable_notification,
message_thread_id=self._config["telegram"].get("topic_id"),
) )
except NetworkError as network_err: except NetworkError as network_err:
# Sometimes the telegram server resets the current connection, # Sometimes the telegram server resets the current connection,
@@ -2067,6 +2078,7 @@ class Telegram(RPCHandler):
parse_mode=parse_mode, parse_mode=parse_mode,
reply_markup=reply_markup, reply_markup=reply_markup,
disable_notification=disable_notification, disable_notification=disable_notification,
message_thread_id=self._config["telegram"].get("topic_id"),
) )
except TelegramError as telegram_err: except TelegramError as telegram_err:
logger.warning("TelegramError: %s! Giving up on that message.", telegram_err.message) 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" "Invalid usage of command /marketdir. \n"
"Usage: */marketdir [short | long | even | none]*" "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)

View File

@@ -625,7 +625,7 @@ def get_default_conf(testdatadir):
"telegram": { "telegram": {
"enabled": False, "enabled": False,
"token": "token", "token": "token",
"chat_id": "0", "chat_id": "1235",
"notification_settings": {}, "notification_settings": {},
}, },
"datadir": Path(testdatadir), "datadir": Path(testdatadir),

View File

@@ -67,7 +67,7 @@ def default_conf(default_conf) -> dict:
@pytest.fixture @pytest.fixture
def update(): 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) _update = Update(0, message=message)
return _update return _update
@@ -167,7 +167,7 @@ def test_telegram_init(default_conf, mocker, caplog) -> None:
"['stopbuy', 'stopentry'], ['whitelist'], ['blacklist'], " "['stopbuy', 'stopentry'], ['whitelist'], ['blacklist'], "
"['bl_delete', 'blacklist_delete'], " "['bl_delete', 'blacklist_delete'], "
"['logs'], ['edge'], ['health'], ['help'], ['version'], ['marketdir'], " "['logs'], ['edge'], ['health'], ['help'], ['version'], ['marketdir'], "
"['order'], ['list_custom_data']]" "['order'], ['list_custom_data'], ['tg_info']]"
) )
assert log_has(message_str, caplog) 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) patch_get_signal(bot)
await dummy.dummy_handler(update=update, context=MagicMock()) await dummy.dummy_handler(update=update, context=MagicMock())
assert dummy.state["called"] is True assert dummy.state["called"] is True
assert log_has("Executing handler: dummy_handler for chat_id: 0", caplog) assert log_has("Executing handler: dummy_handler for chat_id: 1235", caplog)
assert not log_has("Rejected unauthorized message from: 0", caplog) assert not log_has("Rejected unauthorized message from: 1235", caplog)
assert not log_has("Exception occurred within Telegram module", 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": "roi"}) == "off"
assert loudness({"type": RPCMessageType.EXIT, "exit_reason": "partial_exit"}) == "off" assert loudness({"type": RPCMessageType.EXIT, "exit_reason": "partial_exit"}) == "off"
assert loudness({"type": RPCMessageType.EXIT, "exit_reason": "cust_exit112"}) == "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