From dbe14ce35d9cb087ae9fe3e3f166f6c475297e25 Mon Sep 17 00:00:00 2001 From: Thestral <291572938+thestral123@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:25:48 -0700 Subject: [PATCH] feat(gateway): configure Telegram command menu priority Adds a configurable Telegram BotCommand menu cap and priority list via platforms.telegram.extra.command_menu (max_commands clamped 1..100; priority_mode prepend|append|replace). Default cap stays 30; hidden commands remain invokable when typed and /commands lists the full set. Salvaged from PR #42021. Cherry-picked onto current main; the original edited gateway/platforms/telegram.py, now relocated to plugins/platforms/telegram/adapter.py. --- cli-config.yaml.example | 11 ++ hermes_cli/commands.py | 88 ++++++++++- plugins/platforms/telegram/adapter.py | 19 ++- tests/hermes_cli/test_commands.py | 144 ++++++++++++++++++ website/docs/user-guide/messaging/telegram.md | 25 +++ 5 files changed, 275 insertions(+), 12 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 35f87b16c61..1d87255f48d 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -741,6 +741,17 @@ platform_toolsets: # extra: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages # rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default false for copyable legacy MarkdownV2, set true to opt in +# command_menu: +# # Telegram allows up to 100 BotCommands; Hermes defaults to 30 to +# # stay under Telegram's payload-size limits while keeping common +# # commands visible. Values are clamped to 1..100. +# max_commands: 30 +# # prepend = user priority first, then Hermes defaults +# # append = Hermes defaults first, then user priority +# # replace = only the list below defines priority +# priority_mode: prepend +# priority: +# - my_plugin_command # # Discord-specific settings (config.yaml top-level, not under platforms:): # diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 7334214a325..18cbaaf6860 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -534,6 +534,10 @@ def telegram_bot_commands() -> list[tuple[str, str]]: return result +_DEFAULT_TELEGRAM_MENU_MAX_COMMANDS = 30 +_TELEGRAM_BOT_API_MAX_COMMANDS = 100 +_TELEGRAM_PRIORITY_MODES = {"prepend", "append", "replace"} + _TELEGRAM_MENU_PRIORITY = ( # Most-typed everyday commands first. "help", @@ -571,12 +575,92 @@ need to survive the visible menu cap ahead of lower-priority built-ins. """ +def _nested_mapping(root: Mapping[str, Any], *path: str) -> Mapping[str, Any]: + node: Any = root + for key in path: + if not isinstance(node, Mapping): + return {} + node = node.get(key) + return node if isinstance(node, Mapping) else {} + + +def _telegram_command_menu_config() -> dict[str, Any]: + """Return normalized Telegram command-menu config with safe defaults. + + Canonical user-facing path: + ``platforms.telegram.extra.command_menu``. + """ + try: + from hermes_cli.config import read_raw_config + raw_cfg = read_raw_config() or {} + except Exception: + raw_cfg = {} + if not isinstance(raw_cfg, Mapping): + raw_cfg = {} + + menu_cfg = dict(_nested_mapping(raw_cfg, "platforms", "telegram", "extra", "command_menu")) + + max_commands = menu_cfg.get("max_commands", _DEFAULT_TELEGRAM_MENU_MAX_COMMANDS) + try: + max_commands = int(max_commands) + except (TypeError, ValueError): + max_commands = _DEFAULT_TELEGRAM_MENU_MAX_COMMANDS + max_commands = max(1, min(_TELEGRAM_BOT_API_MAX_COMMANDS, max_commands)) + + priority_mode = str(menu_cfg.get("priority_mode") or "prepend").strip().lower() + if priority_mode not in _TELEGRAM_PRIORITY_MODES: + priority_mode = "prepend" + + raw_priority = menu_cfg.get("priority") + if isinstance(raw_priority, list): + priority = [str(item) for item in raw_priority if str(item).strip()] + else: + priority = [] + + return { + "max_commands": max_commands, + "priority_mode": priority_mode, + "priority": priority, + } + + +def telegram_menu_max_commands() -> int: + """Return configured Telegram BotCommand menu cap with safe bounds.""" + return int(_telegram_command_menu_config()["max_commands"]) + + +def _dedupe_sanitized_names(raw_names: list[str] | tuple[str, ...]) -> tuple[str, ...]: + result: list[str] = [] + seen: set[str] = set() + for raw_name in raw_names: + name = _sanitize_telegram_name(str(raw_name)) + if name and name not in seen: + seen.add(name) + result.append(name) + return tuple(result) + + +def _telegram_effective_priority() -> tuple[str, ...]: + menu_cfg = _telegram_command_menu_config() + configured = list(_dedupe_sanitized_names(menu_cfg["priority"])) + defaults = list(_dedupe_sanitized_names(_TELEGRAM_MENU_PRIORITY)) + + if menu_cfg["priority_mode"] == "replace": + raw_priority = configured + elif menu_cfg["priority_mode"] == "append": + raw_priority = defaults + configured + else: + raw_priority = configured + defaults + + return _dedupe_sanitized_names(raw_priority) + + def _prioritize_telegram_menu_commands( commands: list[tuple[str, str]], ) -> list[tuple[str, str]]: priority = { - _sanitize_telegram_name(name): index - for index, name in enumerate(_TELEGRAM_MENU_PRIORITY) + name: index + for index, name in enumerate(_telegram_effective_priority()) } return [ command diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index b2f69647d64..34cd6f2c7e3 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -108,9 +108,6 @@ _TELEGRAM_IMAGE_EXT_TO_MIME = { } -MAX_COMMANDS_PER_SCOPE = 30 - - def check_telegram_requirements() -> bool: """Check if Telegram dependencies are available. @@ -2428,11 +2425,13 @@ class TelegramAdapter(BasePlatformAdapter): BotCommandScopeAllGroupChats, BotCommandScopeDefault, ) - from hermes_cli.commands import telegram_menu_commands + from hermes_cli.commands import telegram_menu_commands, telegram_menu_max_commands # Telegram allows up to 100 commands but has an undocumented - # payload size limit (~4KB total). Limit to 30 core commands - # to stay well under the threshold while covering all categories. - menu_commands, hidden_count = telegram_menu_commands(max_commands=MAX_COMMANDS_PER_SCOPE) + # payload size limit (~4KB total). Hermes defaults to 30 to + # stay well under the threshold while covering all categories; + # users can tune the cap via platforms.telegram.extra.command_menu. + max_commands = telegram_menu_max_commands() + menu_commands, hidden_count = telegram_menu_commands(max_commands=max_commands) bot_commands = [BotCommand(name, desc) for name, desc in menu_commands] # Register for all scopes independently — Telegram picks the # narrowest matching scope per chat type (forum topics fall @@ -2451,7 +2450,7 @@ class TelegramAdapter(BasePlatformAdapter): if hidden_count: logger.info( "[%s] Telegram menu: %d commands registered, %d hidden (over %d limit). Use /commands for full list.", - self.name, len(menu_commands), hidden_count, 30, + self.name, len(menu_commands), hidden_count, max_commands, ) except Exception as e: logger.warning( @@ -6184,8 +6183,8 @@ class TelegramAdapter(BasePlatformAdapter): if chat_id in self._forum_command_registered: return from telegram import BotCommand, BotCommandScopeChat - from hermes_cli.commands import telegram_menu_commands - menu_commands, _ = telegram_menu_commands(max_commands=MAX_COMMANDS_PER_SCOPE) + from hermes_cli.commands import telegram_menu_commands, telegram_menu_max_commands + menu_commands, _ = telegram_menu_commands(max_commands=telegram_menu_max_commands()) bot_commands = [BotCommand(name, desc) for name, desc in menu_commands] await self._bot.set_my_commands(bot_commands, scope=BotCommandScopeChat(chat_id=chat_id)) self._forum_command_registered.add(chat_id) diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 72d8b5e7c37..bd99e3b125d 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -27,6 +27,7 @@ from hermes_cli.commands import ( slack_subcommand_map, telegram_bot_commands, telegram_menu_commands, + telegram_menu_max_commands, ) @@ -1154,6 +1155,149 @@ class TestTelegramMenuCommands: ): assert name in names + def test_configured_priority_prepends_plugin_commands(self, tmp_path, monkeypatch): + """Configured Telegram priorities keep local/plugin commands visible.""" + from unittest.mock import patch + import hermes_cli.plugins as plugins_mod + + plugin_dir = tmp_path / "plugins" / "cmd-plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + (plugin_dir / "plugin.yaml").write_text( + "name: cmd-plugin\nversion: 0.1.0\ndescription: Test plugin\n" + ) + (plugin_dir / "__init__.py").write_text( + "def register(ctx):\n" + " ctx.register_command('lcm', lambda args: 'ok', description='LCM status and diagnostics')\n" + ) + (tmp_path / "config.yaml").write_text( + "plugins:\n" + " enabled:\n" + " - cmd-plugin\n" + "platforms:\n" + " telegram:\n" + " extra:\n" + " command_menu:\n" + " priority_mode: prepend\n" + " priority:\n" + " - lcm\n" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + with patch.object(plugins_mod, "_plugin_manager", None): + menu, _hidden = telegram_menu_commands(max_commands=30) + + names = [name for name, _desc in menu] + assert names[0] == "lcm" + assert "help" in names[1:] + + def test_configured_priority_append_keeps_defaults_before_user_priority(self, tmp_path, monkeypatch): + """append mode preserves built-in defaults ahead of configured names.""" + from unittest.mock import patch + import hermes_cli.plugins as plugins_mod + + plugin_dir = tmp_path / "plugins" / "cmd-plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + (plugin_dir / "plugin.yaml").write_text( + "name: cmd-plugin\nversion: 0.1.0\ndescription: Test plugin\n" + ) + (plugin_dir / "__init__.py").write_text( + "def register(ctx):\n" + " ctx.register_command('lcm', lambda args: 'ok', description='LCM status and diagnostics')\n" + ) + (tmp_path / "config.yaml").write_text( + "plugins:\n" + " enabled:\n" + " - cmd-plugin\n" + "platforms:\n" + " telegram:\n" + " extra:\n" + " command_menu:\n" + " priority_mode: append\n" + " priority:\n" + " - lcm\n" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + with patch.object(plugins_mod, "_plugin_manager", None): + menu, _hidden = telegram_menu_commands(max_commands=30) + + names = [name for name, _desc in menu] + assert names.index("help") < names.index("lcm") + + def test_configured_priority_replace_ignores_builtin_priority_order(self, tmp_path, monkeypatch): + (tmp_path / "config.yaml").write_text( + "platforms:\n" + " telegram:\n" + " extra:\n" + " command_menu:\n" + " priority_mode: replace\n" + " priority:\n" + " - status\n" + " - help\n" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + menu, _hidden = telegram_menu_commands(max_commands=5) + names = [name for name, _desc in menu] + + assert names[:2] == ["status", "help"] + + def test_telegram_menu_max_commands_uses_config_with_safe_bounds(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + assert telegram_menu_max_commands() == 30 + + (tmp_path / "config.yaml").write_text( + "platforms:\n" + " telegram:\n" + " extra:\n" + " command_menu:\n" + " max_commands: 12\n" + ) + assert telegram_menu_max_commands() == 12 + + (tmp_path / "config.yaml").write_text( + "platforms:\n" + " telegram:\n" + " extra:\n" + " command_menu:\n" + " max_commands: 250\n" + ) + assert telegram_menu_max_commands() == 100 + + (tmp_path / "config.yaml").write_text( + "platforms:\n" + " telegram:\n" + " extra:\n" + " command_menu:\n" + " max_commands: 0\n" + ) + assert telegram_menu_max_commands() == 1 + + (tmp_path / "config.yaml").write_text( + "platforms:\n" + " telegram:\n" + " extra:\n" + " command_menu:\n" + " max_commands: nope\n" + ) + assert telegram_menu_max_commands() == 30 + + def test_telegram_menu_ignores_undocumented_command_menu_paths(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text( + "telegram:\n" + " command_menu:\n" + " max_commands: 12\n" + "gateway:\n" + " platforms:\n" + " telegram:\n" + " command_menu:\n" + " max_commands: 9\n" + ) + + assert telegram_menu_max_commands() == 30 + def test_includes_plugin_commands_via_lazy_discovery(self, tmp_path, monkeypatch): """Telegram menu generation should discover plugin slash commands on first access.""" from unittest.mock import patch diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 80b652f4b9b..13b754a4578 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -79,6 +79,31 @@ Notes: profile-text indicator. - Off by default, since it mutates the bot's global profile. +### Command menu priority and cap (Optional) + +Hermes registers its command menu automatically when the Telegram gateway starts. The menu is built from the central slash-command registry plus eligible plugin/skill commands, then capped to a safe default of 30 commands so Telegram accepts the payload reliably. + +If you have local or plugin commands that should stay visible in Telegram's `/` picker, prioritize them in `~/.hermes/config.yaml`: + +```yaml +platforms: + telegram: + extra: + command_menu: + max_commands: 30 + priority_mode: prepend # prepend | append | replace + priority: + - my_plugin_command +``` + +`priority_mode` controls how your list combines with Hermes' built-in priority list: + +- `prepend`: put your commands first, then Hermes defaults +- `append`: keep Hermes defaults first, then your commands +- `replace`: use only your list for priority ordering + +Telegram allows up to 100 BotCommands, but large command payloads can fail. Hermes defaults to 30 for reliability and clamps configured values to `1..100`; use `/commands` for the full command list. + ## Step 3: Privacy Mode (Critical for Groups) Telegram bots have a **privacy mode** that is **enabled by default**. This is the single most common source of confusion when using bots in groups.