Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
# priority_mode: prepend
# priority:
# - my_plugin_command
#
# Discord-specific settings (config.yaml top-level, not under platforms:):
#
Expand Down
92 changes: 90 additions & 2 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,14 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
return result


# 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"}

_TELEGRAM_MENU_PRIORITY = (
# Most-typed everyday commands first.
"help",
Expand Down Expand Up @@ -571,12 +579,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
Expand Down
20 changes: 10 additions & 10 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,6 @@ class _MockContextTypes:
}


MAX_COMMANDS_PER_SCOPE = 30


def check_telegram_requirements() -> bool:
"""Check if Telegram dependencies are available.

Expand Down Expand Up @@ -2428,11 +2425,14 @@ 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 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]
# Register for all scopes independently — Telegram picks the
# narrowest matching scope per chat type (forum topics fall
Expand All @@ -2451,7 +2451,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(
Expand Down Expand Up @@ -6184,8 +6184,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)
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
144 changes: 144 additions & 0 deletions tests/hermes_cli/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
slack_subcommand_map,
telegram_bot_commands,
telegram_menu_commands,
telegram_menu_max_commands,
)


Expand Down Expand Up @@ -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() == 60

(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() == 60

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() == 60

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
Expand Down
25 changes: 25 additions & 0 deletions website/docs/user-guide/messaging/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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`:

```yaml
platforms:
telegram:
extra:
command_menu:
max_commands: 60
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 60 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.
Expand Down
Loading