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:
parent
fe8b44dac4
commit
3b7c940208
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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": {},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue