From 3b7c940208ad586bc4878f3970b46aec02da1b94 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:40:01 -0700 Subject: [PATCH] feat(gateway): more normalized gateway_platform_event types (#64176) Extend the normalized-envelope pipeline shipped in #82063 with new event types, each with its own versioned, event-local payload contract: - Telegram: message_edited (edited_message updates; editor-identity auth extraction, forum topic thread_id, bounded text/caption, ISO edited_at) - Discord: message_edited, message_deleted, thread_created, thread_renamed (on_message_edit/delete, on_thread_create/update fire-sites with has_hook no-subscriber fast-paths, bot-authored events dropped, rename-only filtering on thread updates) All events flow through the same gateway-owned post-auth boundary; malformed or unauthorized events drop, fail closed. Raw SDK payload access is deliberately NOT shipped (round-2 correction: needs its own gateway.raw_events capability and design). The Discord fire-site machinery (no-subscriber fast-path, observer isolation, connect-time wiring) adapts the observer-hook design from PR #62584 (@paoloantinori) onto the normalized-envelope contract; PR #36875's raw telegram update hook is superseded by the same correction. Docs: hooks.md gains per-event payload contract tables. Co-authored-by: Paolo Antinori --- hermes_cli/plugins.py | 30 +- plugins/platforms/discord/adapter.py | 266 ++++++++++++ plugins/platforms/telegram/adapter.py | 100 ++++- tests/gateway/test_discord_platform_events.py | 388 ++++++++++++++++++ .../test_gateway_platform_event_hook.py | 132 +++++- website/docs/user-guide/features/hooks.md | 28 +- 6 files changed, 928 insertions(+), 16 deletions(-) create mode 100644 tests/gateway/test_discord_platform_events.py diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 11cdb198fb04a..947a6e990e71c 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -260,10 +260,13 @@ VALID_HOOKS: Set[str] = { # # gateway_platform_event: inbound platform event as a normalized envelope. # Kwargs: platform, event_type, payload (event_type-specific dict). - # Telegram reactions fire today. Other event types and their hook - # names land here only together with real fire-sites and payload - # contracts; no inert VALID_HOOKS surface is registered ahead of - # implementation. + # Fired today: Telegram "reaction" + "message_edited"; Discord + # "message_edited", "message_deleted", "thread_created", + # "thread_renamed". Each event type carries its own event-local + # additive payload contract (see hooks.md). Other event types and + # hook names land here only together with real fire-sites and + # payload contracts; no inert VALID_HOOKS surface is registered + # ahead of implementation. "gateway_platform_event", # Slash-command dispatch observer (#64204, observer-first per #64182 # ground rule 3). Fired when a recognized slash command is about to be @@ -1085,6 +1088,8 @@ class PluginContext: self._llm: Any = None self._subagent_lifecycle: Any = None self._state: PluginState | None = None + # Lazy-built capability-gated platform action facade (#64176). + self._platform_actions: Any = None @property def plugin_id(self) -> str: @@ -1198,6 +1203,23 @@ class PluginContext: self._state = PluginState(self.plugin_id, self.manifest.skill_namespace) return self._state + @property + def platform_actions(self): + """Capability-gated platform action facade (#64176, v1). + + Minimal verb set (``add_reaction``, ``set_thread_title``) routed + through the live gateway adapter registry. Every call re-checks the + ``gateway.platform_actions`` capability (legacy gate: + ``plugins.entries..allow_platform_actions``, default OFF) and + returns a structured ``{"ok": bool, ...}`` dict — verbs never raise + into hook dispatch. No adapter handles or raw SDK objects are exposed. + """ + if self._platform_actions is None: + from hermes_cli.platform_actions import PlatformActions + + self._platform_actions = PlatformActions(self.plugin_id) + return self._platform_actions + def _track( self, kind: str, diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 10d2e32cd4252..45d2e8858ac0d 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -1356,6 +1356,22 @@ class DiscordAdapter(BasePlatformAdapter): async def on_message(message: DiscordMessage): await adapter_self._dispatch_discord_message(message) + @self._client.event + async def on_message_edit(before: DiscordMessage, after: DiscordMessage): + await adapter_self._on_platform_message_edit(before, after) + + @self._client.event + async def on_message_delete(message: DiscordMessage): + await adapter_self._on_platform_message_delete(message) + + @self._client.event + async def on_thread_create(thread): + await adapter_self._on_platform_thread_create(thread) + + @self._client.event + async def on_thread_update(before, after): + await adapter_self._on_platform_thread_update(before, after) + @self._client.event async def on_voice_state_update(member, before, after): """Track voice channel join/leave events.""" @@ -1519,6 +1535,256 @@ class DiscordAdapter(BasePlatformAdapter): message, role_authorized=role_authorized, ) + # ------------------------------------------------------------------ + # gateway_platform_event fire-sites (#64176) + # ------------------------------------------------------------------ + + def _thread_id_and_chat_for_channel(self, channel) -> tuple[Optional[str], Optional[str]]: + """Return ``(thread_id, chat_id)`` for a message channel. + + For a thread, ``chat_id`` is the thread id itself (matching how + Discord message dispatch keys sessions) and ``thread_id`` is set; + for a plain channel, ``thread_id`` is None. + """ + if channel is None: + return None, None + chan_id = getattr(channel, "id", None) + if chan_id is None: + return None, None + is_thread = isinstance(channel, getattr(discord, "Thread", ())) + return (str(chan_id) if is_thread else None), str(chan_id) + + def _source_for_platform_event( + self, + *, + chat_id: str, + user_id: Optional[str], + user_name: Optional[str], + thread_id: Optional[str], + guild_id: Optional[str], + message_id: Optional[str] = None, + ): + """Build the internal SessionSource the gateway authorizes against. + + Raises ``ValueError`` when the actor or chat identity is missing so the + post-auth boundary fails closed instead of authorizing an incomplete + source (mirrors the Telegram reaction extractor). + """ + if not user_id or not chat_id: + raise ValueError( + "gateway_platform_event requires actor and chat identities" + ) + return self.build_source( + chat_id=chat_id, + chat_type="thread" if thread_id else "group", + user_id=user_id, + user_name=user_name, + thread_id=thread_id, + guild_id=guild_id, + message_id=message_id, + ) + + async def _fire_platform_event(self, event: Dict[str, Any], source) -> None: + """Forward one normalized envelope to the gateway-owned boundary. + + No installed callback means no trusted auth boundary — fail closed. + Dispatch errors never propagate into discord.py's event loop. + """ + handler = getattr(self, "_platform_event_handler", None) + if handler is None: + return + try: + await handler(event, source) + except Exception: + logger.debug( + "[%s] gateway_platform_event dispatch error", self.name, exc_info=True, + ) + + @staticmethod + def _platform_events_subscribed() -> bool: + """has_hook fast-path shared by every Discord fire-site.""" + try: + from hermes_cli.lifecycle import has_hook + + return has_hook("gateway_platform_event") + except Exception: + return False + + async def _on_platform_message_edit(self, before, after) -> None: + """Normalize ``on_message_edit`` into event_type ``message_edited``.""" + if not self._platform_events_subscribed(): + return + try: + message = after if after is not None else before + author = getattr(message, "author", None) + if author is not None and getattr(author, "bot", False): + return # bot's own progressive edits are noise, not user events + thread_id, chat_id = self._thread_id_and_chat_for_channel( + getattr(message, "channel", None) + ) + message_id = getattr(message, "id", None) + if chat_id is None or message_id is None: + return + text = getattr(message, "content", None) + edited_at = getattr(message, "edited_at", None) + guild = getattr(message, "guild", None) + event = { + "platform": "discord", + "event_type": "message_edited", + "payload": { + "chat_id": str(chat_id)[:128], + "message_id": str(message_id)[:128], + "thread_id": thread_id[:128] if thread_id else None, + "text": text[:8192] if isinstance(text, str) else None, + "edited_at": ( + str(edited_at.isoformat())[:64] + if edited_at is not None and hasattr(edited_at, "isoformat") + else None + ), + }, + } + source = self._source_for_platform_event( + chat_id=str(chat_id), + user_id=str(getattr(author, "id", "") or "") or None, + user_name=getattr(author, "display_name", None), + thread_id=thread_id, + guild_id=str(getattr(guild, "id", "")) if guild else None, + message_id=str(message_id), + ) + except Exception: + logger.debug( + "[%s] message_edited normalize error", self.name, exc_info=True, + ) + return + await self._fire_platform_event(event, source) + + async def _on_platform_message_delete(self, message) -> None: + """Normalize ``on_message_delete`` into event_type ``message_deleted``. + + Discord does not identify the deleter in this event; the source + authorized is the deleted message's author (the only identity the + cached event carries). Uncached deletions never fire. + """ + if not self._platform_events_subscribed(): + return + try: + author = getattr(message, "author", None) + if author is not None and getattr(author, "bot", False): + return + thread_id, chat_id = self._thread_id_and_chat_for_channel( + getattr(message, "channel", None) + ) + message_id = getattr(message, "id", None) + if chat_id is None or message_id is None: + return + guild = getattr(message, "guild", None) + event = { + "platform": "discord", + "event_type": "message_deleted", + "payload": { + "chat_id": str(chat_id)[:128], + "message_id": str(message_id)[:128], + "thread_id": thread_id[:128] if thread_id else None, + "author_id": str(getattr(author, "id", "") or "")[:128] or None, + }, + } + source = self._source_for_platform_event( + chat_id=str(chat_id), + user_id=str(getattr(author, "id", "") or "") or None, + user_name=getattr(author, "display_name", None), + thread_id=thread_id, + guild_id=str(getattr(guild, "id", "")) if guild else None, + message_id=str(message_id), + ) + except Exception: + logger.debug( + "[%s] message_deleted normalize error", self.name, exc_info=True, + ) + return + await self._fire_platform_event(event, source) + + async def _on_platform_thread_create(self, thread) -> None: + """Normalize ``on_thread_create`` into event_type ``thread_created``.""" + if not self._platform_events_subscribed(): + return + try: + thread_id = getattr(thread, "id", None) + owner_id = getattr(thread, "owner_id", None) + if thread_id is None: + return + parent_id = getattr(thread, "parent_id", None) + guild = getattr(thread, "guild", None) + name = getattr(thread, "name", None) + event = { + "platform": "discord", + "event_type": "thread_created", + "payload": { + "thread_id": str(thread_id)[:128], + "parent_chat_id": str(parent_id)[:128] if parent_id is not None else None, + "name": name[:256] if isinstance(name, str) else None, + "owner_id": str(owner_id)[:128] if owner_id is not None else None, + }, + } + source = self._source_for_platform_event( + chat_id=str(thread_id), + user_id=str(owner_id) if owner_id is not None else None, + user_name=None, + thread_id=str(thread_id), + guild_id=str(getattr(guild, "id", "")) if guild else None, + ) + except Exception: + logger.debug( + "[%s] thread_created normalize error", self.name, exc_info=True, + ) + return + await self._fire_platform_event(event, source) + + async def _on_platform_thread_update(self, before, after) -> None: + """Normalize a rename observed via ``on_thread_update`` into + event_type ``thread_renamed``. Non-rename updates (archive state, + slowmode, tags) are dropped. + + Discord's thread-update event carries no actor; the thread owner is + the only stable identity available, so that is what the gateway + authorizes (same trade-off as ``message_deleted``'s author). + """ + if not self._platform_events_subscribed(): + return + try: + old_name = getattr(before, "name", None) + new_name = getattr(after, "name", None) + if old_name == new_name or not isinstance(new_name, str): + return + thread_id = getattr(after, "id", None) + owner_id = getattr(after, "owner_id", None) + if thread_id is None: + return + parent_id = getattr(after, "parent_id", None) + guild = getattr(after, "guild", None) + event = { + "platform": "discord", + "event_type": "thread_renamed", + "payload": { + "thread_id": str(thread_id)[:128], + "parent_chat_id": str(parent_id)[:128] if parent_id is not None else None, + "old_name": old_name[:256] if isinstance(old_name, str) else None, + "new_name": new_name[:256], + }, + } + source = self._source_for_platform_event( + chat_id=str(thread_id), + user_id=str(owner_id) if owner_id is not None else None, + user_name=None, + thread_id=str(thread_id), + guild_id=str(getattr(guild, "id", "")) if guild else None, + ) + except Exception: + logger.debug( + "[%s] thread_renamed normalize error", self.name, exc_info=True, + ) + return + await self._fire_platform_event(event, source) + async def _cancel_bot_task(self) -> None: """Cancel and await the background client.start() task, if running.""" if self._bot_task and not self._bot_task.done(): diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 47b3b6ecb0087..b714ab7a12155 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -3765,23 +3765,63 @@ class TelegramAdapter(BasePlatformAdapter): # profile-scoped authorization chain runs before plugin dispatch. No # callback means no trusted auth boundary, so fail closed. try: - source = self._source_from_reaction_for_auth(update) + source = self._source_for_platform_event_auth(update) await handler(event, source) except Exception: logger.debug("[%s] gateway_platform_event dispatch error", self.name, exc_info=True) return + def _source_for_platform_event_auth(self, update): + """Route a supported update to its event-specific auth-source extractor. + + Every ``gateway_platform_event`` type needs its own identity + extraction (a reaction carries the reactor; an edit carries the + editor). Raises ``ValueError`` for updates without a wired extractor + so the post-auth boundary fails closed rather than authorizing an + incomplete source. + """ + if getattr(update, "message_reaction", None) is not None: + return self._source_from_reaction_for_auth(update) + edited = getattr(update, "edited_message", None) + if edited is not None: + source = self._source_from_message_for_auth(edited) + # _source_from_message_for_auth tolerates missing identities for + # its pairing-flow callers; the platform-event boundary must not. + if not source.user_id or not source.chat_id: + raise ValueError( + "gateway_platform_event message_edited requires editor " + "and chat identities" + ) + return source + raise ValueError( + "gateway_platform_event source extraction has no extractor for " + "this update type" + ) + def _normalize_platform_event(self, update) -> Optional[Dict[str, Any]]: """Map an inbound PTB update to a normalized ``gateway_platform_event`` envelope ``{platform, event_type, payload}``, or ``None`` if unsupported. + Each supported event type has its own event-local, additive payload + contract (documented in hooks.md). Raw SDK objects never leave this + boundary. Update types without a wired contract (forward, chat-member) + return ``None`` unless and until they gain a concrete contract and + current fire-site consumer. + """ + if getattr(update, "message_reaction", None) is not None: + return self._normalize_reaction_event(update) + if getattr(update, "edited_message", None) is not None: + return self._normalize_message_edited_event(update) + return None + + def _normalize_reaction_event(self, update) -> Optional[Dict[str, Any]]: + """Normalize a ``message_reaction`` update (event_type ``reaction``). + Reaction (the motivating use case: a plugin that re-renders or reacts to a message when the user reacts to it) is normalized to the fields a plugin consumes: ``emojis`` (standard unicode), ``custom_emoji_ids`` (custom reaction emojis — PTB exposes ``custom_emoji_id`` with no - ``.emoji``), ``chat_id``, ``message_id``, ``thread_id``. Other update - types (forward, edit, chat-member) return ``None`` unless and until - they gain a concrete contract and current fire-site consumer. + ``.emoji``), ``chat_id``, ``message_id``, ``thread_id``. """ mr = getattr(update, "message_reaction", None) if mr is None: @@ -3825,6 +3865,58 @@ class TelegramAdapter(BasePlatformAdapter): }, } + def _normalize_message_edited_event(self, update) -> Optional[Dict[str, Any]]: + """Normalize an ``edited_message`` update (event_type ``message_edited``). + + Payload contract (v1, additive): ``chat_id``, ``message_id``, + ``thread_id`` (forum topic when present), ``text`` (edited text or + caption, bounded), ``edited_at`` (ISO 8601 UTC or None). No raw PTB + ``Message`` object leaves this boundary. Malformed identities return + ``None`` so the fire-site drops the event. + """ + message = getattr(update, "edited_message", None) + if message is None: + return None + chat = getattr(message, "chat", None) + chat_id = getattr(chat, "id", None) if chat is not None else None + message_id = getattr(message, "message_id", None) + if ( + isinstance(chat_id, bool) + or not isinstance(chat_id, (str, int)) + or isinstance(message_id, bool) + or not isinstance(message_id, (str, int)) + ): + return None + text = getattr(message, "text", None) or getattr(message, "caption", None) + if not isinstance(text, str): + text = None + thread_id = None + thread_id_raw = getattr(message, "message_thread_id", None) + if ( + not isinstance(thread_id_raw, bool) + and isinstance(thread_id_raw, (str, int)) + and bool(getattr(message, "is_topic_message", False)) + ): + thread_id = str(thread_id_raw)[:128] + edited_at = None + edit_date = getattr(message, "edit_date", None) + try: + if edit_date is not None and hasattr(edit_date, "isoformat"): + edited_at = str(edit_date.isoformat())[:64] + except Exception: + edited_at = None + return { + "platform": "telegram", + "event_type": "message_edited", + "payload": { + "chat_id": str(chat_id)[:128], + "message_id": str(message_id)[:128], + "thread_id": thread_id, + "text": text[:8192] if text is not None else None, + "edited_at": edited_at, + }, + } + def _register_handlers(self, app) -> None: """Register every PTB handler on ``app``. diff --git a/tests/gateway/test_discord_platform_events.py b/tests/gateway/test_discord_platform_events.py new file mode 100644 index 0000000000000..b57e7370ee0d8 --- /dev/null +++ b/tests/gateway/test_discord_platform_events.py @@ -0,0 +1,388 @@ +"""Discord ``gateway_platform_event`` fire-sites (#64176 remaining scope). + +Covers the Discord half of the normalized-envelope pipeline: +* ``message_edited`` / ``message_deleted`` / ``thread_created`` / + ``thread_renamed`` normalize to stable plain-dict envelopes (no raw SDK + objects) and dispatch through the gateway-owned post-auth boundary +* bot-authored events are dropped at the fire-site (streaming edits are noise) +* malformed events (missing ids / identities) drop, fail closed +* no installed gateway callback means no fire (no trusted auth boundary) +* the has_hook no-subscriber fast-path skips all normalization work +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import Platform + +_repo = str(Path(__file__).resolve().parents[2]) +if _repo not in sys.path: + sys.path.insert(0, _repo) + + +# --------------------------------------------------------------------------- +# discord.py is an optional dep; mock it so the adapter imports +# (same shim as test_discord_attachment_download). +# --------------------------------------------------------------------------- +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.ui = SimpleNamespace( + View=object, button=lambda *a, **k: (lambda fn: fn), Button=object, + ) + discord_mod.ButtonStyle = SimpleNamespace( + success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3, + ) + discord_mod.Color = SimpleNamespace( + orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5, + ) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + +# Import Thread from the mocked module, not discord directly, so isinstance +# checks in the adapter match the class our fixtures instantiate. +_DiscordThread = sys.modules["discord"].Thread + +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 + + +def _adapter() -> DiscordAdapter: + """Build a DiscordAdapter without the heavy __init__ (fire-sites only + need platform/config/gateway_runner and the handler slot).""" + a = object.__new__(DiscordAdapter) + a.platform = Platform.DISCORD + a.config = SimpleNamespace(extra={}) + a.gateway_runner = None + return a + + +def _channel(chan_id=555, thread=False): + if thread: + chan = _DiscordThread() + chan.id = chan_id + return chan + return SimpleNamespace(id=chan_id) + + +def _message( + *, + message_id=456, + chan=None, + author_id=777, + bot=False, + content="hello world", + edited_at=None, +): + return SimpleNamespace( + id=message_id, + channel=chan if chan is not None else _channel(), + author=SimpleNamespace(id=author_id, bot=bot, display_name="user"), + content=content, + edited_at=edited_at, + guild=SimpleNamespace(id=999), + ) + + +def _thread_obj(*, thread_id=321, name="my thread", owner_id=777, parent_id=555): + t = _DiscordThread() + t.id = thread_id + t.name = name + t.owner_id = owner_id + t.parent_id = parent_id + t.guild = SimpleNamespace(id=999) + return t + + +@pytest.fixture(autouse=True) +def _observer_available(monkeypatch): + monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda _name: True) + + +def _capture(a): + seen: list = [] + + async def observe(event, source): + seen.append((event, source)) + + a.set_platform_event_handler(observe) + return seen + + +class TestMessageEdited: + def test_edit_normalized_and_fired(self): + a = _adapter() + seen = _capture(a) + after = _message(content="edited!") + + asyncio.run(a._on_platform_message_edit(_message(), after)) + + assert len(seen) == 1 + event, source = seen[0] + assert event == { + "platform": "discord", + "event_type": "message_edited", + "payload": { + "chat_id": "555", + "message_id": "456", + "thread_id": None, + "text": "edited!", + "edited_at": None, + }, + } + json.dumps(event) + assert source.user_id == "777" + assert source.chat_id == "555" + + def test_edit_in_thread_carries_thread_id(self): + a = _adapter() + seen = _capture(a) + after = _message(chan=_channel(chan_id=888, thread=True)) + + asyncio.run(a._on_platform_message_edit(None, after)) + + event, source = seen[0] + assert event["payload"]["thread_id"] == "888" + assert event["payload"]["chat_id"] == "888" + assert source.thread_id == "888" + + def test_bot_authored_edit_dropped(self): + """The bot's own progressive streaming edits must not fire.""" + a = _adapter() + seen = _capture(a) + + asyncio.run(a._on_platform_message_edit(None, _message(bot=True))) + + assert seen == [] + + def test_edited_at_serialized(self): + import datetime as _dt + + a = _adapter() + seen = _capture(a) + after = _message( + edited_at=_dt.datetime(2026, 8, 12, 10, 30, tzinfo=_dt.timezone.utc), + ) + + asyncio.run(a._on_platform_message_edit(None, after)) + + assert seen[0][0]["payload"]["edited_at"] == "2026-08-12T10:30:00+00:00" + + def test_no_subscriber_skips_everything(self): + a = _adapter() + handler = AsyncMock() + a.set_platform_event_handler(handler) + a._thread_id_and_chat_for_channel = MagicMock() + + import hermes_cli.lifecycle as lifecycle + orig = lifecycle.has_hook + lifecycle.has_hook = lambda _n: False + try: + asyncio.run(a._on_platform_message_edit(None, _message())) + finally: + lifecycle.has_hook = orig + + a._thread_id_and_chat_for_channel.assert_not_called() + handler.assert_not_awaited() + + def test_no_gateway_callback_fails_closed(self): + a = _adapter() # set_platform_event_handler never called + asyncio.run(a._on_platform_message_edit(None, _message())) # no raise + + def test_missing_ids_drop(self): + a = _adapter() + seen = _capture(a) + after = _message() + after.channel = None + + asyncio.run(a._on_platform_message_edit(None, after)) + + assert seen == [] + + def test_dispatch_error_is_swallowed(self): + a = _adapter() + + async def boom(event, source): + raise RuntimeError("plugin boom") + + a.set_platform_event_handler(boom) + asyncio.run(a._on_platform_message_edit(None, _message())) # no raise + + +class TestMessageDeleted: + def test_delete_normalized_and_fired(self): + a = _adapter() + seen = _capture(a) + + asyncio.run(a._on_platform_message_delete(_message())) + + event, source = seen[0] + assert event == { + "platform": "discord", + "event_type": "message_deleted", + "payload": { + "chat_id": "555", + "message_id": "456", + "thread_id": None, + "author_id": "777", + }, + } + assert source.user_id == "777" + + def test_bot_authored_delete_dropped(self): + a = _adapter() + seen = _capture(a) + + asyncio.run(a._on_platform_message_delete(_message(bot=True))) + + assert seen == [] + + def test_missing_author_fails_closed(self): + """No author identity means nothing to authorize against — drop.""" + a = _adapter() + seen = _capture(a) + msg = _message() + msg.author = None + + asyncio.run(a._on_platform_message_delete(msg)) + + assert seen == [] + + +class TestThreadCreated: + def test_thread_create_normalized_and_fired(self): + a = _adapter() + seen = _capture(a) + + asyncio.run(a._on_platform_thread_create(_thread_obj())) + + event, source = seen[0] + assert event == { + "platform": "discord", + "event_type": "thread_created", + "payload": { + "thread_id": "321", + "parent_chat_id": "555", + "name": "my thread", + "owner_id": "777", + }, + } + assert source.thread_id == "321" + assert source.user_id == "777" + + def test_missing_owner_fails_closed(self): + a = _adapter() + seen = _capture(a) + t = _thread_obj(owner_id=None) + + asyncio.run(a._on_platform_thread_create(t)) + + assert seen == [] + + +class TestThreadRenamed: + def test_rename_normalized_and_fired(self): + a = _adapter() + seen = _capture(a) + before = _thread_obj(name="old name") + after = _thread_obj(name="new name") + + asyncio.run(a._on_platform_thread_update(before, after)) + + event, _source = seen[0] + assert event == { + "platform": "discord", + "event_type": "thread_renamed", + "payload": { + "thread_id": "321", + "parent_chat_id": "555", + "old_name": "old name", + "new_name": "new name", + }, + } + + def test_non_rename_update_dropped(self): + """Archive/slowmode/tag updates share on_thread_update — only real + renames fire.""" + a = _adapter() + seen = _capture(a) + before = _thread_obj(name="same") + after = _thread_obj(name="same") + + asyncio.run(a._on_platform_thread_update(before, after)) + + assert seen == [] + + +class TestRunnerBoundaryIntegration: + def test_unauthorized_discord_event_never_reaches_hooks(self): + """Full path: adapter fire-site -> runner post-auth gate denies.""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner._is_user_authorized = lambda source: False + invoked = MagicMock() + a = _adapter() + a.set_platform_event_handler(runner._handle_gateway_platform_event) + + import hermes_cli.lifecycle as lifecycle + orig_invoke = lifecycle.invoke_hook + lifecycle.invoke_hook = invoked + try: + asyncio.run(a._on_platform_message_edit(None, _message())) + finally: + lifecycle.invoke_hook = orig_invoke + + invoked.assert_not_called() + + def test_authorized_discord_event_reaches_hooks(self): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner._is_user_authorized = lambda source: source.user_id == "777" + invoked = MagicMock() + a = _adapter() + a.set_platform_event_handler(runner._handle_gateway_platform_event) + + import hermes_cli.lifecycle as lifecycle + orig_invoke = lifecycle.invoke_hook + lifecycle.invoke_hook = invoked + try: + asyncio.run(a._on_platform_message_edit(None, _message())) + finally: + lifecycle.invoke_hook = orig_invoke + + invoked.assert_called_once() + args, kwargs = invoked.call_args + assert args == ("gateway_platform_event",) + assert kwargs["platform"] == "discord" + assert kwargs["event_type"] == "message_edited" diff --git a/tests/gateway/test_gateway_platform_event_hook.py b/tests/gateway/test_gateway_platform_event_hook.py index 61af3f4c439b7..694d392441136 100644 --- a/tests/gateway/test_gateway_platform_event_hook.py +++ b/tests/gateway/test_gateway_platform_event_hook.py @@ -296,11 +296,140 @@ class TestNormalizePlatformEvent: """Unsupported update types return None until a concrete contract exists.""" a = _adapter() update = MagicMock() - update.message_reaction = None # e.g. an edited_message or chat_member update + update.message_reaction = None # e.g. a chat_member update + update.edited_message = None assert a._normalize_platform_event(update) is None +# --------------------------------------------------------------------------- +# TelegramAdapter message_edited normalization (#64176 remaining scope) +# --------------------------------------------------------------------------- + +def _edited_update( + *, + chat_id: object = 123, + message_id: object = 456, + text: object = "fixed typo", + user_id: object = 777, + chat_type: str = "private", +): + """A PTB Update stand-in carrying an edited_message.""" + update = MagicMock() + update.message_reaction = None + m = MagicMock() + m.chat.id = chat_id + m.chat.type = chat_type + m.chat.is_forum = False + m.message_id = message_id + m.text = text + m.caption = None + m.message_thread_id = None + m.is_topic_message = False + m.edit_date = None + m.from_user.id = user_id + m.from_user.username = "editor" + m.from_user.full_name = "Editor" + update.edited_message = m + return update + + +class TestNormalizeMessageEdited: + def test_edited_message_normalized(self): + a = _adapter() + update = _edited_update(chat_id=123, message_id=456, text="fixed typo") + + assert a._normalize_platform_event(update) == { + "platform": "telegram", + "event_type": "message_edited", + "payload": { + "chat_id": "123", + "message_id": "456", + "thread_id": None, + "text": "fixed typo", + "edited_at": None, + }, + } + + def test_caption_falls_back_when_no_text(self): + a = _adapter() + update = _edited_update(text=None) + update.edited_message.caption = "new caption" + + event = a._normalize_platform_event(update) + assert event["payload"]["text"] == "new caption" + + def test_forum_topic_thread_id_included(self): + a = _adapter() + update = _edited_update(chat_type="supergroup") + update.edited_message.message_thread_id = 42 + update.edited_message.is_topic_message = True + update.edited_message.chat.is_forum = True + + event = a._normalize_platform_event(update) + assert event["payload"]["thread_id"] == "42" + + def test_edit_date_serialized_iso(self): + import datetime as _dt + + a = _adapter() + update = _edited_update() + update.edited_message.edit_date = _dt.datetime( + 2026, 8, 12, 10, 30, tzinfo=_dt.timezone.utc, + ) + + event = a._normalize_platform_event(update) + assert event["payload"]["edited_at"] == "2026-08-12T10:30:00+00:00" + + def test_malformed_identities_return_none(self): + a = _adapter() + update = _edited_update(chat_id=object()) + assert a._normalize_platform_event(update) is None + update = _edited_update(message_id=None) + assert a._normalize_platform_event(update) is None + + def test_text_is_bounded_and_json_safe(self): + a = _adapter() + update = _edited_update(text="x" * 20000) + + event = a._normalize_platform_event(update) + assert len(event["payload"]["text"]) == 8192 + json.dumps(event) + + def test_edited_event_fires_through_boundary_with_editor_source(self): + a = _adapter() + seen: list = [] + + async def observe(event, source): + seen.append((event, source)) + + a.set_platform_event_handler(observe) + asyncio.run(a._on_platform_update(_edited_update(), context=MagicMock())) + + assert len(seen) == 1 + event, source = seen[0] + assert event["event_type"] == "message_edited" + assert source.user_id == "777" + assert source.chat_id == "123" + + def test_edited_event_missing_editor_fails_closed(self): + """No from_user (and no sender_chat) means no identity to authorize — + the boundary must drop the event rather than fire it.""" + a = _adapter() + seen: list = [] + + async def observe(event, source): + seen.append((event, source)) + + a.set_platform_event_handler(observe) + update = _edited_update() + update.edited_message.from_user = None + update.edited_message.sender_chat = None + + asyncio.run(a._on_platform_update(update, context=MagicMock())) + assert seen == [] + + # --------------------------------------------------------------------------- # TelegramAdapter._on_platform_update — fire-site # --------------------------------------------------------------------------- @@ -398,6 +527,7 @@ class TestOnPlatformUpdateAuthBoundary: update = MagicMock() update.message_reaction = None # a future, not-yet-wired event type + update.edited_message = None # Simulate that future normalization produced an event for it. a._normalize_platform_event = lambda u: { # type: ignore[assignment] "platform": "telegram", "event_type": "future", "payload": {}, diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index c20f7646f66c9..bcad31ce22e17 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -461,7 +461,7 @@ Payload fields below are the exact event-specific fields supplied by each call s | `subagent_start` | Observer | Child constructed and about to run; return ignored. | `parent_session_id`, `parent_turn_id`, `parent_subagent_id`, `child_session_id`, `child_subagent_id`, `child_role`, `child_goal` | Child goal may contain user/project content. | | `subagent_stop` | Observer | Child exit; return ignored. | `parent_session_id`, `parent_turn_id`, `child_session_id`, `child_role`, `child_summary`, `child_status`, `tool_call_history`, `duration_ms` | Summary and redacted tool-history metadata may reveal project structure. | | `pre_gateway_dispatch` | Directive/control | Incoming non-internal message before auth/pairing/dispatch; first valid `skip`, `rewrite`, or `allow` controls flow. | `event`, `gateway`, `session_store` | Extremely privileged in-process objects expose inbound user/routing data and host handles. | -| `gateway_platform_event` | Observer | After the gateway's profile-scoped authorization succeeds, when a supported platform-native event is normalized at the gateway boundary (Telegram reactions currently); return ignored. | `platform`, `event_type`, `payload` (reactions: `emojis`, `custom_emoji_ids`, `chat_id`, `message_id`, `thread_id`) | Normalized plain-dict envelope only; raw SDK objects, adapter handles, and bot clients are never exposed. | +| `gateway_platform_event` | Observer | After the gateway's profile-scoped authorization succeeds, when a supported platform-native event is normalized at the gateway boundary (Telegram: reactions, message edits; Discord: message edits/deletes, thread created/renamed); return ignored. | `platform`, `event_type`, `payload` (event-type-specific dict — see the per-event contracts below) | Normalized plain-dict envelope only; raw SDK objects, adapter handles, and bot clients are never exposed. | | `pre_command` | Observer | Recognized slash command about to be dispatched, before the handler runs, on CLI and gateway cold-path dispatch; return ignored in v1 (directive-shaped dicts are logged at debug). Gateway running-agent intercept commands (`/stop`, `/approve` during an active run) are deliberately excluded — control-plane escape hatches must stay outside plugin reach. | `surface` (`"cli"` \| `"gateway"`), `command` (canonical name), `alias_used`, `args_raw`, `session_key`, `platform` | `args_raw` may contain user content or secrets typed after the command. | | `pre_approval_request` | Observer | Before prompted or smart approval; return ignored. | `command`, `description`, `pattern_key`, `pattern_keys`, `session_key`, `surface`, `turn_id`, `tool_call_id` | Command may contain secrets; smart observer preparation force-redacts, but surfaces do not all have identical redaction. | | `post_approval_response` | Observer | After a decision, timeout, or gateway notification failure; return ignored. | `command`, `description`, `pattern_key`, `pattern_keys`, `session_key`, `surface`, `turn_id`, `tool_call_id`, `choice`; smart path may add `decided_by` | Same command sensitivity plus decision metadata. | @@ -1189,12 +1189,14 @@ def register(ctx): Fires for supported platform-native events only **after** the gateway's normal, profile-scoped authorization check succeeds. The callback receives plain dictionaries; raw SDK objects, adapter handles, bot clients, and callback contexts are never part of this stable contract. -Telegram message reactions are the first supported event: +Telegram message reactions were the first supported event; message edits, deletes, and thread lifecycle events followed: ```python def on_platform_event(platform, event_type, payload, **kwargs): if platform == "telegram" and event_type == "reaction": print(payload["chat_id"], payload["message_id"], payload["emojis"]) + elif event_type == "message_edited": + print(platform, payload["chat_id"], payload["message_id"], payload["text"]) def register(ctx): ctx.register_hook("gateway_platform_event", on_platform_event) @@ -1202,13 +1204,25 @@ def register(ctx): | Parameter | Type | Description | |-----------|------|-------------| -| `platform` | `str` | Stable platform id (`"telegram"`). | -| `event_type` | `str` | Event-local contract id (`"reaction"`). | -| `payload` | `dict` | For reactions: `emojis: list[str]`, `custom_emoji_ids: list[str]`, `chat_id: str \| None`, `message_id: str`, and `thread_id: str \| None`. | +| `platform` | `str` | Stable platform id (`"telegram"`, `"discord"`). | +| `event_type` | `str` | Event-local contract id (see the table below). | +| `payload` | `dict` | Event-type-specific fields, documented per event type below. | -The reaction payload is additive and event-specific; there is no monolithic gateway payload version. Telegram reaction updates do not carry a topic id, so `thread_id` is currently `None` rather than guessed. Malformed events and events whose source cannot be authorized are dropped. A transient Telegram Application rebuild re-registers the observer together with the core handlers. +Every payload is additive and event-specific; there is no monolithic gateway payload version. All ids are strings; missing/unavailable fields are `None`, never guessed. Malformed events and events whose source cannot be authorized are dropped (fail closed). A transient Telegram Application rebuild re-registers the observer together with the core handlers. -This hook is observer-only. It does **not** add raw-event access, adapter access, cross-chat actions, or a platform-action facade. `PluginContext.dispatch_tool()` can only call tools registered in the tool registry; `send_message` is intentionally not registered there (its transport is reserved for explicit CLI, cron, kanban, and MCP delivery paths). Consequently a hook callback cannot currently call `ctx.dispatch_tool("send_message", ...)` for a media fallback. A future outbound-delivery contract must first provide stable delivered content/handles across all adapters; this slice does not pre-register an inert `gateway_message_delivered` hook. +**Per-event payload contracts (v1, additive):** + +| `event_type` | Platforms | Payload fields | +|--------------|-----------|----------------| +| `reaction` | telegram | `emojis: list[str]`, `custom_emoji_ids: list[str]`, `chat_id: str`, `message_id: str`, `thread_id: str \| None` (Telegram reaction updates carry no topic id, so currently always `None`). | +| `message_edited` | telegram, discord | `chat_id: str`, `message_id: str`, `thread_id: str \| None`, `text: str \| None` (edited text or caption, bounded; `None` for media-only edits or when uncached), `edited_at: str \| None` (ISO 8601). | +| `message_deleted` | discord | `chat_id: str`, `message_id: str`, `thread_id: str \| None`, `author_id: str \| None`. Discord's delete event does not identify the deleter; the authorized source is the deleted message's author, and uncached deletions never fire. | +| `thread_created` | discord | `thread_id: str`, `parent_chat_id: str \| None`, `name: str \| None`, `owner_id: str \| None`. | +| `thread_renamed` | discord | `thread_id: str`, `parent_chat_id: str \| None`, `old_name: str \| None`, `new_name: str`. Fired only when the name actually changed; other thread updates (archive, slowmode, tags) are dropped. Discord's thread-update event carries no actor, so the thread owner is the authorized source. | + +The bot's own progressive message edits (streaming) never fire `message_edited` on Discord — bot-authored events are dropped at the fire-site. + +This hook is observer-only: it does **not** add raw-event access or adapter access. **Raw SDK payload access is deliberately not shipped** — adapter SDK objects change shape without notice and would become un-evolvable API surface; where genuinely needed it requires its own explicit capability (`gateway.raw_events`) with a "no stability guarantee" label and its own design (tracked in #64228). For *acting* on a platform (adding a reaction, renaming a thread), use the capability-gated `ctx.platform_actions` facade documented in the [plugins guide](plugins.md#platform-actions) — it is gated off by default behind the `gateway.platform_actions` capability. `PluginContext.dispatch_tool()` can only call tools registered in the tool registry; `send_message` is intentionally not registered there (its transport is reserved for explicit CLI, cron, kanban, and MCP delivery paths). A future outbound-delivery contract must first provide stable delivered content/handles across all adapters; this slice does not pre-register an inert `gateway_message_delivered` hook. ---