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 <pantinor@redhat.com>
This commit is contained in:
Teknium 2026-08-12 19:40:01 -07:00
parent fe8b44dac4
commit 3b7c940208
6 changed files with 928 additions and 16 deletions

View File

@ -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.<id>.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,

View File

@ -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():

View File

@ -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``.

View File

@ -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"

View File

@ -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": {},

View File

@ -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.
---