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.
This commit is contained in:
Thestral 2026-06-23 23:25:48 -07:00 committed by Teknium
parent 281a439ad4
commit dbe14ce35d
5 changed files with 275 additions and 12 deletions

View file

@ -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:):
#

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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.