fix(telegram): restore typing indicator and thread routing for forum General topic

In Telegram forum-enabled groups, the General topic does not include
message_thread_id in incoming messages (it is None). This caused:
1. Messages in General losing thread context — replies went to wrong place
2. Typing indicator failing because thread_id=1 was rejected by Telegram

Fix: synthesize thread_id="1" for forum groups when message_thread_id
is None, then handle it correctly per operation:
- send: omit message_thread_id (Telegram rejects thread_id=1 for sends)
- typing: pass thread_id=1, retry without it on "thread not found"

Also centralizes thread_id extraction into _metadata_thread_id() across
all send methods (send, send_voice, send_image, send_document, send_video,
send_animation, send_photo), replacing ~10 duplicate patterns.

Salvaged from PR #7892 by @corazzione.
Closes #7877, closes #7519.
This commit is contained in:
Markus Corazzione 2026-04-15 22:25:54 -07:00 committed by Teknium
parent 3ff18ffe14
commit 0cf7d570e2
2 changed files with 164 additions and 30 deletions

View file

@ -45,6 +45,11 @@ class FakeRetryAfter(Exception):
# Build a fake telegram module tree so the adapter's internal imports work
_fake_telegram = types.ModuleType("telegram")
_fake_telegram.Update = object
_fake_telegram.Bot = object
_fake_telegram.Message = object
_fake_telegram.InlineKeyboardButton = object
_fake_telegram.InlineKeyboardMarkup = object
_fake_telegram_error = types.ModuleType("telegram.error")
_fake_telegram_error.NetworkError = FakeNetworkError
_fake_telegram_error.BadRequest = FakeBadRequest
@ -52,7 +57,21 @@ _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")
_fake_telegram_constants.ChatType = SimpleNamespace(
GROUP="group",
SUPERGROUP="supergroup",
CHANNEL="channel",
)
_fake_telegram.constants = _fake_telegram_constants
_fake_telegram_ext = types.ModuleType("telegram.ext")
_fake_telegram_ext.Application = object
_fake_telegram_ext.CommandHandler = object
_fake_telegram_ext.CallbackQueryHandler = object
_fake_telegram_ext.MessageHandler = 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)
@ -61,6 +80,8 @@ 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():
@ -68,6 +89,7 @@ def _make_adapter():
config = PlatformConfig(enabled=True, token="fake-token")
adapter = object.__new__(TelegramAdapter)
adapter.config = config
adapter._config = config
adapter._platform = Platform.TELEGRAM
adapter._connected = True
@ -82,6 +104,81 @@ def _make_adapter():
return adapter
def test_forum_general_topic_without_message_thread_id_keeps_thread_context():
"""Forum General-topic messages should keep synthetic thread context."""
from gateway.platforms import telegram as telegram_mod
adapter = _make_adapter()
message = SimpleNamespace(
text="hello from General",
caption=None,
chat=SimpleNamespace(
id=-100123,
type=telegram_mod.ChatType.SUPERGROUP,
is_forum=True,
title="Forum group",
),
from_user=SimpleNamespace(id=456, full_name="Alice"),
message_thread_id=None,
reply_to_message=None,
message_id=10,
date=None,
)
event = adapter._build_message_event(message, msg_type=SimpleNamespace(value="text"))
assert event.source.chat_id == "-100123"
assert event.source.chat_type == "group"
assert event.source.thread_id == "1"
@pytest.mark.asyncio
async def test_send_omits_general_topic_thread_id():
"""Telegram sends to forum General should omit message_thread_id=1."""
adapter = _make_adapter()
call_log = []
async def mock_send_message(**kwargs):
call_log.append(dict(kwargs))
return SimpleNamespace(message_id=42)
adapter._bot = SimpleNamespace(send_message=mock_send_message)
result = await adapter.send(
chat_id="-100123",
content="test message",
metadata={"thread_id": "1"},
)
assert result.success is True
assert len(call_log) == 1
assert call_log[0]["chat_id"] == -100123
assert call_log[0]["text"] == "test message"
assert call_log[0]["reply_to_message_id"] is None
assert call_log[0]["message_thread_id"] is None
@pytest.mark.asyncio
async def test_send_typing_retries_without_general_thread_when_not_found():
"""Typing for forum General should fall back if Telegram rejects thread 1."""
adapter = _make_adapter()
call_log = []
async def mock_send_chat_action(**kwargs):
call_log.append(dict(kwargs))
if kwargs.get("message_thread_id") == 1:
raise FakeBadRequest("Message thread not found")
adapter._bot = SimpleNamespace(send_chat_action=mock_send_chat_action)
await adapter.send_typing("-100123", metadata={"thread_id": "1"})
assert call_log == [
{"chat_id": -100123, "action": "typing", "message_thread_id": 1},
{"chat_id": -100123, "action": "typing", "message_thread_id": None},
]
@pytest.mark.asyncio
async def test_send_retries_without_thread_on_thread_not_found():
"""When message_thread_id causes 'thread not found', retry without it."""