fix(telegram): build command menu off event loop

This commit is contained in:
KoNit-K
2026-09-14 16:43:23 +08:00
committed by Teknium
parent 14bd358f5e
commit 0a5846d37f
2 changed files with 59 additions and 3 deletions
+14 -3
View File
@@ -39,6 +39,19 @@ def _redact_telegram_error_text(error: object) -> str:
return "<telegram error redacted>"
def _build_telegram_command_menu() -> tuple[list[tuple[str, str]], int, int]:
"""Build the Telegram menu off the gateway event loop.
Skill discovery resolves configured paths and can touch a slow filesystem, so it
must not run inline with reconnect housekeeping.
"""
from hermes_cli.commands_platforms import telegram_menu_commands, telegram_menu_max_commands
max_commands = telegram_menu_max_commands()
menu_commands, hidden_count = telegram_menu_commands(max_commands=max_commands)
return menu_commands, hidden_count, max_commands
def _consume_abandoned_task(task: asyncio.Task) -> None:
"""Observe a detached task's terminal exception to avoid noisy loop logs."""
try:
@@ -2586,12 +2599,10 @@ class TelegramAdapter(BasePlatformAdapter):
"""Register the command menu (from COMMAND_REGISTRY) in every scope — Telegram picks the
narrowest matching one per chat type; forum topics are handled lazily by _ensure_forum_commands."""
from telegram import BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats, BotCommandScopeDefault
from hermes_cli.commands_platforms import telegram_menu_commands, telegram_menu_max_commands
if not self._bot:
return
# Telegram allows 100 commands but has an undocumented ~4KB payload limit; default cap 60.
max_commands = telegram_menu_max_commands()
menu_commands, hidden_count = telegram_menu_commands(max_commands=max_commands)
menu_commands, hidden_count, max_commands = await asyncio.to_thread(_build_telegram_command_menu)
bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]
for scope_cls in (BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats):
scope_name = getattr(scope_cls, "__name__", str(scope_cls))
@@ -1,6 +1,7 @@
"""Tests for lazy forum command registration in TelegramAdapter."""
import asyncio
import threading
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@@ -83,3 +84,47 @@ async def test_ensure_forum_commands_race_safety():
# The lock should make this exactly 1 call, not 2.
assert adapter._bot.set_my_commands.await_count == 1
@pytest.mark.asyncio
async def test_register_command_menu_builds_skill_menu_off_event_loop(monkeypatch):
"""A slow skill scan must not starve Telegram's reconnect event loop."""
import telegram
adapter = _make_test_adapter()
scan_started = threading.Event()
release_scan = threading.Event()
loop_progressed = threading.Event()
observed = {}
def _blocking_menu(*, max_commands):
assert max_commands == 60
scan_started.set()
release_scan.wait(timeout=1)
observed["loop_progressed"] = loop_progressed.is_set()
return [("help", "Show help"), ("skill", "Run a skill")], 0
monkeypatch.setattr("hermes_cli.commands_platforms.telegram_menu_max_commands", lambda: 60)
monkeypatch.setattr("hermes_cli.commands_platforms.telegram_menu_commands", _blocking_menu)
monkeypatch.setattr(
telegram, "BotCommand", lambda command, description: SimpleNamespace(command=command, description=description))
# This releases a pre-fix synchronous scan so the regression test cannot hang the suite.
safety_release = threading.Timer(0.2, release_scan.set)
safety_release.start()
task = asyncio.create_task(adapter._register_command_menu())
try:
await asyncio.to_thread(scan_started.wait)
loop_progressed.set()
release_scan.set()
await task
finally:
release_scan.set()
safety_release.cancel()
if not task.done():
task.cancel()
await task
assert observed["loop_progressed"] is True
assert adapter._bot.set_my_commands.await_count == 3
for call in adapter._bot.set_my_commands.await_args_list:
assert [command.command for command in call.args[0]] == ["help", "skill"]