mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
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 <season.saw@gmail.com>
This commit is contained in:
parent
f2ca3e3d84
commit
ab1f9b94c5
4 changed files with 321 additions and 39 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
51
plugins/platforms/telegram/telegram_ids.py
Normal file
51
plugins/platforms/telegram/telegram_ids.py
Normal file
|
|
@ -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
|
||||
215
tests/gateway/test_telegram_username_chat_id.py
Normal file
215
tests/gateway/test_telegram_username_chat_id.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue