Skip to content
Open
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
10 changes: 10 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,16 @@ platform_toolsets:
# # allowed_chats: ["-1001234567890"]
# extra:
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
# # callback_text_routes: maps inline-keyboard callback_data (exact match,
# # <=64 bytes per Telegram's limit) to the text injected into the
# # conversation when an authorized user taps the button — handled exactly
# # as if the user typed it. Useful for buttons sent by skills/cron jobs
# # through the raw Bot API. Routes are consulted only after every
# # built-in callback handler has declined the query, so built-in
# # callbacks always take precedence.
# # callback_text_routes:
# # "log:breakfast:default": "Yes, had the usual ✅"
# # "log:breakfast:custom": "Had something different ✏️"
#
# Discord-specific settings (config.yaml top-level, not under platforms:):
#
Expand Down
136 changes: 136 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ def __init__(self, config: PlatformConfig):
self._mention_patterns = self._compile_mention_patterns()
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
# Config-driven callback→text routes (extra.callback_text_routes):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: current main no longer contains gateway/platforms/telegram.py; Telegram now runs from plugins/platforms/telegram/adapter.py (migration 476d8d9cc). Please port this initialization and the associated handler changes to the active plugin adapter.

# maps exact inline-keyboard callback_data strings to the text that is
# injected into the conversation as if the tapping user typed it.
self._callback_text_routes: Dict[str, str] = self._parse_callback_text_routes()
# Buffer rapid/album photo updates so Telegram image bursts are handled
# as a single MessageEvent instead of self-interrupting multiple turns.
self._media_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", "0.8"))
Expand Down Expand Up @@ -895,6 +899,131 @@ def _coerce_bool_extra(self, key: str, default: bool = False) -> bool:
return default
return bool(value)

def _parse_callback_text_routes(self) -> Dict[str, str]:
"""Parse ``extra.callback_text_routes`` into an exact-match dict.

Each entry maps an inline-keyboard ``callback_data`` string to the
text injected into the conversation when an authorized user taps the
button. Invalid entries — non-string key/value, empty text, or keys
over Telegram's 64-byte callback_data limit — are dropped with a
warning so a config typo cannot break button handling. Routes are
only consulted after every built-in callback handler has declined the
query (see ``_handle_callback_text_route``), so they can never shadow
built-in callbacks.

Note: a route key that matches a built-in callback prefix (e.g.
``mp:``, ``mb``, ``mx``, ``gt:``, ``ea:``, ``sc:``, ``cl:``,
``update_prompt:``) never fires — the built-in handler claims the
query first and the route entry is dead config. Pick a dedicated
prefix for routed buttons (e.g. ``log:``).
"""
raw = self.config.extra.get("callback_text_routes") or {}
if not isinstance(raw, dict):
logger.warning(
"[%s] callback_text_routes must be a mapping, got %s; ignoring",
self.name, type(raw).__name__,
)
return {}
routes: Dict[str, str] = {}
for key, text in raw.items():
if not isinstance(key, str) or not key or not isinstance(text, str) or not text.strip():
logger.warning("[%s] Ignoring invalid callback_text_routes entry: %r", self.name, key)
continue
if len(key.encode("utf-8")) > 64:
logger.warning(
"[%s] Ignoring callback_text_routes key over Telegram's 64-byte callback_data limit: %r",
self.name, key,
)
continue
routes[key] = text
return routes

async def _handle_callback_text_route(
self,
query,
data: str,
query_chat_id,
query_chat_type,
query_thread_id,
query_user_name,
) -> None:
"""Handle a callback query via config-driven callback→text routes.

Called as the FINAL step of ``_handle_callback_query``, after every
built-in prefix handler has declined the query — chain position alone
guarantees built-in callbacks always win over config routes. On an
exact ``callback_data`` match the mapped text is re-injected as a
normal ``MessageEvent``, so session routing, skills, and logging treat
it exactly as if the tapping user had typed it. Lets deployments wire
inline-keyboard buttons sent outside the agent's own toolchain (e.g.
by skills or cron jobs through the raw Bot API) back into the normal
conversation flow. No-op when the data matches no route.
"""
resolved_text = self._callback_text_routes.get(data)
if resolved_text is None:
return

caller_id = str(getattr(query.from_user, "id", ""))
if not self._is_callback_user_authorized(
caller_id,
chat_id=query_chat_id,
chat_type=str(query_chat_type) if query_chat_type is not None else None,
thread_id=str(query_thread_id) if query_thread_id is not None else None,
user_name=query_user_name,
):
await query.answer(text="⛔ You are not authorized to use this button.")
return

await query.answer()
if not query.message:
logger.warning("[%s] Callback route %r has no source message; dropping", self.name, data)
return

user_display = getattr(query.from_user, "first_name", None) or "User"
# Append the tapped choice to the prompt message and strip the
# keyboard so the button cannot be tapped twice. Best-effort: a
# failed edit must not prevent the text from being routed.
try:
original_text = query.message.text or ""
await query.edit_message_text(
text=(
f"{_html.escape(original_text)}\n\n"
f"<b>{_html.escape(user_display)}:</b> {_html.escape(resolved_text)}"
),
parse_mode="HTML",
reply_markup=None,
)
except Exception as exc:
logger.debug("[%s] Callback route message edit failed (non-fatal): %s", self.name, exc)

# Re-inject the mapped text as a normal MessageEvent so existing
# session routing, skills, and logging treat it exactly like a
# typed message — full chat/user/thread context preserved.
# Dispatched directly through handle_message rather than the typed-text
# batching path (_enqueue_text_event): a button tap is a discrete,
# complete user action — never a client-side split fragment — so
# buffering it would only add latency.
try:
from types import SimpleNamespace

callback_message = SimpleNamespace(
chat=query.message.chat,
from_user=query.from_user,
text=resolved_text,
message_id=getattr(query.message, "message_id", None),
message_thread_id=getattr(query.message, "message_thread_id", None),
is_topic_message=getattr(query.message, "is_topic_message", False),
reply_to_message=None,
quote=None,
date=datetime.now(timezone.utc),
forum_topic_created=None,
)
event = self._build_message_event(callback_message, MessageType.TEXT)
event = self._apply_telegram_group_observe_attribution(event)
await self.handle_message(event)
except Exception as exc:
logger.error("[%s] Callback text route failed: %s", self.name, exc, exc_info=True)

def _link_preview_kwargs(self) -> Dict[str, Any]:
if not getattr(self, "_disable_link_previews", False):
return {}
Expand Down Expand Up @@ -3672,6 +3801,13 @@ async def _handle_callback_query(

# --- Update prompt callbacks ---
if not data.startswith("update_prompt:"):
# No built-in handler claimed this query — as the final step,
# consult the config-driven callback→text routes
# (extra.callback_text_routes). Chain position guarantees config
# routes can never shadow built-in callbacks.
await self._handle_callback_text_route(
query, data, query_chat_id, query_chat_type, query_thread_id, query_user_name,
)
return
answer = data.split(":", 1)[1] # "y" or "n"
caller_id = str(getattr(query.from_user, "id", ""))
Expand Down
142 changes: 142 additions & 0 deletions tests/gateway/test_telegram_callback_text_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Tests for config-driven Telegram callback→text routes (extra.callback_text_routes)."""

from types import SimpleNamespace
from typing import cast
from unittest.mock import AsyncMock, MagicMock

import pytest

from gateway.config import PlatformConfig
from gateway.platforms.telegram import TelegramAdapter

ROUTES = {
"log:breakfast:default": "Yes, had the usual ✅",
"log:breakfast:custom": "Had something different ✏️",
}


def _make_adapter(routes=ROUTES):
extra = {"callback_text_routes": routes} if routes is not None else {}
config = PlatformConfig(enabled=True, token="test-token", extra=extra)
adapter = TelegramAdapter(config)
adapter._bot = AsyncMock()
adapter._app = MagicMock()
adapter.handle_message = AsyncMock()
return adapter


def _make_callback(data: str):
chat = SimpleNamespace(
id=12345,
type="private",
title=None,
full_name="Alice",
is_forum=False,
)
prompt_message = SimpleNamespace(
chat=chat,
chat_id=12345,
text="Did you have your usual breakfast?",
message_id=777,
message_thread_id=None,
is_topic_message=False,
)
from_user = SimpleNamespace(id="49334209", first_name="Alice", full_name="Alice")
query = AsyncMock()
query.data = data
query.message = prompt_message
query.from_user = from_user
return SimpleNamespace(callback_query=query), query


@pytest.mark.asyncio
async def test_routed_callback_injects_mapped_text_with_full_context(monkeypatch):
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
adapter = _make_adapter()
update, query = _make_callback("log:breakfast:default")

await adapter._handle_callback_query(update, None)

query.answer.assert_awaited_once()
query.edit_message_text.assert_awaited_once()
handle_message = cast(AsyncMock, adapter.handle_message)
handle_message.assert_awaited_once()
event = handle_message.call_args.args[0]
assert event.text == "Yes, had the usual ✅"
assert event.source.chat_id == "12345"
assert event.source.user_id == "49334209"


@pytest.mark.asyncio
async def test_routed_callback_strips_buttons_and_shows_choice(monkeypatch):
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
adapter = _make_adapter()
update, query = _make_callback("log:breakfast:custom")

await adapter._handle_callback_query(update, None)

kwargs = query.edit_message_text.call_args.kwargs
assert kwargs["reply_markup"] is None
assert "Alice" in kwargs["text"]
assert "Had something different" in kwargs["text"]


@pytest.mark.asyncio
async def test_unknown_callback_data_is_not_routed(monkeypatch):
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
adapter = _make_adapter()
update, query = _make_callback("log:unknown:choice")

await adapter._handle_callback_query(update, None)

handle_message = cast(AsyncMock, adapter.handle_message)
handle_message.assert_not_awaited()
query.answer.assert_not_awaited()
query.edit_message_text.assert_not_awaited()


@pytest.mark.asyncio
async def test_unauthorized_user_is_rejected(monkeypatch):
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
adapter = _make_adapter()
adapter._is_callback_user_authorized = MagicMock(return_value=False)
update, query = _make_callback("log:breakfast:default")

await adapter._handle_callback_query(update, None)

handle_message = cast(AsyncMock, adapter.handle_message)
handle_message.assert_not_awaited()
answer_kwargs = query.answer.call_args.kwargs
assert "not authorized" in answer_kwargs.get("text", "")


@pytest.mark.asyncio
async def test_edit_failure_still_routes_text(monkeypatch):
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
adapter = _make_adapter()
update, query = _make_callback("log:breakfast:default")
query.edit_message_text.side_effect = RuntimeError("message too old")

await adapter._handle_callback_query(update, None)

handle_message = cast(AsyncMock, adapter.handle_message)
handle_message.assert_awaited_once()
event = handle_message.call_args.args[0]
assert event.text == "Yes, had the usual ✅"


def test_invalid_routes_config_is_ignored():
adapter = _make_adapter(routes="not-a-mapping")
assert adapter._callback_text_routes == {}


def test_oversized_and_empty_entries_are_dropped():
adapter = _make_adapter(
routes={
"k" * 65: "over the 64-byte callback_data limit",
"log:empty": " ",
"log:ok": "fine",
42: "non-string key",
}
)
assert adapter._callback_text_routes == {"log:ok": "fine"}