From f8cc67b911a98e3750e5f8aea014c52fd6d2c4bb Mon Sep 17 00:00:00 2001 From: TiM Date: Sun, 9 Aug 2026 14:38:21 +1200 Subject: [PATCH] [verified] Add Telegram plugin callback support --- hermes_cli/plugins.py | 71 +++++++++ plugins/platforms/telegram/adapter.py | 129 +++++++++++++++++ .../gateway/test_telegram_approval_buttons.py | 137 ++++++++++++++++++ tests/hermes_cli/test_plugins.py | 38 +++++ website/docs/developer-guide/plugins/index.md | 26 ++++ 5 files changed, 401 insertions(+) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index c6ed42d726e73..43cc7c9794bc4 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -1096,6 +1096,69 @@ async def _on_approve(ack, body, action): action_id, ) + # -- telegram callback handler registration ----------------------------- + + def register_telegram_callback_handler( + self, + matcher: Any, + callback: Callable, + ) -> None: + """Register a handler for namespaced Telegram callback data. + + ``matcher`` may be a non-empty literal prefix string (for example + ``"inbox:"``) or a compiled regular expression. Callbacks receive + ``(adapter, query, context)`` and may be synchronous or asynchronous. + Returning ``True`` or ``{"handled": True}`` claims the callback. + """ + if not callable(callback): + raise ValueError( + f"Plugin '{self.manifest.name}' tried to register a Telegram " + "callback handler with a non-callable callback." + ) + if isinstance(matcher, str): + matcher = matcher.strip() + if not matcher or ":" not in matcher: + raise ValueError( + f"Plugin '{self.manifest.name}' Telegram callback prefix " + "must be non-empty and namespaced (for example 'plugin:')." + ) + matcher_key = ("prefix", matcher) + elif callable(getattr(matcher, "match", None)): + matcher_key = ( + "regex", + getattr(matcher, "pattern", repr(matcher)), + getattr(matcher, "flags", None), + ) + else: + raise ValueError( + f"Plugin '{self.manifest.name}' Telegram callback matcher must " + "be a literal prefix string or compiled regular expression." + ) + + for existing, _callback, plugin_name in self._manager._telegram_callback_handlers: + if isinstance(existing, str): + existing_key = ("prefix", existing) + else: + existing_key = ( + "regex", + getattr(existing, "pattern", repr(existing)), + getattr(existing, "flags", None), + ) + if existing_key == matcher_key: + raise ValueError( + f"Plugin '{self.manifest.name}' Telegram callback matcher " + f"conflicts with plugin '{plugin_name}'." + ) + + self._manager._telegram_callback_handlers.append( + (matcher, callback, self.manifest.name) + ) + logger.debug( + "Plugin %s registered Telegram callback handler: %s", + self.manifest.name, + matcher_key[1], + ) + # -- hook registration -------------------------------------------------- # -- auxiliary task registration --------------------------------------- @@ -1333,6 +1396,9 @@ def __init__(self) -> None: # ``re.Pattern``, or a constraint dict); ``callback`` is an async # function with the slack_bolt signature ``(ack, body, action)``. self._slack_action_handlers: List[tuple] = [] + # Telegram inline-keyboard callback handlers registered by plugins. + # Each entry is (prefix_or_regex, callback, plugin_name). + self._telegram_callback_handlers: List[tuple] = [] # ----------------------------------------------------------------------- # Public @@ -1363,6 +1429,7 @@ def discover_and_load(self, force: bool = False) -> None: self._portable_mcp_servers.clear() self._aux_tasks.clear() self._slack_action_handlers.clear() + self._telegram_callback_handlers.clear() self._context_engine = None # Set the flag up front as a re-entrancy guard (a plugin's register() # can transitively trigger discovery again), but reset it if the sweep @@ -2184,6 +2251,10 @@ def get_slack_action_handlers(self) -> List[tuple]: """ return list(self._slack_action_handlers) + def get_telegram_callback_handlers(self) -> List[tuple]: + """Return plugin Telegram callback handlers in registration order.""" + return list(self._telegram_callback_handlers) + # ----------------------------------------------------------------------- # Introspection # ----------------------------------------------------------------------- diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index aeb341aa4079b..6a675f665bbab 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -6375,6 +6375,69 @@ def get_label(slug): # Catch-all (e.g. page counter button "mx:noop") await query.answer() + async def dispatch_callback_text(self, query: Any, text: str) -> None: + """Dispatch callback-selected text as an ordinary Telegram user turn. + + The synthetic event preserves the callback user's identity and the + picker message's chat/topic identity, then enters the same public + ``handle_message`` path as typed Telegram text. + """ + text = str(text or "").strip() + query_message = getattr(query, "message", None) + query_user = getattr(query, "from_user", None) + query_chat = getattr(query_message, "chat", None) + if not text or query_message is None or query_user is None or query_chat is None: + raise ValueError("Callback query is missing text, user, message, or chat identity") + + chat_id = str(getattr(query_chat, "id", getattr(query_message, "chat_id", ""))) + user_id = str(getattr(query_user, "id", "")) + telegram_chat_type = str(getattr(query_chat, "type", "")).split(".")[-1].lower() + normalized_chat_type = "dm" + if telegram_chat_type in {"group", "supergroup"}: + normalized_chat_type = "group" + elif telegram_chat_type == "channel": + normalized_chat_type = "channel" + # Use the same topic normalizer as ordinary typed messages. Telegram + # also puts reply-anchor IDs in message_thread_id; those are not + # durable session threads, while forum General normalizes to thread 1. + thread_id = self._effective_message_thread_id(query_message) + + if not self._is_callback_user_authorized( + user_id, + chat_id=chat_id, + chat_type=normalized_chat_type, + thread_id=thread_id, + user_name=getattr(query_user, "first_name", None), + ): + raise PermissionError("Telegram callback user is not authorized") + + source = self.build_source( + chat_id=chat_id, + chat_name=( + getattr(query_chat, "title", None) + or getattr(query_chat, "full_name", None) + ), + chat_type=normalized_chat_type, + user_id=user_id, + user_name=( + getattr(query_user, "full_name", None) + or getattr(query_user, "first_name", None) + ), + thread_id=thread_id, + message_id=str(getattr(query_message, "message_id", "")), + is_bot=False, + ) + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + raw_message=query_message, + message_id=str(getattr(query_message, "message_id", "")), + timestamp=getattr(query_message, "date", None) or datetime.now(timezone.utc), + metadata={"telegram_callback_dispatch": True}, + ) + await self.handle_message(event) + async def _notify_clarify_expired(self, query, user_display: str) -> None: """Tell the user a clarify tap arrived too late to be delivered. @@ -6415,6 +6478,72 @@ async def _handle_callback_query( query_thread_id = getattr(query_message, "message_thread_id", None) query_user_name = getattr(query.from_user, "first_name", None) + # --- Plugin callback handlers --- + # Plugins claim only their own namespaced callback data. Unclaimed or + # failed callbacks continue through the unchanged built-in routes. + try: + from hermes_cli.plugins import get_plugin_manager + + plugin_handlers = get_plugin_manager().get_telegram_callback_handlers() + except Exception as exc: + logger.warning("[%s] Could not load Telegram plugin callback handlers: %s", self.name, exc) + plugin_handlers = [] + + built_in_prefixes = ( + "mp:", "mpg:", "mpv:", "mm:", "mc:", "mb", "mx", "mg:", + "cp:", "gt:", "ea:", "sc:", "cl:", "update_prompt:", + ) + # Built-in controls retain precedence even if a plugin registers an + # over-broad matcher. Plugin callbacks are an extension namespace, not + # an override mechanism for approvals, clarify prompts, or settings. + if data.startswith(built_in_prefixes): + plugin_handlers = [] + + for matcher, callback, plugin_name in plugin_handlers: + try: + matched = ( + data.startswith(matcher) + if isinstance(matcher, str) + else bool(matcher.match(data)) + ) + except Exception as exc: + logger.warning( + "[%s] Telegram callback matcher from plugin %s failed: %s", + self.name, + plugin_name, + exc, + ) + continue + if not matched: + continue + + 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 control.") + return + + try: + result = callback(self, query, context) + if inspect.isawaitable(result): + result = await result + except Exception as exc: + logger.warning( + "[%s] Telegram callback handler from plugin %s failed: %s", + self.name, + plugin_name, + exc, + exc_info=True, + ) + continue + if result is True or (isinstance(result, dict) and result.get("handled") is True): + return + # --- Model picker callbacks --- if data.startswith(("mp:", "mpg:", "mpv:", "mm:", "mc:", "mb", "mx", "mg:")): chat_id = str(query.message.chat_id) if query.message else None diff --git a/tests/gateway/test_telegram_approval_buttons.py b/tests/gateway/test_telegram_approval_buttons.py index 0f6ba60db35ab..5609426e209f3 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/tests/gateway/test_telegram_approval_buttons.py @@ -205,6 +205,143 @@ async def mock_send_message(**kwargs): class TestTelegramApprovalCallback: """Test the approval callback handling in _handle_callback_query.""" + @pytest.mark.asyncio + async def test_plugin_callback_claims_namespaced_data(self): + adapter = _make_adapter() + callback = AsyncMock(return_value={"handled": True}) + manager = MagicMock() + manager.get_telegram_callback_handlers.return_value = [ + ("dp:", callback, "digest-picker") + ] + query = AsyncMock() + query.data = "dp:t:abc:2" + query.message = MagicMock(chat_id=12345, message_thread_id=None) + query.message.chat.type = "private" + query.from_user = MagicMock(id="12345", first_name="Tim") + update = MagicMock(callback_query=query) + context = MagicMock() + + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "12345"}, clear=False): + with patch("hermes_cli.plugins.get_plugin_manager", return_value=manager): + await adapter._handle_callback_query(update, context) + + callback.assert_awaited_once_with(adapter, query, context) + + @pytest.mark.asyncio + async def test_builtin_callback_takes_precedence_over_plugin_matcher(self): + adapter = _make_adapter() + adapter._approval_state[8] = "some-session" + callback = AsyncMock(return_value={"handled": True}) + manager = MagicMock() + manager.get_telegram_callback_handlers.return_value = [ + ("ea:", callback, "broken-plugin") + ] + query = AsyncMock() + query.data = "ea:once:8" + query.message = MagicMock(chat_id=12345, message_thread_id=None) + query.message.chat.type = "private" + query.from_user = MagicMock(id="12345", first_name="Tim") + update = MagicMock(callback_query=query) + + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "12345"}, clear=False): + with patch("hermes_cli.plugins.get_plugin_manager", return_value=manager): + with patch("tools.approval.resolve_gateway_approval", return_value=1) as resolve: + await adapter._handle_callback_query(update, MagicMock()) + + resolve.assert_called_once_with("some-session", "once") + callback.assert_not_awaited() + + @pytest.mark.asyncio + async def test_plugin_callback_exception_does_not_break_polling(self): + adapter = _make_adapter() + callback = AsyncMock(side_effect=RuntimeError("boom")) + manager = MagicMock() + manager.get_telegram_callback_handlers.return_value = [ + ("zz:", callback, "broken-plugin") + ] + query = AsyncMock() + query.data = "zz:anything" + query.message = MagicMock(chat_id=12345, message_thread_id=None) + query.message.chat.type = "private" + query.from_user = MagicMock(id="12345", first_name="Tim") + update = MagicMock(callback_query=query) + + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "12345"}, clear=False): + with patch("hermes_cli.plugins.get_plugin_manager", return_value=manager): + await adapter._handle_callback_query(update, MagicMock()) + + callback.assert_awaited_once() + + @pytest.mark.asyncio + async def test_dispatch_callback_text_preserves_identity_and_topic(self): + adapter = _make_adapter() + adapter.handle_message = AsyncMock() + query = MagicMock() + query.from_user.id = 777 + query.from_user.full_name = "Tim" + query.message.chat.id = 12345 + query.message.chat.type = "private" + query.message.chat.full_name = "Tim" + query.message.message_id = 42 + query.message.message_thread_id = 99 + query.message.is_topic_message = False + query.message.date = None + + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "777"}, clear=False): + await adapter.dispatch_callback_text(query, "3,1,3") + + event = adapter.handle_message.await_args.args[0] + assert event.text == "3,1,3" + assert event.source.platform == Platform.TELEGRAM + assert event.source.chat_id == "12345" + assert event.source.user_id == "777" + assert event.source.thread_id is None + assert event.metadata["telegram_callback_dispatch"] is True + + @pytest.mark.asyncio + async def test_dispatch_callback_text_preserves_real_dm_topic(self): + adapter = _make_adapter() + adapter.handle_message = AsyncMock() + query = MagicMock() + query.from_user.id = 777 + query.from_user.full_name = "Tim" + query.message.chat.id = 12345 + query.message.chat.type = "private" + query.message.chat.full_name = "Tim" + query.message.message_id = 42 + query.message.message_thread_id = 99 + query.message.is_topic_message = True + query.message.date = None + + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "777"}, clear=False): + await adapter.dispatch_callback_text(query, "3,1,3") + + event = adapter.handle_message.await_args.args[0] + assert event.source.chat_type == "dm" + assert event.source.thread_id == "99" + + @pytest.mark.asyncio + async def test_dispatch_callback_text_normalizes_forum_general_topic(self): + adapter = _make_adapter() + adapter.handle_message = AsyncMock() + query = MagicMock() + query.from_user.id = 777 + query.from_user.full_name = "Tim" + query.message.chat.id = -10012345 + query.message.chat.type = "supergroup" + query.message.chat.is_forum = True + query.message.chat.title = "Digest group" + query.message.message_id = 42 + query.message.message_thread_id = None + query.message.is_topic_message = True + query.message.date = None + + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "777"}, clear=False): + await adapter.dispatch_callback_text(query, "3,1,3") + + event = adapter.handle_message.await_args.args[0] + assert event.source.chat_type == "group" + assert event.source.thread_id == "1" @pytest.mark.asyncio async def test_resume_typing_after_inline_approval(self): diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index bec12cb6f367d..f242dbf3490a5 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -365,6 +365,7 @@ def test_force_rediscover_clears_all_plugin_registries(self, monkeypatch): mgr._plugin_skills["p:skill"] = {} mgr._aux_tasks["task"] = {"plugin": "p"} mgr._slack_action_handlers.append(("aid", lambda **_: None, "p")) + mgr._telegram_callback_handlers.append(("p:", lambda *_: True, "p")) mgr._discovered = True monkeypatch.setattr(PluginManager, "_discover_and_load_inner", lambda self_inner: None) @@ -382,6 +383,43 @@ def test_force_rediscover_clears_all_plugin_registries(self, monkeypatch): assert mgr._plugin_skills == {} assert mgr._aux_tasks == {} assert mgr._slack_action_handlers == [] + assert mgr._telegram_callback_handlers == [] + + +class TestTelegramCallbackHandlers: + def test_registers_prefix_and_regex_handlers(self): + import re + + mgr = PluginManager() + ctx = PluginContext(PluginManifest(name="picker", source="user"), mgr) + prefix_handler = lambda *_: True + regex_handler = lambda *_: {"handled": True} + + ctx.register_telegram_callback_handler("dp:", prefix_handler) + matcher = re.compile(r"^other:\d+$") + ctx.register_telegram_callback_handler(matcher, regex_handler) + + assert mgr.get_telegram_callback_handlers() == [ + ("dp:", prefix_handler, "picker"), + (matcher, regex_handler, "picker"), + ] + + @pytest.mark.parametrize("matcher", [None, "", "unnamespaced", object()]) + def test_rejects_invalid_matcher(self, matcher): + mgr = PluginManager() + ctx = PluginContext(PluginManifest(name="picker", source="user"), mgr) + + with pytest.raises(ValueError): + ctx.register_telegram_callback_handler(matcher, lambda *_: True) + + def test_rejects_duplicate_matcher(self): + mgr = PluginManager() + first = PluginContext(PluginManifest(name="first", source="user"), mgr) + second = PluginContext(PluginManifest(name="second", source="user"), mgr) + first.register_telegram_callback_handler("dp:", lambda *_: True) + + with pytest.raises(ValueError, match="conflicts with plugin 'first'"): + second.register_telegram_callback_handler("dp:", lambda *_: True) # ── TestPluginLoading ────────────────────────────────────────────────────── diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 9749c7b46b262..f75d0b7d817fd 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -972,6 +972,32 @@ def register(ctx): This is the public way for plugins to participate in Slack interactivity. Older plugins may patch `SlackAdapter.connect`; prefer this API instead. +### Handle Telegram inline-keyboard callbacks + +Plugins that send Telegram inline keyboards can register a namespaced callback-data prefix or compiled regex. The adapter applies the normal Telegram user allowlist before invoking the callback, and built-in Hermes controls retain precedence. + +```python +def register(ctx): + async def _on_picker(adapter, query, telegram_context): + await query.answer("Selection saved") + # query.data, query.from_user, query.message.chat_id + # ...update the plugin's deterministic state... + return {"handled": True} + + ctx.register_telegram_callback_handler("acme:", _on_picker) +``` + +**Signature:** `ctx.register_telegram_callback_handler(matcher, callback) -> None` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `matcher` | `str \| re.Pattern` | A namespaced string prefix (for example `"acme:"`) or compiled regex. Duplicate matchers are rejected. | +| `callback` | async or sync callable | Receives `(adapter, callback_query, telegram_context)`. Return `True` or `{"handled": true}` to claim the callback. | + +Callbacks should call `await callback_query.answer(...)` promptly so Telegram stops its progress spinner. Exceptions are logged and fail open to other plugin handlers; they do not stop polling. Hermes's built-in approval, model-picker, clarify, settings, and update-prompt callback namespaces cannot be intercepted by plugins. + +To feed a deterministic picker result back through the normal gateway session, call `await adapter.dispatch_callback_text(callback_query, text)`. This constructs a regular `MessageEvent` from the callback's real user, chat, and topic identity, then dispatches it through the same agent path as typed Telegram text. + :::tip This guide covers **general plugins** (tools, hooks, slash commands, CLI commands). The sections below sketch the authoring pattern for each specialized plugin type; each links to its full guide for field reference and examples. :::