Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1074,7 +1074,7 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(group_allowed_chats, list):
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
for _telegram_extra_key in ("guest_mode", "disable_link_previews", "command_menu"):
for _telegram_extra_key in ("guest_mode", "disable_link_previews"):
if _telegram_extra_key in telegram_cfg:
plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {})
if not isinstance(plat_data, dict):
Expand Down
89 changes: 21 additions & 68 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -1527,28 +1527,11 @@ def _polling_error_callback(error: Exception) -> None:
BotCommandScopeDefault,
BotCommandScopeChat,
)
from hermes_cli.commands import (
telegram_menu_commands,
telegram_quick_menu_commands,
)
from hermes_cli.commands import telegram_menu_commands
# Telegram allows up to 100 commands but has an undocumented
# payload size limit (~4KB total). Limit to 30 core commands
# to stay well under the threshold while covering all categories.
if self.config.extra.get("command_menu") == "quick_commands_only":
# Fetch quick_commands via the gateway runner reference if
# available; otherwise fall back to PlatformConfig.extra.
_qc = self.config.extra.get("quick_commands")
if not isinstance(_qc, dict) or not _qc:
_runner_ref = getattr(self, "_runner_ref", None)
_runner = _runner_ref() if callable(_runner_ref) else None
_gw_cfg = getattr(_runner, "config", None) if _runner else None
_qc = getattr(_gw_cfg, "quick_commands", {}) or {}
menu_commands, hidden_count = telegram_quick_menu_commands(
_qc,
max_commands=MAX_COMMANDS_PER_SCOPE,
)
else:
menu_commands, hidden_count = telegram_menu_commands(max_commands=MAX_COMMANDS_PER_SCOPE)
menu_commands, hidden_count = telegram_menu_commands(max_commands=MAX_COMMANDS_PER_SCOPE)
bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]
# Register for all scopes independently — Telegram picks the
# narrowest matching scope per chat type (forum topics fall
Expand Down Expand Up @@ -4449,12 +4432,11 @@ def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:
return cleaned or text

def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
"""Apply Telegram group trigger rules and user allowlist.
"""Apply Telegram group trigger rules.

DMs and group messages are both subject to TELEGRAM_ALLOWED_USERS
allowlist check. The chat also passes the ``allowed_chats`` whitelist
(when set), or ``guest_mode`` is enabled and the bot is explicitly
mentioned. Group/supergroup messages are additionally accepted when:
DMs remain unrestricted. Group/supergroup messages are accepted when:
- the chat passes the ``allowed_chats`` whitelist (when set), or
``guest_mode`` is enabled and the bot is explicitly mentioned
- the chat is explicitly allowlisted in ``free_response_chats``
- ``require_mention`` is disabled
- the message replies to the bot
Expand All @@ -4471,18 +4453,6 @@ def _should_process_message(self, message: Message, *, is_command: bool = False)
mentioning the bot (``@botname /command``), both of which are
recognised as mentions by :meth:`_message_mentions_bot`.
"""
# Enforce TELEGRAM_ALLOWED_USERS allowlist for ALL message types
# (DMs and groups). Previously only callback actions were gated,
# leaving inbound messages unblocked (issue #23778).
_user = getattr(message, "from_user", None)
_user_id = str(getattr(_user, "id", "")) if _user else ""
if not self._is_callback_user_authorized(_user_id):
logger.warning(
"[%s] Unauthorized user %s — message dropped",
self.name, _user_id,
)
return False

if not self._is_group_chat(message):
return True

Expand Down Expand Up @@ -5432,25 +5402,16 @@ async def _clear_reactions(self, chat_id: str, message_id: str) -> bool:
return False

async def on_processing_start(self, event: MessageEvent) -> None:
"""Add an in-progress reaction and pin the message when processing begins."""
"""Add an in-progress reaction when message processing begins."""
if not self._reactions_enabled():
return
chat_id = getattr(event.source, "chat_id", None)
message_id = getattr(event, "message_id", None)
if chat_id and message_id:
if self._reactions_enabled():
await self._set_reaction(chat_id, message_id, "\U0001f440")
# Pin the incoming message for the duration of the turn
if self._bot:
try:
await self._bot.pin_chat_message(
chat_id=int(chat_id),
message_id=int(message_id),
disable_notification=True,
)
except Exception:
logger.debug("[Telegram] Failed to pin message %s in chat %s", message_id, chat_id)
await self._set_reaction(chat_id, message_id, "\U0001f440")

async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
"""Swap the in-progress reaction for a final success/failure reaction and unpin.
"""Swap the in-progress reaction for a final success/failure reaction.

Unlike Discord (additive reactions), Telegram's set_message_reaction
replaces all existing reactions in one call — no remove step needed.
Expand All @@ -5462,25 +5423,17 @@ async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingO
another agent run to swap it to 👍/👎 — which never happens if the
cancellation was the last activity in the chat.
"""
if not self._reactions_enabled():
return
chat_id = getattr(event.source, "chat_id", None)
message_id = getattr(event, "message_id", None)
if not (chat_id and message_id):
return
if self._reactions_enabled():
if outcome == ProcessingOutcome.CANCELLED:
await self._clear_reactions(chat_id, message_id)
else:
await self._set_reaction(
chat_id,
message_id,
"\U0001f44d" if outcome == ProcessingOutcome.SUCCESS else "\U0001f44e",
)
# Unpin the message when processing is complete
if self._bot:
try:
await self._bot.unpin_chat_message(
chat_id=int(chat_id),
message_id=int(message_id),
)
except Exception:
logger.debug("[Telegram] Failed to unpin message %s in chat %s", message_id, chat_id)
if outcome == ProcessingOutcome.CANCELLED:
await self._clear_reactions(chat_id, message_id)
else:
await self._set_reaction(
chat_id,
message_id,
"\U0001f44d" if outcome == ProcessingOutcome.SUCCESS else "\U0001f44e",
)
36 changes: 0 additions & 36 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,42 +740,6 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str
return all_commands[:max_commands], hidden_count


