From ab1f9b94c5055f859a3b0183f6bdd0ff4d93d65a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:47:46 -0700 Subject: [PATCH] fix(telegram): accept @username chat_id in delivery paths (#13206) TELEGRAM_HOME_CHANNEL set to an @username (not a numeric chat ID) crashed all webhook/cron->Telegram home-channel delivery with 'ValueError: invalid literal for int()'. The Telegram Bot API accepts both a numeric chat_id and an @username string; Hermes was force-coercing every chat_id with int(). Add normalize_telegram_chat_id() (returns int for numeric values, passes @username strings through) and apply it at the Bot API send/edit sites in the Telegram adapter and the send_message tool. Username targets are now recognized as explicit targets in _parse_target_ref. Reapplies the approach from #13274 (season179), whose branch predated the gateway/platforms/telegram.py -> plugins/platforms/telegram/adapter.py relocation. Dupes: #13535 (Tranquil-Flow), #37572 (chewkaah). Co-authored-by: season179 --- plugins/platforms/telegram/adapter.py | 79 +++---- plugins/platforms/telegram/telegram_ids.py | 51 +++++ .../gateway/test_telegram_username_chat_id.py | 215 ++++++++++++++++++ tools/send_message_tool.py | 15 +- 4 files changed, 321 insertions(+), 39 deletions(-) create mode 100644 plugins/platforms/telegram/telegram_ids.py create mode 100644 tests/gateway/test_telegram_username_chat_id.py diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 732a32aa7fb..cf37684ee98 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -83,6 +83,9 @@ from gateway.platforms.base import ( _TEXT_INJECT_EXTENSIONS, utf16_len, ) +from plugins.platforms.telegram.telegram_ids import ( + normalize_telegram_chat_id, +) from plugins.platforms.telegram.telegram_network import ( TelegramFallbackTransport, discover_fallback_ips, @@ -1243,7 +1246,7 @@ class TelegramAdapter(BasePlatformAdapter): reply_to_id, thread_kwargs = routing payload: Dict[str, Any] = { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "rich_message": self._rich_message_payload(content), } # Only forward non-None routing keys: when direct_messages_topic_id is @@ -1350,7 +1353,7 @@ class TelegramAdapter(BasePlatformAdapter): semantics (the message may already be edited; do NOT legacy-resend) """ payload: Dict[str, Any] = { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "message_id": int(message_id), "rich_message": self._rich_message_payload(content), } @@ -1443,7 +1446,7 @@ class TelegramAdapter(BasePlatformAdapter): latches ``_rich_draft_disabled`` so later frames skip the rich attempt. """ payload: Dict[str, Any] = { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "draft_id": int(draft_id), "rich_message": self._rich_message_payload(content), } @@ -2079,7 +2082,7 @@ class TelegramAdapter(BasePlatformAdapter): icon_emoji = topic_conf.get("icon_custom_emoji_id") thread_id = await self._create_dm_topic( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), name=topic_name, icon_color=icon_color, icon_custom_emoji_id=icon_emoji, @@ -2098,7 +2101,7 @@ class TelegramAdapter(BasePlatformAdapter): # Empty topics are hidden by the client UI until they contain a message. try: await self._bot.send_message( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_thread_id=thread_id, text=f"\U0001f4cc {topic_name}", ) @@ -2720,7 +2723,7 @@ class TelegramAdapter(BasePlatformAdapter): # Try Markdown first, fall back to plain text if it fails try: msg = await self._bot.send_message( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), text=chunk, parse_mode=ParseMode.MARKDOWN_V2, reply_to_message_id=reply_to_id, @@ -2734,7 +2737,7 @@ class TelegramAdapter(BasePlatformAdapter): logger.warning("[%s] MarkdownV2 parse failed, falling back to plain text: %s", self.name, md_error) plain_chunk = _strip_mdv2(chunk) msg = await self._bot.send_message( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), text=plain_chunk, parse_mode=None, reply_to_message_id=reply_to_id, @@ -3002,7 +3005,7 @@ class TelegramAdapter(BasePlatformAdapter): try: if not finalize: await self._bot.edit_message_text( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), text=content, ) @@ -3011,7 +3014,7 @@ class TelegramAdapter(BasePlatformAdapter): formatted = self.format_message(content) try: await self._bot.edit_message_text( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), text=formatted, parse_mode=ParseMode.MARKDOWN_V2, @@ -3028,7 +3031,7 @@ class TelegramAdapter(BasePlatformAdapter): ) _plain = _strip_mdv2(content) if content else content await self._bot.edit_message_text( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), text=_plain, ) @@ -3053,7 +3056,7 @@ class TelegramAdapter(BasePlatformAdapter): # Mid-stream: truncate and retry instead of splitting (#48648). truncated = self._truncate_stream_overflow_preview(content) await self._bot.edit_message_text( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), text=truncated, ) @@ -3073,7 +3076,7 @@ class TelegramAdapter(BasePlatformAdapter): await asyncio.sleep(wait) try: await self._bot.edit_message_text( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), text=content, ) @@ -3177,7 +3180,7 @@ class TelegramAdapter(BasePlatformAdapter): ) try: await self._bot.edit_message_text( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), text=formatted, parse_mode=ParseMode.MARKDOWN_V2, @@ -3190,13 +3193,13 @@ class TelegramAdapter(BasePlatformAdapter): self.name, fmt_err, ) await self._bot.edit_message_text( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), text=_strip_mdv2(first_chunk), ) else: await self._bot.edit_message_text( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), text=first_chunk, ) @@ -3245,7 +3248,7 @@ class TelegramAdapter(BasePlatformAdapter): # literally); streaming previews stay raw. text = _strip_mdv2(chunk) if finalize else chunk sent_msg = await self._bot.send_message( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), text=text, parse_mode=ParseMode.MARKDOWN_V2 if use_markdown else None, reply_to_message_id=reply_to_id, @@ -3268,7 +3271,7 @@ class TelegramAdapter(BasePlatformAdapter): ) try: sent_msg = await self._bot.send_message( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), text=_strip_mdv2(chunk) if finalize else chunk, **retry_thread_kwargs, **self._link_preview_kwargs(), @@ -3351,7 +3354,7 @@ class TelegramAdapter(BasePlatformAdapter): return False try: await self._bot.delete_message( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), ) return True @@ -3433,7 +3436,7 @@ class TelegramAdapter(BasePlatformAdapter): # kills draft streaming for the whole response. for use_markdown in (True, False): kwargs: Dict[str, Any] = { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "draft_id": int(draft_id), "text": self.format_message(text) if use_markdown else text, } @@ -3533,7 +3536,7 @@ class TelegramAdapter(BasePlatformAdapter): thread_id = self._metadata_thread_id(metadata) reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode) msg = await self._send_message_with_thread_fallback( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), text=text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard, @@ -3596,7 +3599,7 @@ class TelegramAdapter(BasePlatformAdapter): ]) kwargs: Dict[str, Any] = { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "text": text, "parse_mode": ParseMode.HTML, "reply_markup": keyboard, @@ -3647,7 +3650,7 @@ class TelegramAdapter(BasePlatformAdapter): thread_id = self._metadata_thread_id(metadata) kwargs: Dict[str, Any] = { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "text": preview, "parse_mode": ParseMode.MARKDOWN_V2, "reply_markup": keyboard, @@ -3711,7 +3714,7 @@ class TelegramAdapter(BasePlatformAdapter): text += f"\n\n{option_lines}" kwargs: Dict[str, Any] = { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "text": text, "parse_mode": ParseMode.HTML, **self._link_preview_kwargs(), @@ -3795,7 +3798,7 @@ class TelegramAdapter(BasePlatformAdapter): thread_id = metadata.get("thread_id") if metadata else None reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode) msg = await self._send_message_with_thread_fallback( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), text=text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard, @@ -4755,7 +4758,7 @@ class TelegramAdapter(BasePlatformAdapter): msg = await self._send_with_dm_topic_reply_anchor_retry( self._bot.send_voice, { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "voice": audio_file, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, @@ -4781,7 +4784,7 @@ class TelegramAdapter(BasePlatformAdapter): msg = await self._send_with_dm_topic_reply_anchor_retry( self._bot.send_audio, { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "audio": audio_file, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, @@ -4920,7 +4923,7 @@ class TelegramAdapter(BasePlatformAdapter): await self._send_with_dm_topic_reply_anchor_retry( self._bot.send_media_group, { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "media": media, "reply_to_message_id": reply_to_id, **thread_kwargs, @@ -4978,7 +4981,7 @@ class TelegramAdapter(BasePlatformAdapter): msg = await self._send_with_dm_topic_reply_anchor_retry( self._bot.send_photo, { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "photo": image_file, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, @@ -5074,7 +5077,7 @@ class TelegramAdapter(BasePlatformAdapter): msg = await self._send_with_dm_topic_reply_anchor_retry( self._bot.send_document, { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "document": f, "filename": display_name, "caption": caption[:1024] if caption else None, @@ -5122,7 +5125,7 @@ class TelegramAdapter(BasePlatformAdapter): msg = await self._send_with_dm_topic_reply_anchor_retry( self._bot.send_video, { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "video": f, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, @@ -5174,7 +5177,7 @@ class TelegramAdapter(BasePlatformAdapter): msg = await self._send_with_dm_topic_reply_anchor_retry( self._bot.send_photo, { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "photo": image_url, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, @@ -5211,7 +5214,7 @@ class TelegramAdapter(BasePlatformAdapter): msg = await self._send_with_dm_topic_reply_anchor_retry( self._bot.send_photo, { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "photo": image_data, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, @@ -5258,7 +5261,7 @@ class TelegramAdapter(BasePlatformAdapter): msg = await self._send_with_dm_topic_reply_anchor_retry( self._bot.send_animation, { - "chat_id": int(chat_id), + "chat_id": normalize_telegram_chat_id(chat_id), "animation": animation_url, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, @@ -5290,7 +5293,7 @@ class TelegramAdapter(BasePlatformAdapter): _is_dm_topic = bool(metadata and metadata.get("telegram_dm_topic_reply_fallback")) message_thread_id = self._message_thread_id_for_typing(_typing_thread) await self._bot.send_chat_action( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), action="typing", message_thread_id=message_thread_id, ) @@ -5301,7 +5304,7 @@ class TelegramAdapter(BasePlatformAdapter): if _is_dm_topic and message_thread_id is not None: try: await self._bot.send_chat_action( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), action="typing", ) return @@ -5321,7 +5324,7 @@ class TelegramAdapter(BasePlatformAdapter): return {"name": "Unknown", "type": "dm"} try: - chat = await self._bot.get_chat(int(chat_id)) + chat = await self._bot.get_chat(normalize_telegram_chat_id(chat_id)) chat_type = "dm" if chat.type == ChatType.GROUP: @@ -7198,7 +7201,7 @@ class TelegramAdapter(BasePlatformAdapter): return False try: await self._bot.set_message_reaction( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), reaction=emoji, ) @@ -7219,7 +7222,7 @@ class TelegramAdapter(BasePlatformAdapter): return False try: await self._bot.set_message_reaction( - chat_id=int(chat_id), + chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), reaction=None, ) diff --git a/plugins/platforms/telegram/telegram_ids.py b/plugins/platforms/telegram/telegram_ids.py new file mode 100644 index 00000000000..8553c876b2d --- /dev/null +++ b/plugins/platforms/telegram/telegram_ids.py @@ -0,0 +1,51 @@ +"""Helpers for Telegram Bot API chat identifiers. + +Telegram's Bot API accepts a ``chat_id`` in two forms: a numeric ID (an int, +e.g. ``123456789`` for a DM or ``-1001234567890`` for a channel/supergroup) or +an ``@username`` string for public channels and groups. Hermes historically +coerced every ``chat_id`` with ``int()``, which crashes on the username form +(``ValueError: invalid literal for int()``). Normalizing here lets numeric IDs +pass through as ints while usernames pass through unchanged — both are valid +values for the Bot API. +""" + +from __future__ import annotations + +import re +from typing import Any, Union + +# Telegram usernames are 5-32 chars: letters, digits, underscores, with a +# leading "@". (Telegram also permits 4-char usernames for some legacy/official +# accounts, but the 5-32 public rule is the safe lower bound for routing.) +_TELEGRAM_USERNAME_RE = re.compile(r"@[A-Za-z0-9_]{4,32}") + + +def normalize_telegram_chat_id(chat_id: Any) -> Union[int, str]: + """Return a Bot API-compatible chat_id. + + Numeric values (incl. negative channel IDs) are returned as ``int``; any + non-numeric value (e.g. an ``@username``) is returned as a stripped string. + Telegram's Bot API accepts both, so this never raises on a username the way + a bare ``int(chat_id)`` would. + """ + chat_id_str = str(chat_id).strip() + try: + return int(chat_id_str) + except (TypeError, ValueError): + return chat_id_str + + +def telegram_chat_id_key(chat_id: Any) -> str: + """Stable string key for a chat_id (for dict keys / persisted state).""" + return str(normalize_telegram_chat_id(chat_id)) + + +def looks_like_telegram_username(chat_id: Any) -> bool: + """True when the value is an ``@username``-format Telegram chat identifier.""" + return bool(_TELEGRAM_USERNAME_RE.fullmatch(str(chat_id).strip())) + + +def parse_telegram_username_target(target_ref: Any) -> Union[str, None]: + """Return the value when it is an ``@username`` target, else ``None``.""" + value = str(target_ref).strip() + return value if looks_like_telegram_username(value) else None diff --git a/tests/gateway/test_telegram_username_chat_id.py b/tests/gateway/test_telegram_username_chat_id.py new file mode 100644 index 00000000000..d8564be9517 --- /dev/null +++ b/tests/gateway/test_telegram_username_chat_id.py @@ -0,0 +1,215 @@ +"""Tests for Telegram username (non-numeric) chat_id handling (#13206). + +When ``TELEGRAM_HOME_CHANNEL`` is an ``@username`` rather than a numeric chat +ID, webhook/cron deliveries that fall back to the home channel used to crash +with ``ValueError: invalid literal for int()`` because the adapter coerced +every chat_id with ``int()``. Telegram's Bot API accepts both forms, so the +adapter now normalizes instead of force-casting. +""" + +import sys +import types +from types import SimpleNamespace + +import pytest + +from gateway.config import PlatformConfig, Platform +from plugins.platforms.telegram.telegram_ids import ( + looks_like_telegram_username, + normalize_telegram_chat_id, + parse_telegram_username_target, + telegram_chat_id_key, +) + + +# --------------------------------------------------------------------------- +# Helper-level behavior (no telegram import needed) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "value,expected", + [ + ("123456789", 123456789), # positive numeric DM id + ("-1001234567890", -1001234567890), # negative channel/supergroup id + (123456789, 123456789), # already int + (" 42 ", 42), # surrounding whitespace + ("@some_user", "@some_user"), # username passes through as str + ("@a_channel", "@a_channel"), + ("not_numeric", "not_numeric"), # any other non-numeric string + ], +) +def test_normalize_returns_int_or_passthrough_string(value, expected): + assert normalize_telegram_chat_id(value) == expected + + +def test_normalize_never_raises_on_username(): + # A bare int() here would raise ValueError; normalize must not. + assert normalize_telegram_chat_id("@some_user") == "@some_user" + + +def test_numeric_normalizes_to_int_type(): + assert isinstance(normalize_telegram_chat_id("123"), int) + + +def test_username_normalizes_to_str_type(): + assert isinstance(normalize_telegram_chat_id("@some_user"), str) + + +@pytest.mark.parametrize( + "value,expected", + [ + ("@some_user", True), + ("@a_chan", True), + ("@abcd", True), # 4-char minimum + ("@abc", False), # too short + ("123456", False), # numeric + ("-100123", False), + ("@with space", False), + ("plain", False), + ], +) +def test_looks_like_username(value, expected): + assert looks_like_telegram_username(value) is expected + + +def test_parse_username_target(): + assert parse_telegram_username_target("@some_user") == "@some_user" + assert parse_telegram_username_target(" @some_user ") == "@some_user" + assert parse_telegram_username_target("123456") is None + assert parse_telegram_username_target("-1001234567890") is None + + +def test_chat_id_key_is_stable_string(): + assert telegram_chat_id_key("123") == "123" + assert telegram_chat_id_key(123) == "123" + assert telegram_chat_id_key("@some_user") == "@some_user" + + +# --------------------------------------------------------------------------- +# Fake telegram module tree (mirrors test_telegram_thread_fallback.py) +# --------------------------------------------------------------------------- + +class FakeNetworkError(Exception): + pass + + +class FakeBadRequest(FakeNetworkError): + pass + + +class FakeTimedOut(FakeNetworkError): + pass + + +class _FakeInlineKeyboardButton: + def __init__(self, text, callback_data=None, **kwargs): + self.text = text + self.callback_data = callback_data + + +class _FakeInlineKeyboardMarkup: + def __init__(self, inline_keyboard): + self.inline_keyboard = inline_keyboard + + +_fake_telegram = types.ModuleType("telegram") +_fake_telegram.Update = object +_fake_telegram.Bot = object +_fake_telegram.Message = object +_fake_telegram.InlineKeyboardButton = _FakeInlineKeyboardButton +_fake_telegram.InlineKeyboardMarkup = _FakeInlineKeyboardMarkup +_fake_telegram.InputMediaPhoto = object +_fake_telegram_error = types.ModuleType("telegram.error") +_fake_telegram_error.NetworkError = FakeNetworkError +_fake_telegram_error.BadRequest = FakeBadRequest +_fake_telegram_error.TimedOut = FakeTimedOut +_fake_telegram.error = _fake_telegram_error +_fake_telegram_constants = types.ModuleType("telegram.constants") +_fake_telegram_constants.ParseMode = SimpleNamespace( + MARKDOWN_V2="MarkdownV2", MARKDOWN="Markdown", HTML="HTML", +) +_fake_telegram_constants.ChatType = SimpleNamespace( + GROUP="group", SUPERGROUP="supergroup", CHANNEL="channel", PRIVATE="private", +) +_fake_telegram.constants = _fake_telegram_constants +_fake_telegram_ext = types.ModuleType("telegram.ext") +for _attr in ( + "Application", "CommandHandler", "CallbackQueryHandler", + "MessageHandler", "TypeHandler", +): + setattr(_fake_telegram_ext, _attr, object) +_fake_telegram_ext.ContextTypes = SimpleNamespace(DEFAULT_TYPE=object) +_fake_telegram_ext.filters = object +_fake_telegram_request = types.ModuleType("telegram.request") +_fake_telegram_request.HTTPXRequest = object + + +@pytest.fixture(autouse=True) +def _inject_fake_telegram(monkeypatch): + monkeypatch.setitem(sys.modules, "telegram", _fake_telegram) + monkeypatch.setitem(sys.modules, "telegram.error", _fake_telegram_error) + monkeypatch.setitem(sys.modules, "telegram.constants", _fake_telegram_constants) + monkeypatch.setitem(sys.modules, "telegram.ext", _fake_telegram_ext) + monkeypatch.setitem(sys.modules, "telegram.request", _fake_telegram_request) + + +def _make_adapter(): + from plugins.platforms.telegram.adapter import TelegramAdapter + + config = PlatformConfig(enabled=True, token="fake-token") + adapter = object.__new__(TelegramAdapter) + adapter.config = config + adapter._config = config + adapter._platform = Platform.TELEGRAM + adapter._connected = True + adapter._dm_topics = {} + adapter._dm_topics_config = [] + adapter._reply_to_mode = "first" + adapter._fallback_ips = [] + adapter._polling_conflict_count = 0 + adapter._polling_network_error_count = 0 + adapter._polling_error_callback_ref = None + adapter.platform = Platform.TELEGRAM + return adapter + + +# --------------------------------------------------------------------------- +# Adapter send path: username chat_id reaches the Bot API without int() crash +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_send_passes_username_chat_id_through_unchanged(): + """adapter.send(@username) calls the Bot API with the username string + rather than crashing on int() coercion (the #13206 regression).""" + adapter = _make_adapter() + call_log = [] + + async def mock_send_message(**kwargs): + call_log.append(dict(kwargs)) + return SimpleNamespace(message_id=99) + + adapter._bot = SimpleNamespace(send_message=mock_send_message) + + result = await adapter.send(chat_id="@some_user", content="hello world") + + assert result.success is True + assert call_log, "send_message was never called" + assert call_log[0]["chat_id"] == "@some_user" + + +@pytest.mark.asyncio +async def test_send_passes_numeric_chat_id_as_int(): + adapter = _make_adapter() + call_log = [] + + async def mock_send_message(**kwargs): + call_log.append(dict(kwargs)) + return SimpleNamespace(message_id=1) + + adapter._bot = SimpleNamespace(send_message=mock_send_message) + + result = await adapter.send(chat_id="123456789", content="hi") + + assert result.success is True + assert call_log[0]["chat_id"] == 123456789 + assert isinstance(call_log[0]["chat_id"], int) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index b654d8ff2ec..f7c32bff72e 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -478,6 +478,13 @@ def _parse_target_ref(platform_name: str, target_ref: str): match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref) if match: return match.group(1), match.group(2), True + from plugins.platforms.telegram.telegram_ids import ( + parse_telegram_username_target, + ) + + username = parse_telegram_username_target(target_ref) + if username: + return username, None, True if platform_name == "feishu": match = _FEISHU_TARGET_RE.fullmatch(target_ref) if match: @@ -1034,7 +1041,13 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No bot = Bot(token=token) else: bot = Bot(token=token) - int_chat_id = int(chat_id) + from plugins.platforms.telegram.telegram_ids import ( + normalize_telegram_chat_id, + ) + + # Telegram accepts a numeric chat_id OR an @username string; normalize + # rather than force-int so username home channels don't crash (#13206). + int_chat_id = normalize_telegram_chat_id(chat_id) media_files = media_files or [] thread_kwargs = {} if thread_id is not None: