{"repo":"NousResearch/hermes-agent","public_id":"4272","display_label":"Telegram bot command name validation and sanitization","summary":"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.","subsystem":"gateway","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5596},"resolution":{"state":"partially_resolved","by":"pr-5596"},"freshness":{"state":"stale","last_verified_at":"2026-04-06T18:27:29+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":5534,"pr_number":5596,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":4272,"title":"Telegram setMyCommands fails when skill command names exceed 32 characters","url":null,"state":"closed"},{"kind":"issue","number":5534,"title":"[Bug]:  Telegram `set_my_commands` fails with `Bot_command_invalid`","url":null,"state":"closed"},{"kind":"pr","number":4270,"title":"fix(telegram): truncate command names to 32 characters","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":5540,"title":"fix(telegram): sanitize command names for setMyCommands","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5553","number":5553},{"kind":"pr","value":"#5553","number":5553},{"kind":"pr","value":"#5534","number":5534}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":5547,"title":"fix(telegram): sanitize bot command names to fix Bot_command_invalid …","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":5553,"title":"fix: sanitize Telegram command names to strip invalid characters","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5596","number":5596}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5596,"title":"fix: sanitize Telegram command names to strip invalid characters","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/skill_commands.py","hermes_cli/commands.py","tests/agent/test_skill_commands.py","tests/hermes_cli/test_commands.py"],"fix_hunks":[{"file":"agent/skill_commands.py","hunks":["@@ -16,6 +16,9 @@\n \n _skill_commands: Dict[str, Dict[str, Any]] = {}\n _PLAN_SLUG_RE = re.compile(r\"[^a-z0-9]+\")\n+# Patterns for sanitizing skill names into clean hyphen-separated slugs.\n+_SKILL_INVALID_CHARS = re.compile(r\"[^a-z0-9-]\")\n+_SKILL_MULTI_HYPHEN = re.compile(r\"-{2,}\")\n \n \n def build_plan_path(","@@ -196,7 +199,14 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:\n                                 description = line[:80]\n                                 break\n                     seen_names.add(name)\n+                    # Normalize to hyphen-separated slug, stripping\n+                    # non-alnum chars (e.g. +, /) to avoid invalid\n+                    # Telegram command names downstream.\n                     cmd_name = name.lower().replace(' ', '-').replace('_', '-')\n+                    cmd_name = _SKILL_INVALID_CHARS.sub('', cmd_name)\n+                    cmd_name = _SKILL_MULTI_HYPHEN.sub('-', cmd_name).strip('-')\n+                    if not cmd_name:\n+                        continue\n                     _skill_commands[f\"/{cmd_name}\"] = {\n                         \"name\": name,\n                         \"description\": description or f\"Invoke the {name} skill\","]},{"file":"hermes_cli/commands.py","hunks":["@@ -366,13 +366,33 @@ def telegram_bot_commands() -> list[tuple[str, str]]:\n     for cmd in COMMAND_REGISTRY:\n         if not _is_gateway_available(cmd, overrides):\n             continue\n-        tg_name = cmd.name.replace(\"-\", \"_\")\n-        result.append((tg_name, cmd.description))\n+        tg_name = _sanitize_telegram_name(cmd.name)\n+        if tg_name:\n+            result.append((tg_name, cmd.description))\n     return result\n \n \n _TG_NAME_LIMIT = 32\n \n+# Telegram Bot API allows only lowercase a-z, 0-9, and underscores in\n+# command names.  This regex strips everything else after initial conversion.\n+_TG_INVALID_CHARS = re.compile(r\"[^a-z0-9_]\")\n+_TG_MULTI_UNDERSCORE = re.compile(r\"_{2,}\")\n+\n+\n+def _sanitize_telegram_name(raw: str) -> str:\n+    \"\"\"Convert a command/skill/plugin name to a valid Telegram command name.\n+\n+    Telegram requires: 1-32 chars, lowercase a-z, digits 0-9, underscores only.\n+    Steps: lowercase → replace hyphens with underscores → strip all other\n+    invalid characters → collapse consecutive underscores → strip leading/\n+    trailing underscores.\n+    \"\"\"\n+    name = raw.lower().replace(\"-\", \"_\")\n+    name = _TG_INVALID_CHARS.sub(\"\", name)\n+    name = _TG_MULTI_UNDERSCORE.sub(\"_\", name)\n+    return name.strip(\"_\")\n+\n \n def _clamp_telegram_names(\n     entries: list[tuple[str, str]],","@@ -436,7 +456,9 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str\n         pm = get_plugin_manager()\n         plugin_cmds = getattr(pm, \"_plugin_commands\", {})\n         for cmd_name in sorted(plugin_cmds):\n-            tg_name = cmd_name.replace(\"-\", \"_\")\n+            tg_name = _sanitize_telegram_name(cmd_name)\n+            if not tg_name:\n+                continue\n             desc = \"Plugin command\"\n             if len(desc) > 40:\n                 desc = desc[:37] + \"...\"","@@ -479,7 +501,9 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str\n             skill_name = info.get(\"name\", \"\")\n             if skill_name in _platform_disabled:\n                 continue\n-            name = cmd_key.lstrip(\"/\").replace(\"-\", \"_\")\n+            name = _sanitize_telegram_name(cmd_key.lstrip(\"/\"))\n+            if not name:\n+                continue\n             desc = info.get(\"description\", \"\")\n             # Keep descriptions short — setMyCommands has an undocumented\n             # total payload limit.  40 chars fits 100 commands safely."]},{"file":"tests/agent/test_skill_commands.py","hunks":["@@ -102,6 +102,49 @@ def test_excludes_disabled_skills(self, tmp_path):\n         assert \"/disabled-skill\" not in result\n \n \n+    def test_special_chars_stripped_from_cmd_key(self, tmp_path):\n+        \"\"\"Skill names with +, /, or other special chars produce clean cmd keys.\"\"\"\n+        with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):\n+            # Simulate a skill named \"Jellyfin + Jellystat 24h Summary\"\n+            skill_dir = tmp_path / \"jellyfin-plus\"\n+            skill_dir.mkdir()\n+            (skill_dir / \"SKILL.md\").write_text(\n+                \"---\\nname: Jellyfin + Jellystat 24h Summary\\n\"\n+                \"description: Test skill\\n---\\n\\nBody.\\n\"\n+            )\n+            result = scan_skill_commands()\n+        # The + should be stripped, not left as a literal character\n+        assert \"/jellyfin-jellystat-24h-summary\" in result\n+        # The old buggy key should NOT exist\n+        assert \"/jellyfin-+-jellystat-24h-summary\" not in result\n+\n+    def test_allspecial_name_skipped(self, tmp_path):\n+        \"\"\"Skill with name consisting only of special chars is silently skipped.\"\"\"\n+        with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):\n+            skill_dir = tmp_path / \"bad-name\"\n+            skill_dir.mkdir()\n+            (skill_dir / \"SKILL.md\").write_text(\n+                \"---\\nname: +++\\ndescription: Bad skill\\n---\\n\\nBody.\\n\"\n+            )\n+            result = scan_skill_commands()\n+        # Should not create a \"/\" key or any entry\n+        assert \"/\" not in result\n+        assert result == {}\n+\n+    def test_slash_in_name_stripped_from_cmd_key(self, tmp_path):\n+        \"\"\"Skill names with / chars produce clean cmd keys.\"\"\"\n+        with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):\n+            skill_dir = tmp_path / \"sonarr-api\"\n+            skill_dir.mkdir()\n+            (skill_dir / \"SKILL.md\").write_text(\n+                \"---\\nname: Sonarr v3/v4 API\\n\"\n+                \"description: Test skill\\n---\\n\\nBody.\\n\"\n+            )\n+            result = scan_skill_commands()\n+        assert \"/sonarr-v3v4-api\" in result\n+        assert any(\"/\" in k[1:] for k in result) is False  # no unescaped /\n+\n+\n class TestResolveSkillCommandKey:\n     \"\"\"Telegram bot-command names disallow hyphens, so the menu registers\n     skills with hyphens swapped for underscores. When Telegram autocomplete"]},{"file":"tests/hermes_cli/test_commands.py","hunks":["@@ -14,6 +14,7 @@\n     SlashCommandCompleter,\n     _TG_NAME_LIMIT,\n     _clamp_telegram_names,\n+    _sanitize_telegram_name,\n     gateway_help_lines,\n     resolve_command,\n     slack_subcommand_map,","@@ -198,6 +199,13 @@ def test_no_hyphens_in_command_names(self):\n         for name, _ in telegram_bot_commands():\n             assert \"-\" not in name, f\"Telegram command '{name}' contains a hyphen\"\n \n+    def test_all_names_valid_telegram_chars(self):\n+        \"\"\"Telegram requires: lowercase a-z, 0-9, underscores only.\"\"\"\n+        import re\n+        tg_valid = re.compile(r\"^[a-z0-9_]+$\")\n+        for name, _ in telegram_bot_commands():\n+            assert tg_valid.match(name), f\"Invalid Telegram command name: {name!r}\"\n+\n     def test_excludes_cli_only_without_config_gate(self):\n         names = {name for name, _ in telegram_bot_commands()}\n         for cmd in COMMAND_REGISTRY:","@@ -509,6 +517,53 @@ def test_no_suggestion_for_non_slash(self):\n         assert _suggestion(\"hello\") is None\n \n \n+# ---------------------------------------------------------------------------\n+# Telegram command name sanitization\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestSanitizeTelegramName:\n+    \"\"\"Tests for _sanitize_telegram_name() — Telegram requires [a-z0-9_] only.\"\"\"\n+\n+    def test_hyphens_replaced_with_underscores(self):\n+        assert _sanitize_telegram_name(\"my-skill-name\") == \"my_skill_name\"\n+\n+    def test_plus_sign_stripped(self):\n+        \"\"\"Regression: skill name 'Jellyfin + Jellystat 24h Summary'.\"\"\"\n+        assert _sanitize_telegram_name(\"jellyfin-+-jellystat-24h-summary\") == \"jellyfin_jellystat_24h_summary\"\n+\n+    def test_slash_stripped(self):\n+        \"\"\"Regression: skill name 'Sonarr v3/v4 API Integration'.\"\"\"\n+        assert _sanitize_telegram_name(\"sonarr-v3/v4-api-integration\") == \"sonarr_v3v4_api_integration\"\n+\n+    def test_uppercase_lowercased(self):\n+        assert _sanitize_telegram_name(\"MyCommand\") == \"mycommand\"\n+\n+    def test_dots_and_special_chars_stripped(self):\n+        assert _sanitize_telegram_name(\"skill.v2@beta!\") == \"skillv2beta\"\n+\n+    def test_consecutive_underscores_collapsed(self):\n+        assert _sanitize_telegram_name(\"a---b\") == \"a_b\"\n+        assert _sanitize_telegram_name(\"a-+-b\") == \"a_b\"\n+\n+    def test_leading_trailing_underscores_stripped(self):\n+        assert _sanitize_telegram_name(\"-leading\") == \"leading\"\n+        assert _sanitize_telegram_name(\"trailing-\") == \"trailing\"\n+        assert _sanitize_telegram_name(\"-both-\") == \"both\"\n+\n+    def test_digits_preserved(self):\n+        assert _sanitize_telegram_name(\"skill-24h\") == \"skill_24h\"\n+\n+    def test_empty_after_sanitization(self):\n+        assert _sanitize_telegram_name(\"+++\") == \"\"\n+\n+    def test_spaces_only_becomes_empty(self):\n+        assert _sanitize_telegram_name(\"   \") == \"\"\n+\n+    def test_already_valid(self):\n+        assert _sanitize_telegram_name(\"valid_name_123\") == \"valid_name_123\"\n+\n+\n # ---------------------------------------------------------------------------\n # Telegram command name clamping (32-char limit)\n # ---------------------------------------------------------------------------","@@ -628,3 +683,71 @@ def test_excludes_telegram_disabled_skills(self, tmp_path, monkeypatch):\n         menu_names = {n for n, _ in menu}\n         assert \"my_enabled_skill\" in menu_names\n         assert \"my_disabled_skill\" not in menu_names\n+\n+    def test_special_chars_in_skill_names_sanitized(self, tmp_path, monkeypatch):\n+        \"\"\"Skills with +, /, or other special chars produce valid Telegram names.\"\"\"\n+        from unittest.mock import patch\n+        import re\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        fake_skills_dir = str(tmp_path / \"skills\")\n+        fake_cmds = {\n+            \"/jellyfin-+-jellystat-24h-summary\": {\n+                \"name\": \"Jellyfin + Jellystat 24h Summary\",\n+                \"description\": \"Test\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/jellyfin/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/jellyfin\",\n+            },\n+            \"/sonarr-v3/v4-api\": {\n+                \"name\": \"Sonarr v3/v4 API\",\n+                \"description\": \"Test\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/sonarr/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/sonarr\",\n+            },\n+        }\n+        with (\n+            patch(\"agent.skill_commands.get_skill_commands\", return_value=fake_cmds),\n+            patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path / \"skills\"),\n+        ):\n+            (tmp_path / \"skills\").mkdir(exist_ok=True)\n+            menu, _ = telegram_menu_commands(max_commands=100)\n+\n+        # Every name must match Telegram's [a-z0-9_] requirement\n+        tg_valid = re.compile(r\"^[a-z0-9_]+$\")\n+        for name, _ in menu:\n+            assert tg_valid.match(name), f\"Invalid Telegram command name: {name!r}\"\n+\n+    def test_empty_sanitized_names_excluded(self, tmp_path, monkeypatch):\n+        \"\"\"Skills whose names sanitize to empty string are silently dropped.\"\"\"\n+        from unittest.mock import patch\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        fake_skills_dir = str(tmp_path / \"skills\")\n+        fake_cmds = {\n+            \"/+++\": {\n+                \"name\": \"+++\",\n+                \"description\": \"All special chars\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/bad/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/bad\",\n+            },\n+            \"/valid-skill\": {\n+                \"name\": \"valid-skill\",\n+                \"description\": \"Normal skill\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/valid/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/valid\",\n+            },\n+        }\n+        with (\n+            patch(\"agent.skill_commands.get_skill_commands\", return_value=fake_cmds),\n+            patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path / \"skills\"),\n+        ):\n+            (tmp_path / \"skills\").mkdir(exist_ok=True)\n+            menu, _ = telegram_menu_commands(max_commands=100)\n+\n+        menu_names = {n for n, _ in menu}\n+        # The valid skill should be present, the empty one should not\n+        assert \"valid_skill\" in menu_names\n+        # No empty string in menu names\n+        assert \"\" not in menu_names\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}