def telegram_quick_menu_commands(
quick_commands: Mapping[str, Any] | None,
max_commands: int = 100,
) -> tuple[list[tuple[str, str]], int]:
"""Return Telegram BotCommands for profile-defined quick commands only.

Specialist Telegram bots often use ``quick_commands`` as their whole
user-facing interface. This helper lets a profile opt into a focused
Telegram slash menu without exposing every generic Hermes command.

``show_in_telegram_menu: false`` hides a quick command from the native
menu while leaving gateway dispatch unchanged.
"""
if not isinstance(quick_commands, Mapping):
return [], 0

menu: list[tuple[str, str]] = []
seen: set[str] = set()
for raw_name, raw_config in quick_commands.items():
if not isinstance(raw_name, str) or not isinstance(raw_config, Mapping):
continue
if raw_config.get("show_in_telegram_menu") is False:
continue
name = _sanitize_telegram_name(raw_name)
if not name or name in seen:
continue
desc = str(raw_config.get("description") or f"Run /{raw_name}")
if len(desc) > 40:
desc = desc[:37] + "..."
menu.append((name, desc))
seen.add(name)

hidden_count = max(0, len(menu) - max_commands)
return menu[:max_commands], hidden_count


def discord_skill_commands(
max_slots: int,
reserved_names: set[str],
Expand Down
19 changes: 0 additions & 19 deletions tests/gateway/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,25 +270,6 @@ def test_bridges_quick_commands_from_config_yaml(self, tmp_path, monkeypatch):

assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}

def test_bridges_telegram_command_menu_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"telegram:\n"
" command_menu: quick_commands_only\n",
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(hermes_home))

config = load_gateway_config()

assert (
config.platforms[Platform.TELEGRAM].extra["command_menu"]
== "quick_commands_only"
)

def test_bridges_group_sessions_per_user_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
Expand Down
42 changes: 0 additions & 42 deletions tests/hermes_cli/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
slack_subcommand_map,
telegram_bot_commands,
telegram_menu_commands,
telegram_quick_menu_commands,
)


Expand Down Expand Up @@ -1154,47 +1153,6 @@ def test_empty_sanitized_names_excluded(self, tmp_path, monkeypatch):
# No empty string in menu names
assert "" not in menu_names

