mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
fix: sanitize Telegram command names to strip invalid characters
Telegram Bot API requires command names to contain only lowercase a-z, digits 0-9, and underscores. Skill/plugin names containing characters like +, /, @, or . caused set_my_commands to fail with Bot_command_invalid. Two-layer fix: - scan_skill_commands(): strip non-alphanumeric/non-hyphen chars from cmd_key at source, collapse consecutive hyphens, trim edges, skip names that sanitize to empty string - _sanitize_telegram_name(): centralized helper used by all 3 Telegram name generation sites (core commands, plugin commands, skill commands) with empty-name guard at each call site Closes #5534
This commit is contained in:
@@ -14,6 +14,7 @@ from hermes_cli.commands import (
|
||||
SlashCommandCompleter,
|
||||
_TG_NAME_LIMIT,
|
||||
_clamp_telegram_names,
|
||||
_sanitize_telegram_name,
|
||||
gateway_help_lines,
|
||||
resolve_command,
|
||||
slack_subcommand_map,
|
||||
@@ -198,6 +199,13 @@ class TestTelegramBotCommands:
|
||||
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 @@ class TestGhostText:
|
||||
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 @@ class TestTelegramMenuCommands:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user