Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,13 @@ def merge_pending_message_event(
MessageHandler = Callable[[MessageEvent], Awaitable[Optional[Union[str, "EphemeralReply"]]]]


# Type for reaction handlers. Receives a normalised event dict describing
# the reaction (platform, user_id, item info, optional permalink) and is
# expected to fan-out to the gateway hook system. Always async; returns
# nothing.
ReactionHandler = Callable[[Dict[str, Any]], Awaitable[None]]


def resolve_channel_prompt(
config_extra: dict,
channel_id: str,
Expand Down Expand Up @@ -1516,6 +1523,7 @@ def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
self._message_handler: Optional[MessageHandler] = None
self._reaction_handler: Optional["ReactionHandler"] = None
self._running = False
self._fatal_error_code: Optional[str] = None
self._fatal_error_message: Optional[str] = None
Expand Down Expand Up @@ -1774,6 +1782,21 @@ def set_message_handler(self, handler: MessageHandler) -> None:
def set_busy_session_handler(self, handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]]) -> None:
"""Set an optional handler for messages arriving during active sessions."""
self._busy_session_handler = handler

def set_reaction_handler(self, handler: Optional["ReactionHandler"]) -> None:
"""
Set the handler for emoji reactions on platform messages.

Currently emitted by adapters that subscribe to platform-native
reaction events (Slack ``reaction_added``/``reaction_removed``).
The handler receives a normalised event dict — see
``website/docs/user-guide/features/hooks.md`` for the schema —
and is expected to fan-out via ``HookRegistry.emit("reaction:added", ...)``.

Adapters that don't support reactions simply never call the
handler; this setter is a no-op for them.
"""
self._reaction_handler = handler

def set_session_store(self, session_store: Any) -> None:
"""
Expand Down
60 changes: 60 additions & 0 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,24 @@ async def handle_assistant_thread_started(event, say):
async def handle_assistant_thread_context_changed(event, say):
await self._handle_assistant_thread_lifecycle_event(event)

# Reaction lifecycle. Forward to the gateway-supplied reaction
# handler which fans-out via HookRegistry as ``reaction:added``
# / ``reaction:removed``. We resolve a permalink for message
# targets here (one Web API call per reaction) so downstream
# hook handlers don't each repeat the lookup.
#
# Requires the bot OAuth scope ``reactions:read`` plus the
# ``reaction_added`` / ``reaction_removed`` bot event
# subscriptions in the Slack app config. Bot must be a member
# of the channel where the reaction occurs.
@self._app.event("reaction_added")
async def handle_reaction_added(event, say):
await self._forward_reaction_event("reaction:added", event)

@self._app.event("reaction_removed")
async def handle_reaction_removed(event, say):
await self._forward_reaction_event("reaction:removed", event)

# Register slash command handler(s)
#
# Every gateway command from COMMAND_REGISTRY is a native Slack
Expand Down Expand Up @@ -1288,6 +1306,48 @@ def _convert_header(m):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please add dispatch tests for both event names, normalized payloads, and a failed permalink lookup. Current Slack coverage only asserts registration of the two event names (tests/gateway/test_slack.py:241-242).

# ----- Reactions -----

async def _forward_reaction_event(self, hook_event_name: str, event: dict) -> None:
"""Normalise a Slack reaction event and dispatch to the gateway handler.

Adapter callers (``reaction_added`` / ``reaction_removed``) feed
raw Slack event payloads in here. We resolve a permalink for
``item.type == "message"`` and pass the structured dict to the
gateway-supplied reaction handler. Errors are caught — they must
never bubble into Slack's event loop.
"""
handler = self._reaction_handler
if handler is None:
return
try:
item = event.get("item") or {}
channel_id = item.get("channel")
message_ts = item.get("ts")
permalink: Optional[str] = None
if item.get("type") == "message" and channel_id and message_ts:
try:
resp = await self._get_client(channel_id).chat_getPermalink(
channel=channel_id, message_ts=message_ts
)
permalink = resp.get("permalink")
except Exception as e:
logger.debug("[Slack] chat_getPermalink failed for reaction: %s", e)
ctx = {
"platform": "slack",
"event_name": hook_event_name,
"reaction": event.get("reaction"),
"user_id": event.get("user"),
"item_user_id": event.get("item_user"),
"item_type": item.get("type"),
"channel_id": channel_id,
"message_ts": message_ts,
"permalink": permalink,
"event_ts": event.get("event_ts"),
"raw_event": event,
}
await handler(ctx)
except Exception as e:
logger.exception("[Slack] reaction event forwarding failed: %s", e)

async def _add_reaction(
self, channel: str, timestamp: str, emoji: str
) -> bool:
Expand Down
20 changes: 20 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2464,6 +2464,24 @@ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwar
route["request_overrides"] = overrides or {}
return route

async def _handle_reaction_event(self, ctx: Dict[str, Any]) -> None:
"""Forward a normalised reaction event to the HookRegistry.

