From 53e9233208760a685b004786441259b639b5d568 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 1/3] 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 35f87b16c617..1d87255f48de 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 7334214a325a..18cbaaf68602 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 @@ def telegram_bot_commands() -> list[tuple[str, str]]: """ +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 b2f69647d642..34cd6f2c7e3d 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -108,9 +108,6 @@ class _MockContextTypes: } -MAX_COMMANDS_PER_SCOPE = 30 - - def check_telegram_requirements() -> bool: """Check if Telegram dependencies are available. @@ -2428,11 +2425,13 @@ def _polling_error_callback(error: Exception) -> None: 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 @@ def _polling_error_callback(error: Exception) -> None: 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 @@ async def _ensure_forum_commands(self, message) -> None: 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 72d8b5e7c378..bd99e3b125d9 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -27,6 +27,7 @@ slack_subcommand_map, telegram_bot_commands, telegram_menu_commands, + telegram_menu_max_commands, ) @@ -1154,6 +1155,149 @@ def test_operational_builtins_survive_thirty_command_cap(self, tmp_path, monkeyp ): 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 80b652f4b9be..13b754a45788 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. From a925f2ee901ac53c8cea553ad2ad7cb5662c58f2 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:26:07 -0700 Subject: [PATCH 2/3] fix(telegram): raise default command-menu cap to 60 so skills stay visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 30-slot default could not fit Hermes's ~50 built-in commands, so every skill command (and 20 built-ins) were silently dropped from the Telegram \`/\` menu by default — they only worked when typed manually. Raising the default to 60 keeps all built-ins plus common skill commands visible out of the box while staying under Telegram's ~4KB payload limit. Users can still tune it via platforms.telegram.extra.command_menu. --- cli-config.yaml.example | 8 ++++---- hermes_cli/commands.py | 6 +++++- plugins/platforms/telegram/adapter.py | 7 ++++--- tests/hermes_cli/test_commands.py | 6 +++--- website/docs/user-guide/messaging/telegram.md | 6 +++--- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 1d87255f48de..55ae05b4b70a 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -742,10 +742,10 @@ platform_toolsets: # 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 +# # Telegram allows up to 100 BotCommands; Hermes defaults to 60 so +# # all built-in commands plus common skill commands stay visible +# # while remaining under Telegram's payload-size limit. Clamped 1..100. +# max_commands: 60 # # prepend = user priority first, then Hermes defaults # # append = Hermes defaults first, then user priority # # replace = only the list below defines priority diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 18cbaaf68602..c7ce5566e402 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -534,7 +534,11 @@ def telegram_bot_commands() -> list[tuple[str, str]]: return result -_DEFAULT_TELEGRAM_MENU_MAX_COMMANDS = 30 +# Telegram allows up to 100 BotCommands. Hermes ships ~50 built-in commands; +# a 60-slot default keeps every built-in plus common skill commands visible in +# the `/` menu while staying comfortably under Telegram's ~4KB payload limit. +# Users can tune this via platforms.telegram.extra.command_menu.max_commands. +_DEFAULT_TELEGRAM_MENU_MAX_COMMANDS = 60 _TELEGRAM_BOT_API_MAX_COMMANDS = 100 _TELEGRAM_PRIORITY_MODES = {"prepend", "append", "replace"} diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 34cd6f2c7e3d..b1f31e5b13ae 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -2427,9 +2427,10 @@ def _polling_error_callback(error: Exception) -> None: ) 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). 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. + # payload size limit (~4KB total). Hermes defaults to 60 to + # keep built-ins plus common skill commands visible while + # staying under the threshold; 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] diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index bd99e3b125d9..d357a7c89a6a 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -1245,7 +1245,7 @@ def test_configured_priority_replace_ignores_builtin_priority_order(self, tmp_pa 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 + assert telegram_menu_max_commands() == 60 (tmp_path / "config.yaml").write_text( "platforms:\n" @@ -1281,7 +1281,7 @@ def test_telegram_menu_max_commands_uses_config_with_safe_bounds(self, tmp_path, " command_menu:\n" " max_commands: nope\n" ) - assert telegram_menu_max_commands() == 30 + assert telegram_menu_max_commands() == 60 def test_telegram_menu_ignores_undocumented_command_menu_paths(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) @@ -1296,7 +1296,7 @@ def test_telegram_menu_ignores_undocumented_command_menu_paths(self, tmp_path, m " max_commands: 9\n" ) - assert telegram_menu_max_commands() == 30 + assert telegram_menu_max_commands() == 60 def test_includes_plugin_commands_via_lazy_discovery(self, tmp_path, monkeypatch): """Telegram menu generation should discover plugin slash commands on first access.""" diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 13b754a45788..1e5eea92efd8 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -81,7 +81,7 @@ Notes: ### 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. +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 so Telegram accepts the payload reliably. The default cap is 60 commands — enough to keep all built-in commands plus common skill commands visible. If you have local or plugin commands that should stay visible in Telegram's `/` picker, prioritize them in `~/.hermes/config.yaml`: @@ -90,7 +90,7 @@ platforms: telegram: extra: command_menu: - max_commands: 30 + max_commands: 60 priority_mode: prepend # prepend | append | replace priority: - my_plugin_command @@ -102,7 +102,7 @@ platforms: - `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. +Telegram allows up to 100 BotCommands, but large command payloads can fail. Hermes defaults to 60 for reliability and clamps configured values to `1..100`; use `/commands` for the full command list. ## Step 3: Privacy Mode (Critical for Groups) From 33d6b4bf1de39fb30ecd7853401ac9210e647bfc Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:27:04 -0700 Subject: [PATCH 3/3] chore(release): map thestral123 author email for PR #42021 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 967740a25a25..03295b4c35fd 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -133,6 +133,7 @@ "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", "mucio@mucio.net": "francescomucio", + "291572938+thestral123@users.noreply.github.com": "thestral123", "tkwong@inspiresynergy.com": "tkwong", "buihongduc132@gmail.com": "buihongduc132", "etheraura@protonmail.com": "EtherAura", # PR #45205 salvage (Linux in-app update relaunch / GUI-skew terminal state)