diff --git a/gateway/config.py b/gateway/config.py index 26998e28380..7acbd98a44e 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1070,7 +1070,7 @@ def load_gateway_config() -> GatewayConfig: if isinstance(group_allowed_chats, list): group_allowed_chats = ",".join(str(v) for v in group_allowed_chats) os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats) - for _telegram_extra_key in ("guest_mode", "disable_link_previews"): + for _telegram_extra_key in ("guest_mode", "disable_link_previews", "command_menu"): if _telegram_extra_key in telegram_cfg: plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {}) if not isinstance(plat_data, dict): diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index a372699da9a..6747381672e 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1507,11 +1507,28 @@ class TelegramAdapter(BasePlatformAdapter): BotCommandScopeDefault, BotCommandScopeChat, ) - from hermes_cli.commands import telegram_menu_commands + from hermes_cli.commands import ( + telegram_menu_commands, + telegram_quick_menu_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) + if self.config.extra.get("command_menu") == "quick_commands_only": + # Fetch quick_commands via the gateway runner reference if + # available; otherwise fall back to PlatformConfig.extra. + _qc = self.config.extra.get("quick_commands") + if not isinstance(_qc, dict) or not _qc: + _runner_ref = getattr(self, "_runner_ref", None) + _runner = _runner_ref() if callable(_runner_ref) else None + _gw_cfg = getattr(_runner, "config", None) if _runner else None + _qc = getattr(_gw_cfg, "quick_commands", {}) or {} + menu_commands, hidden_count = telegram_quick_menu_commands( + _qc, + max_commands=MAX_COMMANDS_PER_SCOPE, + ) + else: + menu_commands, hidden_count = telegram_menu_commands(max_commands=MAX_COMMANDS_PER_SCOPE) 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 diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 9fc0472f512..610621aed6f 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -740,6 +740,42 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str return all_commands[:max_commands], hidden_count +def telegram_quick_menu_commands( + quick_commands: Mapping[str, Any] | None, + max_commands: int = 100, +) -> tuple[list[tuple[str, str]], int]: + """Return Telegram BotCommands for profile-defined quick commands only. + + Specialist Telegram bots often use ``quick_commands`` as their whole + user-facing interface. This helper lets a profile opt into a focused + Telegram slash menu without exposing every generic Hermes command. + + ``show_in_telegram_menu: false`` hides a quick command from the native + menu while leaving gateway dispatch unchanged. + """ + if not isinstance(quick_commands, Mapping): + return [], 0 + + menu: list[tuple[str, str]] = [] + seen: set[str] = set() + for raw_name, raw_config in quick_commands.items(): + if not isinstance(raw_name, str) or not isinstance(raw_config, Mapping): + continue + if raw_config.get("show_in_telegram_menu") is False: + continue + name = _sanitize_telegram_name(raw_name) + if not name or name in seen: + continue + desc = str(raw_config.get("description") or f"Run /{raw_name}") + if len(desc) > 40: + desc = desc[:37] + "..." + menu.append((name, desc)) + seen.add(name) + + hidden_count = max(0, len(menu) - max_commands) + return menu[:max_commands], hidden_count + + def discord_skill_commands( max_slots: int, reserved_names: set[str], diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index da7673011fe..ea00a44b1d6 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -270,6 +270,25 @@ class TestLoadGatewayConfig: assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}} + def test_bridges_telegram_command_menu_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "telegram:\n" + " command_menu: quick_commands_only\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert ( + config.platforms[Platform.TELEGRAM].extra["command_menu"] + == "quick_commands_only" + ) + def test_bridges_group_sessions_per_user_from_config_yaml(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 6de778347e1..7fefd7470c7 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -26,6 +26,7 @@ from hermes_cli.commands import ( slack_subcommand_map, telegram_bot_commands, telegram_menu_commands, + telegram_quick_menu_commands, ) @@ -1153,6 +1154,47 @@ class TestTelegramMenuCommands: # No empty string in menu names assert "" not in menu_names + def test_quick_menu_commands_include_profile_quick_commands_only(self): + menu, hidden = telegram_quick_menu_commands( + { + "agent-health": { + "type": "exec", + "command": "echo ok", + "description": "Show agent health", + }, + "hidden": { + "type": "exec", + "command": "echo hidden", + "description": "Hidden command", + "show_in_telegram_menu": False, + }, + } + ) + + assert hidden == 0 + assert menu == [("agent_health", "Show agent health")] + + def test_quick_menu_commands_sanitize_dedupe_and_trim_descriptions(self): + menu, hidden = telegram_quick_menu_commands( + { + "agent-health": { + "description": "A" * 80, + }, + "agent_health": { + "description": "Duplicate after sanitization", + }, + "+++": { + "description": "Sanitizes to empty", + }, + }, + max_commands=1, + ) + + assert hidden == 0 + assert len(menu) == 1 + assert menu[0][0] == "agent_health" + assert menu[0][1] == ("A" * 37) + "..." + # --------------------------------------------------------------------------- # Backward-compat aliases diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 05424c1cd18..63ca46c81ff 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -126,6 +126,11 @@ quick_commands: Then type `/status`, `/deploy`, or `/inbox` in the CLI or a messaging platform. Quick commands are resolved at dispatch time and may not appear in every built-in autocomplete/help table. +For specialist Telegram bots, set `telegram.command_menu: quick_commands_only` +to make Telegram's native slash menu show only profile-defined quick commands. +Set `show_in_telegram_menu: false` on a quick command to keep it callable but +hide it from the Telegram menu. + String-only prompt shortcuts are not supported as quick commands. Put longer reusable prompts in a skill, or use `type: alias` to point at an existing slash command. ### Custom model aliases diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 204c6d39c24..ab546b354e1 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1417,6 +1417,28 @@ Usage: type `/status`, `/disk`, `/update`, `/gpu`, or `/restart` in the CLI or a - **Type** — supported types are `exec` and `alias`; other types show an error - **Works everywhere** — CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant +Telegram profiles can opt into a focused BotCommand menu that shows only +profile-defined quick commands: + +```yaml +telegram: + command_menu: quick_commands_only + +quick_commands: + health: + type: exec + command: scripts/health.sh + description: Show service health + internal-debug: + type: exec + command: scripts/debug.sh + description: Internal debug helper + show_in_telegram_menu: false +``` + +`show_in_telegram_menu: false` hides a quick command from Telegram's native +slash menu while leaving the command callable. + String-only prompt shortcuts are not valid quick commands. For reusable prompt workflows, create a skill or alias to an existing slash command. ## Human Delay