feat(telegram): support quick-command-only menus
This commit is contained in:
@@ -1070,7 +1070,7 @@ def load_gateway_config() -> GatewayConfig:
|
|||||||
if isinstance(group_allowed_chats, list):
|
if isinstance(group_allowed_chats, list):
|
||||||
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
|
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
|
||||||
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(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:
|
if _telegram_extra_key in telegram_cfg:
|
||||||
plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {})
|
plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {})
|
||||||
if not isinstance(plat_data, dict):
|
if not isinstance(plat_data, dict):
|
||||||
|
|||||||
@@ -1507,11 +1507,28 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
BotCommandScopeDefault,
|
BotCommandScopeDefault,
|
||||||
BotCommandScopeChat,
|
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
|
# Telegram allows up to 100 commands but has an undocumented
|
||||||
# payload size limit (~4KB total). Limit to 30 core commands
|
# payload size limit (~4KB total). Limit to 30 core commands
|
||||||
# to stay well under the threshold while covering all categories.
|
# 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]
|
bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]
|
||||||
# Register for all scopes independently — Telegram picks the
|
# Register for all scopes independently — Telegram picks the
|
||||||
# narrowest matching scope per chat type (forum topics fall
|
# narrowest matching scope per chat type (forum topics fall
|
||||||
|
|||||||
@@ -740,6 +740,42 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str
|
|||||||
return all_commands[:max_commands], hidden_count
|
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(
|
def discord_skill_commands(
|
||||||
max_slots: int,
|
max_slots: int,
|
||||||
reserved_names: set[str],
|
reserved_names: set[str],
|
||||||
|
|||||||
@@ -270,6 +270,25 @@ class TestLoadGatewayConfig:
|
|||||||
|
|
||||||
assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
|
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):
|
def test_bridges_group_sessions_per_user_from_config_yaml(self, tmp_path, monkeypatch):
|
||||||
hermes_home = tmp_path / ".hermes"
|
hermes_home = tmp_path / ".hermes"
|
||||||
hermes_home.mkdir()
|
hermes_home.mkdir()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from hermes_cli.commands import (
|
|||||||
slack_subcommand_map,
|
slack_subcommand_map,
|
||||||
telegram_bot_commands,
|
telegram_bot_commands,
|
||||||
telegram_menu_commands,
|
telegram_menu_commands,
|
||||||
|
telegram_quick_menu_commands,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1153,6 +1154,47 @@ class TestTelegramMenuCommands:
|
|||||||
# No empty string in menu names
|
# No empty string in menu names
|
||||||
assert "" not 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
|
# Backward-compat aliases
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
|
### Custom model aliases
|
||||||
|
|||||||
@@ -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
|
- **Type** — supported types are `exec` and `alias`; other types show an error
|
||||||
- **Works everywhere** — CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant
|
- **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.
|
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
|
## Human Delay
|
||||||
|
|||||||
Reference in New Issue
Block a user