Adapters call this via ``set_reaction_handler`` for every
platform-native reaction event they choose to surface. We map
the adapter-supplied ``event_name`` ("reaction:added" /
"reaction:removed") onto a hook event so user hooks can subscribe
with the same name scheme as the existing ``agent:*`` family.

Errors are swallowed at the HookRegistry layer per the
non-blocking hook contract.
"""
event_name = ctx.get("event_name", "reaction:added")
try:
await self.hooks.emit(event_name, ctx)
except Exception as e:
logger.exception("[Gateway] reaction hook emit failed: %s", e)

async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None:
"""React to an adapter failure after startup.

Expand Down Expand Up @@ -4130,6 +4148,7 @@ async def start(self) -> bool:
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please also wire this callback for multiplexed-profile adapters. Current main has a separate bootstrap at gateway/run.py:8604-8612; without equivalent setup there, secondary-profile Slack reaction events will retain no handler.

adapter.set_session_store(self.session_store)
adapter.set_busy_session_handler(self._handle_active_session_busy_message)
adapter.set_reaction_handler(self._handle_reaction_event)
adapter._busy_text_mode = self._busy_text_mode

# Try to connect
Expand Down Expand Up @@ -5743,6 +5762,7 @@ async def _platform_reconnect_watcher(self) -> None:
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
adapter.set_session_store(self.session_store)
adapter.set_busy_session_handler(self._handle_active_session_busy_message)
adapter.set_reaction_handler(self._handle_reaction_event)
adapter._busy_text_mode = self._busy_text_mode

success = await self._connect_adapter_with_timeout(adapter, platform)
Expand Down
50 changes: 50 additions & 0 deletions website/docs/user-guide/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ async def handle(event_type: str, context: dict):
| `agent:start` | Agent begins processing a message | `platform`, `user_id`, `session_id`, `message` |
| `agent:step` | Each iteration of the tool-calling loop | `platform`, `user_id`, `session_id`, `iteration`, `tool_names` |
| `agent:end` | Agent finishes processing | `platform`, `user_id`, `session_id`, `message`, `response` |
| `reaction:added` | A reaction was added to a message the bot can see (Slack adapter currently). Requires `reactions:read` scope + `reaction_added` bot event subscription. Bot must be in the channel. | `platform`, `reaction`, `user_id`, `item_user_id`, `item_type`, `channel_id`, `message_ts`, `permalink`, `event_ts`, `raw_event` |
| `reaction:removed` | A reaction was removed from a message the bot can see. Requires `reactions:read` scope + `reaction_removed` bot event subscription. | same shape as `reaction:added` |
| `command:*` | Any slash command executed | `platform`, `user_id`, `command`, `args` |

#### Wildcard Matching
Expand Down Expand Up @@ -122,6 +124,54 @@ async def handle(event_type: str, context: dict):
)
```

#### Slack Reaction Capture

Turn a `:task:` reaction in Slack into a task in your tracker. The hook
receives the message permalink — fetch the message body separately if
you need the text.

```yaml
# ~/.hermes/hooks/slack-task-reaction/HOOK.yaml
name: slack-task-reaction
description: Capture messages reacted with :task: into an external tracker
events:
- reaction:added
```

```python
# ~/.hermes/hooks/slack-task-reaction/handler.py
import logging
import os

import httpx

logger = logging.getLogger(__name__)

CAPTURE_EMOJI = "task"
TRACKER_WEBHOOK = os.getenv("TRACKER_WEBHOOK_URL")

async def handle(event_type: str, context: dict):
if context.get("reaction") != CAPTURE_EMOJI:
return
permalink = context.get("permalink")
if not (permalink and TRACKER_WEBHOOK):
return
payload = {
"source": "slack",
"actor_user_id": context.get("user_id"),
"permalink": permalink,
}
async with httpx.AsyncClient() as client:
try:
await client.post(TRACKER_WEBHOOK, json=payload, timeout=10)
except httpx.HTTPError as exc:
logger.warning("Tracker webhook failed: %s", exc)
```

Requires the bot OAuth scope `reactions:read` and the `reaction_added`
bot event subscription in your Slack app config. The bot must be a member
of the channel where the reaction occurs (DMs work without an `/invite`).

#### Command Usage Logger

Track which slash commands are used:
Expand Down