diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index a167c4ac5..c67d4e9db 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -368,6 +368,42 @@ def telegram_bot_commands() -> list[tuple[str, str]]: return result +_TG_NAME_LIMIT = 32 + + +def _clamp_telegram_names( + entries: list[tuple[str, str]], + reserved: set[str], +) -> list[tuple[str, str]]: + """Enforce Telegram's 32-char command name limit with collision avoidance. + + Names exceeding 32 chars are truncated. If truncation creates a duplicate + (against *reserved* names or earlier entries in the same batch), the name is + shortened to 31 chars and a digit ``0``-``9`` is appended to differentiate. + If all 10 digit slots are taken the entry is silently dropped. + """ + used: set[str] = set(reserved) + result: list[tuple[str, str]] = [] + for name, desc in entries: + if len(name) > _TG_NAME_LIMIT: + candidate = name[:_TG_NAME_LIMIT] + if candidate in used: + prefix = name[:_TG_NAME_LIMIT - 1] + for digit in range(10): + candidate = f"{prefix}{digit}" + if candidate not in used: + break + else: + # All 10 digit slots exhausted — skip entry + continue + name = candidate + if name in used: + continue + used.add(name) + result.append((name, desc)) + return result + + def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]: """Return Telegram menu commands capped to the Bot API limit. @@ -383,9 +419,13 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str (menu_commands, hidden_count) where hidden_count is the number of skill commands omitted due to the cap. """ - all_commands = list(telegram_bot_commands()) + core_commands = list(telegram_bot_commands()) + # Reserve core names so plugin/skill truncation can't collide with them + reserved_names = {n for n, _ in core_commands} + all_commands = list(core_commands) # Plugin slash commands get priority over skills + plugin_entries: list[tuple[str, str]] = [] try: from hermes_cli.plugins import get_plugin_manager pm = get_plugin_manager() @@ -395,10 +435,15 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str desc = "Plugin command" if len(desc) > 40: desc = desc[:37] + "..." - all_commands.append((tg_name, desc)) + plugin_entries.append((tg_name, desc)) except Exception: pass + # Clamp plugin names to 32 chars with collision avoidance + plugin_entries = _clamp_telegram_names(plugin_entries, reserved_names) + reserved_names.update(n for n, _ in plugin_entries) + all_commands.extend(plugin_entries) + # Remaining slots go to built-in skill commands (not hub-installed). skill_entries: list[tuple[str, str]] = [] try: @@ -424,6 +469,9 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str except Exception: pass + # Clamp skill names to 32 chars with collision avoidance + skill_entries = _clamp_telegram_names(skill_entries, reserved_names) + # Skills fill remaining slots — they're the only tier that gets trimmed remaining_slots = max(0, max_commands - len(all_commands)) hidden_count = max(0, len(skill_entries) - remaining_slots) diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 2c7ef280a..321f8f161 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -12,10 +12,13 @@ from hermes_cli.commands import ( SUBCOMMANDS, SlashCommandAutoSuggest, SlashCommandCompleter, + _TG_NAME_LIMIT, + _clamp_telegram_names, gateway_help_lines, resolve_command, slack_subcommand_map, telegram_bot_commands, + telegram_menu_commands, ) @@ -504,3 +507,83 @@ class TestGhostText: def test_no_suggestion_for_non_slash(self): assert _suggestion("hello") is None + + +# --------------------------------------------------------------------------- +# Telegram command name clamping (32-char limit) +# --------------------------------------------------------------------------- + + +class TestClampTelegramNames: + """Tests for _clamp_telegram_names() — 32-char enforcement + collision.""" + + def test_short_names_unchanged(self): + entries = [("help", "Show help"), ("status", "Show status")] + result = _clamp_telegram_names(entries, set()) + assert result == entries + + def test_long_name_truncated(self): + long = "a" * 40 + result = _clamp_telegram_names([(long, "desc")], set()) + assert len(result) == 1 + assert result[0][0] == "a" * _TG_NAME_LIMIT + assert result[0][1] == "desc" + + def test_collision_with_reserved_gets_digit_suffix(self): + # The truncated form collides with a reserved name + prefix = "x" * _TG_NAME_LIMIT + long_name = "x" * 40 + result = _clamp_telegram_names([(long_name, "d")], reserved={prefix}) + assert len(result) == 1 + name = result[0][0] + assert len(name) == _TG_NAME_LIMIT + assert name == "x" * (_TG_NAME_LIMIT - 1) + "0" + + def test_collision_between_entries_gets_incrementing_digits(self): + # Two long names that truncate to the same 32-char prefix + base = "y" * 40 + entries = [(base + "_alpha", "d1"), (base + "_beta", "d2")] + result = _clamp_telegram_names(entries, set()) + assert len(result) == 2 + assert result[0][0] == "y" * _TG_NAME_LIMIT + assert result[1][0] == "y" * (_TG_NAME_LIMIT - 1) + "0" + + def test_collision_with_reserved_and_entries_skips_taken_digits(self): + prefix = "z" * _TG_NAME_LIMIT + digit0 = "z" * (_TG_NAME_LIMIT - 1) + "0" + # Reserve both the plain truncation and digit-0 + reserved = {prefix, digit0} + long_name = "z" * 50 + result = _clamp_telegram_names([(long_name, "d")], reserved) + assert len(result) == 1 + assert result[0][0] == "z" * (_TG_NAME_LIMIT - 1) + "1" + + def test_all_digits_exhausted_drops_entry(self): + prefix = "w" * _TG_NAME_LIMIT + # Reserve the plain truncation + all 10 digit slots + reserved = {prefix} | {"w" * (_TG_NAME_LIMIT - 1) + str(d) for d in range(10)} + long_name = "w" * 50 + result = _clamp_telegram_names([(long_name, "d")], reserved) + assert result == [] + + def test_exact_32_chars_not_truncated(self): + name = "a" * _TG_NAME_LIMIT + result = _clamp_telegram_names([(name, "desc")], set()) + assert result[0][0] == name + + def test_duplicate_short_name_deduplicated(self): + entries = [("foo", "d1"), ("foo", "d2")] + result = _clamp_telegram_names(entries, set()) + assert len(result) == 1 + assert result[0] == ("foo", "d1") + + +class TestTelegramMenuCommands: + """Integration: telegram_menu_commands enforces the 32-char limit.""" + + def test_all_names_within_limit(self): + menu, _ = telegram_menu_commands(max_commands=100) + for name, _desc in menu: + assert 1 <= len(name) <= _TG_NAME_LIMIT, ( + f"Command '{name}' is {len(name)} chars (limit {_TG_NAME_LIMIT})" + )