def test_quick_menu_commands_include_profile_quick_commands_only(self):
menu, hidden = telegram_quick_menu_commands(
{
"agent-health": {
"type": "exec",
"command": "echo ok",
"description": "Show agent health",
},
"hidden": {
"type": "exec",
"command": "echo hidden",
"description": "Hidden command",
"show_in_telegram_menu": False,
},
}
)

assert hidden == 0
assert menu == [("agent_health", "Show agent health")]

def test_quick_menu_commands_sanitize_dedupe_and_trim_descriptions(self):
menu, hidden = telegram_quick_menu_commands(
{
"agent-health": {
"description": "A" * 80,
},
"agent_health": {
"description": "Duplicate after sanitization",
},
"+++": {
"description": "Sanitizes to empty",
},
},
max_commands=1,
)

assert hidden == 0
assert len(menu) == 1
assert menu[0][0] == "agent_health"
assert menu[0][1] == ("A" * 37) + "..."


# ---------------------------------------------------------------------------
# Backward-compat aliases
Expand Down
14 changes: 3 additions & 11 deletions tools/send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
instead, bypassing MarkdownV2 conversion.
"""
try:
from telegram import Bot, MessageEntity
from telegram import Bot
from telegram.constants import ParseMode

# Auto-detect HTML tags — if present, skip MarkdownV2 and send as HTML.
Expand All @@ -825,14 +825,6 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
formatted = message
send_parse_mode = ParseMode.MARKDOWN_V2

# Detect @username patterns and create mention entities so
# require_mention on the receiving bot's Gateway can trigger.
_MENTION_RE = re.compile(r'@([a-zA-Z][a-zA-Z0-9_]{4,31})')
_entities = [
MessageEntity(type="mention", offset=m.start(), length=len(m.group()))
for m in _MENTION_RE.finditer(formatted)
]

# Honour a configured proxy (telegram.proxy_url in config.yaml, exported
# as TELEGRAM_PROXY env var by load_gateway_config). Without this, the
# standalone send path bypasses the proxy and times out in regions
Expand Down Expand Up @@ -897,7 +889,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
last_msg = await _send_telegram_message_with_retry(
bot,
chat_id=int_chat_id, text=formatted,
parse_mode=send_parse_mode, entities=_entities, **text_kwargs
parse_mode=send_parse_mode, **text_kwargs
)
except Exception as md_error:
# Thread not found — retry without message_thread_id so the
Expand Down Expand Up @@ -931,7 +923,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
last_msg = await _send_telegram_message_with_retry(
bot,
chat_id=int_chat_id, text=plain,
parse_mode=None, entities=_entities, **text_kwargs
parse_mode=None, **text_kwargs
)
else:
raise
Expand Down
5 changes: 0 additions & 5 deletions website/docs/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,6 @@ quick_commands:

Then type `/status`, `/deploy`, or `/inbox` in the CLI or a messaging platform. Quick commands are resolved at dispatch time and may not appear in every built-in autocomplete/help table.

For specialist Telegram bots, set `telegram.command_menu: quick_commands_only`
to make Telegram's native slash menu show only profile-defined quick commands.
Set `show_in_telegram_menu: false` on a quick command to keep it callable but
hide it from the Telegram menu.

String-only prompt shortcuts are not supported as quick commands. Put longer reusable prompts in a skill, or use `type: alias` to point at an existing slash command.

### Custom model aliases
Expand Down
22 changes: 0 additions & 22 deletions website/docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1433,28 +1433,6 @@ Usage: type `/status`, `/disk`, `/update`, `/gpu`, or `/restart` in the CLI or a
- **Type** — supported types are `exec` and `alias`; other types show an error
- **Works everywhere** — CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant

Telegram profiles can opt into a focused BotCommand menu that shows only
profile-defined quick commands:

```yaml
telegram:
command_menu: quick_commands_only

quick_commands:
health:
type: exec
command: scripts/health.sh
description: Show service health
internal-debug:
type: exec
command: scripts/debug.sh
description: Internal debug helper
show_in_telegram_menu: false
```

`show_in_telegram_menu: false` hides a quick command from Telegram's native
slash menu while leaving the command callable.

String-only prompt shortcuts are not valid quick commands. For reusable prompt workflows, create a skill or alias to an existing slash command.

## Human Delay
Expand Down
Loading