fix(telegram): enforce 32-char limit on command names with collision avoidance (#4211)
Telegram Bot API requires command names to be 1-32 characters. Plugin and skill names that exceed this limit now get truncated. If truncation creates a collision (with core commands, other plugins, or other skills), the name is shortened to 31 chars and a digit 0-9 is appended. Adds _clamp_telegram_names() helper used for both plugin and skill entries in telegram_menu_commands(). Core CommandDef commands are tracked as reserved names so truncated plugin/skill names never shadow them. Addresses the fix from PR #4191 (sroecker) with collision-safe truncation. Tests: 9 new tests covering truncation, digit suffixes, exhaustion, dedup.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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})"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user