Telegram bot command name validation and sanitization
NousResearch/hermes-agent · cluster 4272 · gateway
Data as of
Nothing open on GitHub. Fix merged in #5596. Check it covers your case.
The short answer
- Known?
- Yes. 7 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #5596. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #5596. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and pull requests address failures in Telegram's setMyCommands API caused by invalid command names—specifically names exceeding 32 characters or containing disallowed characters. The work involves sanitizing and truncating command names to meet Telegram's requirements.
Open issues0
Open PRs0
Fixes on record
-
#5596 fix: sanitize Telegram command names to strip invalid characters
files:
agent/skill_commands.py,hermes_cli/commands.py,tests/agent/test_skill_commands.py,tests/hermes_cli/test_commands.pyShow the diff
-
agent/skill_commands.py@@ -16,6 +16,9 @@ _skill_commands: Dict[str, Dict[str, Any]] = {} _PLAN_SLUG_RE = re.compile(r"[^a-z0-9]+") +# Patterns for sanitizing skill names into clean hyphen-separated slugs. +_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") +_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") def build_plan_path( @@ -196,7 +199,14 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: description = line[:80] break seen_names.add(name) + # Normalize to hyphen-separated slug, stripping + # non-alnum chars (e.g. +, /) to avoid invalid + # Telegram command names downstream. cmd_name = name.lower().replace(' ', '-').replace('_', '-') + cmd_name = _SKILL_INVALID_CHARS.sub('', cmd_name) + cmd_name = _SKILL_MULTI_HYPHEN.sub('-', cmd_name).strip('-') + if not cmd_name: + continue _skill_commands[f"/{cmd_name}"] = { "name": name, "description": description or f"Invoke the {name} skill", -
hermes_cli/commands.py@@ -366,13 +366,33 @@ def telegram_bot_commands() -> list[tuple[str, str]]: for cmd in COMMAND_REGISTRY: if not _is_gateway_available(cmd, overrides): continue - tg_name = cmd.name.replace("-", "_") - result.append((tg_name, cmd.description)) + tg_name = _sanitize_telegram_name(cmd.name) + if tg_name: + result.append((tg_name, cmd.description)) return result _TG_NAME_LIMIT = 32 +# Telegram Bot API allows only lowercase a-z, 0-9, and underscores in +# command names. This regex strips everything else after initial conversion. +_TG_INVALID_CHARS = re.compile(r"[^a-z0-9_]") +_TG_MULTI_UNDERSCORE = re.compile(r"_{2,}") + + +def _sanitize_telegram_name(raw: str) -> str: + """Convert a command/skill/plugin name to a valid Telegram command name. + + Telegram requires: 1-32 chars, lowercase a-z, digits 0-9, underscores only. + Steps: lowercase → replace hyphens with underscores → strip all other + invalid characters → collapse consecutive underscores → strip leading/ + trailing underscores. + """ + name = raw.lower().replace("-", "_") + name = _TG_INVALID_CHARS.sub("", name) + name = _TG_MULTI_UNDERSCORE.sub("_", name) + return name.strip("_") + def _clamp_telegram_names( entries: list[tuple[str, str]], @@ -436,7 +456,9 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str pm = get_plugin_manager() plugin_cmds = getattr(pm, "_plugin_commands", {}) for cmd_name in sorted(plugin_cmds): - tg_name = cmd_name.replace("-", "_") + tg_name = _sanitize_telegram_name(cmd_name) + if not tg_name: + continue desc = "Plugin command" if len(desc) > 40: desc = desc[:37] + "..." @@ -479,7 +501,9 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str skill_name = info.get("name", "") if skill_name in _platform_disabled: continue - name = cmd_key.lstrip("/").replace("-", "_") + name = _sanitize_telegram_name(cmd_key.lstrip("/")) + if not name: + continue desc = info.get("description", "") # Keep descriptions short — setMyCommands has an undocumented # total payload limit. 40 chars fits 100 commands safely. -
tests/agent/test_skill_commands.py@@ -102,6 +102,49 @@ def test_excludes_disabled_skills(self, tmp_path): assert "/disabled-skill" not in result + def test_special_chars_stripped_from_cmd_key(self, tmp_path): + """Skill names with +, /, or other special chars produce clean cmd keys.""" + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + # Simulate a skill named "Jellyfin + Jellystat 24h Summary" + skill_dir = tmp_path / "jellyfin-plus" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: Jellyfin + Jellystat 24h Summary\n" + "description: Test skill\n---\n\nBody.\n" + ) + result = scan_skill_commands() + # The + should be stripped, not left as a literal character + assert "/jellyfin-jellystat-24h-summary" in result + # The old buggy key should NOT exist + assert "/jellyfin-+-jellystat-24h-summary" not in result + + def test_allspecial_name_skipped(self, tmp_path): + """Skill with name consisting only of special chars is silently skipped.""" + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + skill_dir = tmp_path / "bad-name" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: +++\ndescription: Bad skill\n---\n\nBody.\n" + ) + result = scan_skill_commands() + # Should not create a "/" key or any entry + assert "/" not in result + assert result == {} + + def test_slash_in_name_stripped_from_cmd_key(self, tmp_path): + """Skill names with / chars produce clean cmd keys.""" + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + skill_dir = tmp_path / "sonarr-api" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: Sonarr v3/v4 API\n" + "description: Test skill\n---\n\nBody.\n" + ) + result = scan_skill_commands() + assert "/sonarr-v3v4-api" in result + assert any("/" in k[1:] for k in result) is False # no unescaped / + + class TestResolveSkillCommandKey: """Telegram bot-command names disallow hyphens, so the menu registers skills with hyphens swapped for underscores. When Telegram autocomplete -
tests/hermes_cli/test_commands.py@@ -14,6 +14,7 @@ SlashCommandCompleter, _TG_NAME_LIMIT, _clamp_telegram_names, + _sanitize_telegram_name, gateway_help_lines, resolve_command, slack_subcommand_map, @@ -198,6 +199,13 @@ def test_no_hyphens_in_command_names(self): for name, _ in telegram_bot_commands(): assert "-" not in name, f"Telegram command '{name}' contains a hyphen" + def test_all_names_valid_telegram_chars(self): + """Telegram requires: lowercase a-z, 0-9, underscores only.""" + import re + tg_valid = re.compile(r"^[a-z0-9_]+$") + for name, _ in telegram_bot_commands(): + assert tg_valid.match(name), f"Invalid Telegram command name: {name!r}" + def test_excludes_cli_only_without_config_gate(self): names = {name for name, _ in telegram_bot_commands()} for cmd in COMMAND_REGISTRY: @@ -509,6 +517,53 @@ def test_no_suggestion_for_non_slash(self): assert _suggestion("hello") is None +# --------------------------------------------------------------------------- +# Telegram command name sanitization +# --------------------------------------------------------------------------- + + +class TestSanitizeTelegramName: + """Tests for _sanitize_telegram_name() — Telegram requires [a-z0-9_] only.""" + + def test_hyphens_replaced_with_underscores(self): + assert _sanitize_telegram_name("my-skill-name") == "my_skill_name" + + def test_plus_sign_stripped(self): + """Regression: skill name 'Jellyfin + Jellystat 24h Summary'.""" + assert _sanitize_telegram_name("jellyfin-+-jellystat-24h-summary") == "jellyfin_jellystat_24h_summary" + + def test_slash_stripped(self): + """Regression: skill name 'Sonarr v3/v4 API Integration'.""" + assert _sanitize_telegram_name("sonarr-v3/v4-api-integration") == "sonarr_v3v4_api_integration" + + def test_uppercase_lowercased(self): + assert _sanitize_telegram_name("MyCommand") == "mycommand" + + def test_dots_and_special_chars_stripped(self): + assert _sanitize_telegram_name("skill.v2@beta!") == "skillv2beta" + + def test_consecutive_underscores_collapsed(self): + assert _sanitize_telegram_name("a---b") == "a_b" + assert _sanitize_telegram_name("a-+-b") == "a_b" + + def test_leading_trailing_underscores_stripped(self): + assert _sanitize_telegram_name("-leading") == "leading" + assert _sanitize_telegram_name("trailing-") == "trailing" + assert _sanitize_telegram_name("-both-") == "both" + + def test_digits_preserved(self): + assert _sanitize_telegram_name("skill-24h") == "skill_24h" + + def test_empty_after_sanitization(self): + assert _sanitize_telegram_name("+++") == "" + + def test_spaces_only_becomes_empty(self): + assert _sanitize_telegram_name(" ") == "" + + def test_already_valid(self): + assert _sanitize_telegram_name("valid_name_123") == "valid_name_123" + + # --------------------------------------------------------------------------- # Telegram command name clamping (32-char limit) # --------------------------------------------------------------------------- @@ -628,3 +683,71 @@ def test_excludes_telegram_disabled_skills(self, tmp_path, monkeypatch): menu_names = {n for n, _ in menu} assert "my_enabled_skill" in menu_names assert "my_disabled_skill" not in menu_names + + def test_special_chars_in_skill_names_sanitized(self, tmp_path, monkeypatch): + """Skills with +, /, or other special chars produce valid Telegram names.""" + from unittest.mock import patch + import re + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + fake_skills_dir = str(tmp_path / "skills") + fake_cmds = { + "/jellyfin-+-jellystat-24h-summary": { + "name": "Jellyfin + Jellystat 24h Summary", + "description": "Test", + "skill_md_path": f"{fake_skills_dir}/jellyfin/SKILL.md", + "skill_dir": f"{fake_skills_dir}/jellyfin", + }, + "/sonarr-v3/v4-api": { + "name": "Sonarr v3/v4 API", + "description": "Test", + "skill_md_path": f"{fake_skills_dir}/sonarr/SKILL.md", + "skill_dir": f"{fake_skills_dir}/sonarr", + }, + } + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + (tmp_path / "skills").mkdir(exist_ok=True) + menu, _ = telegram_menu_commands(max_commands=100) + + # Every name must match Telegram's [a-z0-9_] requirement + tg_valid = re.compile(r"^[a-z0-9_]+$") + for name, _ in menu: + assert tg_valid.match(name), f"Invalid Telegram command name: {name!r}" + + def test_empty_sanitized_names_excluded(self, tmp_path, monkeypatch): + """Skills whose names sanitize to empty string are silently dropped.""" + from unittest.mock import patch + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + fake_skills_dir = str(tmp_path / "skills") + fake_cmds = { + "/+++": { + "name": "+++", + "description": "All special chars", + "skill_md_path": f"{fake_skills_dir}/bad/SKILL.md", + "skill_dir": f"{fake_skills_dir}/bad", + }, + "/valid-skill": { + "name": "valid-skill", + "description": "Normal skill", + "skill_md_path": f"{fake_skills_dir}/valid/SKILL.md", + "skill_dir": f"{fake_skills_dir}/valid", + }, + } + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + (tmp_path / "skills").mkdir(exist_ok=True) + menu, _ = telegram_menu_commands(max_commands=100) + + menu_names = {n for n, _ in menu} + # The valid skill should be present, the empty one should not + assert "valid_skill" in menu_names + # No empty string in menu names + assert "" not in menu_names
-
4 PRs closed without merging
From the close comments: done elsewhere 2 · merged via another change 1 · superseded by another PR 1
- #4270 fix(telegram): truncate command names to 32 characters · done elsewhere
-
#5540 fix(telegram): sanitize command names for setMyCommands · superseded by another PR →
#5553,#5534 - #5547 fix(telegram): sanitize bot command names to fix Bot_command_invalid … · done elsewhere
-
#5553 fix: sanitize Telegram command names to strip invalid characters · merged via another change →
#5596
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.26: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #5596
- Closed issues
- 2
- Attempts
- 4 PRs closed without merging
- Fix link checked
- 6 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.