feat(slack): forward reaction_added/removed to gateway hooks - #33111
feat(slack): forward reaction_added/removed to gateway hooks#33111johnkattenhorn wants to merge 1 commit into
Conversation
Adds first-class support for reaction events in the gateway hook system.
The Slack adapter now subscribes to `reaction_added` and `reaction_removed`,
resolves a permalink for message targets, and forwards the normalised
event to a new `set_reaction_handler` callback on `BasePlatformAdapter`.
The gateway wires that callback to `HookRegistry.emit` so user-authored
hooks can subscribe to `reaction:added` / `reaction:removed` with the
same shape as existing `agent:*` and `command:*` events.
Why
---
Some workflows want to capture the *act of reacting* (e.g. emoji-driven
task capture: react with `:task:` -> push to an external tracker).
There's currently no way to receive reaction events in a hook handler
unless you patch the adapter locally.
What's emitted
--------------
`reaction:added` and `reaction:removed` events with context:
platform -> "slack"
reaction -> emoji name without colons ("task")
user_id -> reactor's Slack user ID
item_user_id -> author of the target message
item_type -> "message" / "file" / "file_comment"
channel_id -> target channel (None for non-message items)
message_ts -> target ts (None for non-message items)
permalink -> resolved via chat.getPermalink (best effort)
event_ts -> Slack event timestamp
raw_event -> the original event dict for advanced consumers
Slack app config
----------------
Requires the bot OAuth scope `reactions:read` and the
`reaction_added`/`reaction_removed` bot event subscriptions. Bot must be
a member of the channel where the reaction occurs (DMs work without an
explicit /invite).
Adapter coverage
----------------
Slack only in this PR. The setter on `BasePlatformAdapter` is a no-op
for adapters that don't call the handler, so other platforms can opt in
later without API changes here. Discord and Telegram both support
reactions natively and would be reasonable follow-ups.
Tests
-----
Not added in this PR — the change is additive (no behaviour change for
adapters that don't subscribe), syntax-checked, and the slack adapter
test suite is currently empty (`tests/gateway/platforms/` has no slack
file). Happy to add unit tests for the dispatch path in a follow-up if
the maintainers want them as a prerequisite.
Docs
----
`website/docs/user-guide/features/hooks.md` updated with the two new
events plus a worked example (`:task:` reaction -> webhook).
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the concrete Slack automation use case. The feature premise still holds on current main: plugins/platforms/slack/adapter.py:1121-1127 acknowledges both reaction events with no-op handlers, while website/docs/user-guide/features/hooks.md:73-84 has no reaction hook events.
Problems
- The patch targets removed
gateway/platforms/slack.py. Commit5600105478ffde29d7566b45421b100eaa29c4efmoved it toplugins/platforms/slack/adapter.py; this PR is currently conflicting. - The proposed callback is wired only in normal startup/reconnect. Current multiplexed-profile setup has a separate adapter bootstrap at
gateway/run.py:8604-8612; it needs the same wiring or profile-owned Slack adapters cannot emit reaction hooks. - The diff adds no tests. Existing
tests/gateway/test_slack.py:241-242only checks event registration.
Suggested changes
- Port the adapter work to
plugins/platforms/slack/adapter.py, cover all adapter bootstrap paths, and add focused forwarding/permalink/error-path tests.
Automated hermes-sweeper review.
| @@ -4130,6 +4148,7 @@ async def start(self) -> bool: | |||
| adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) | |||
There was a problem hiding this comment.
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.
| @@ -1288,6 +1306,48 @@ def _convert_header(m): | |||
|
|
|||
There was a problem hiding this comment.
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).
Slack reaction_added events were explicitly acked and dropped, so a user reacting to a bot message (👍 to approve, ✅ to acknowledge) produced nothing. Forward them through the normal message pipeline as synthesized MessageEvents whose text is the reaction emoji (translated to unicode for common names), keeping the downstream auth gate, thread-context fetch, dedup, and skill routing unchanged. - Self-reactions and non-message items are dropped; reactions on messages not sent by this bot are dropped (Feishu-adapter parity). - The reacted-to message's thread parent becomes the synthesized thread_ts so the reaction lands in the same session as a reply would. - Manifest gains reactions:read scope + reaction_added bot event. Salvaged from PR #29916 by @bpross. Related: #33111, #44508, #45265 (same cluster).
… handoff Build the full reaction pipeline on top of the #29916 base: - Opt-in gate: slack.reaction_triggers (default OFF — reaction events stay acked-and-dropped so busy channels don't wake the agent on every emoji). 'true' routes reactions on the bot's OWN messages; an explicit emoji-name list routes those emojis from any message (handoff flows). - reaction_removed events now route too, distinguished by the cross-platform text convention reaction:added:<emoji> / reaction:removed:<emoji> (matches the Feishu and Photon adapters, so agents and skills see one shape everywhere). - Authorization: the reactor becomes the synthesized message's user, so the early _is_user_authorized gate and allowed_channels whitelist apply exactly as for typed messages. _hermes_force_process only skips the mention requirement (a reaction on the bot's own message is definitionally addressed to the bot), mirroring Feishu/Photon. - Gateway hooks (#33111 by @johnkattenhorn): every human reaction on a message item fires reaction:added / reaction:removed through the new BasePlatformAdapter.set_reaction_handler → GatewayRunner ._handle_reaction_event → HookRegistry.emit, independent of the routing opt-in. Documented in hooks.md. - Channel handoff (#45265 by @Kev-fs): slack.reaction_trigger_target routes the reaction turn to a configured channel (top-level via _hermes_no_thread_response + reply-anchor suppression in gateway/platforms/base.py) or C123:<ts> thread. - Manifest: reaction_removed event subscription added alongside reaction_added/reactions:read. - Docs: slack.md Reaction Triggers section; hooks.md event table rows. Also credits #44508 by @harrisonmedmedmetrics (inbound reaction_added handling — same plumbing class, superseded by this consolidated shape). Co-authored-by: johnkattenhorn <john.kattenhorn.personal@gmail.com> Co-authored-by: Kev-fs <kevin@fleetsmarts.net> Co-authored-by: harrisonmedmedmetrics <harrison@medmetricsrx.com>
Slack reaction_added events were explicitly acked and dropped, so a user reacting to a bot message (👍 to approve, ✅ to acknowledge) produced nothing. Forward them through the normal message pipeline as synthesized MessageEvents whose text is the reaction emoji (translated to unicode for common names), keeping the downstream auth gate, thread-context fetch, dedup, and skill routing unchanged. - Self-reactions and non-message items are dropped; reactions on messages not sent by this bot are dropped (Feishu-adapter parity). - The reacted-to message's thread parent becomes the synthesized thread_ts so the reaction lands in the same session as a reply would. - Manifest gains reactions:read scope + reaction_added bot event. Salvaged from PR #29916 by @bpross. Related: #33111, #44508, #45265 (same cluster).
… handoff Build the full reaction pipeline on top of the #29916 base: - Opt-in gate: slack.reaction_triggers (default OFF — reaction events stay acked-and-dropped so busy channels don't wake the agent on every emoji). 'true' routes reactions on the bot's OWN messages; an explicit emoji-name list routes those emojis from any message (handoff flows). - reaction_removed events now route too, distinguished by the cross-platform text convention reaction:added:<emoji> / reaction:removed:<emoji> (matches the Feishu and Photon adapters, so agents and skills see one shape everywhere). - Authorization: the reactor becomes the synthesized message's user, so the early _is_user_authorized gate and allowed_channels whitelist apply exactly as for typed messages. _hermes_force_process only skips the mention requirement (a reaction on the bot's own message is definitionally addressed to the bot), mirroring Feishu/Photon. - Gateway hooks (#33111 by @johnkattenhorn): every human reaction on a message item fires reaction:added / reaction:removed through the new BasePlatformAdapter.set_reaction_handler → GatewayRunner ._handle_reaction_event → HookRegistry.emit, independent of the routing opt-in. Documented in hooks.md. - Channel handoff (#45265 by @Kev-fs): slack.reaction_trigger_target routes the reaction turn to a configured channel (top-level via _hermes_no_thread_response + reply-anchor suppression in gateway/platforms/base.py) or C123:<ts> thread. - Manifest: reaction_removed event subscription added alongside reaction_added/reactions:read. - Docs: slack.md Reaction Triggers section; hooks.md event table rows. Also credits #44508 by @harrisonmedmedmetrics (inbound reaction_added handling — same plumbing class, superseded by this consolidated shape). Co-authored-by: johnkattenhorn <john.kattenhorn.personal@gmail.com> Co-authored-by: Kev-fs <kevin@fleetsmarts.net> Co-authored-by: harrisonmedmedmetrics <harrison@medmetricsrx.com>
Slack reaction_added events were explicitly acked and dropped, so a user reacting to a bot message (👍 to approve, ✅ to acknowledge) produced nothing. Forward them through the normal message pipeline as synthesized MessageEvents whose text is the reaction emoji (translated to unicode for common names), keeping the downstream auth gate, thread-context fetch, dedup, and skill routing unchanged. - Self-reactions and non-message items are dropped; reactions on messages not sent by this bot are dropped (Feishu-adapter parity). - The reacted-to message's thread parent becomes the synthesized thread_ts so the reaction lands in the same session as a reply would. - Manifest gains reactions:read scope + reaction_added bot event. Salvaged from PR #29916 by @bpross. Related: #33111, #44508, #45265 (same cluster).
… handoff Build the full reaction pipeline on top of the #29916 base: - Opt-in gate: slack.reaction_triggers (default OFF — reaction events stay acked-and-dropped so busy channels don't wake the agent on every emoji). 'true' routes reactions on the bot's OWN messages; an explicit emoji-name list routes those emojis from any message (handoff flows). - reaction_removed events now route too, distinguished by the cross-platform text convention reaction:added:<emoji> / reaction:removed:<emoji> (matches the Feishu and Photon adapters, so agents and skills see one shape everywhere). - Authorization: the reactor becomes the synthesized message's user, so the early _is_user_authorized gate and allowed_channels whitelist apply exactly as for typed messages. _hermes_force_process only skips the mention requirement (a reaction on the bot's own message is definitionally addressed to the bot), mirroring Feishu/Photon. - Gateway hooks (#33111 by @johnkattenhorn): every human reaction on a message item fires reaction:added / reaction:removed through the new BasePlatformAdapter.set_reaction_handler → GatewayRunner ._handle_reaction_event → HookRegistry.emit, independent of the routing opt-in. Documented in hooks.md. - Channel handoff (#45265 by @Kev-fs): slack.reaction_trigger_target routes the reaction turn to a configured channel (top-level via _hermes_no_thread_response + reply-anchor suppression in gateway/platforms/base.py) or C123:<ts> thread. - Manifest: reaction_removed event subscription added alongside reaction_added/reactions:read. - Docs: slack.md Reaction Triggers section; hooks.md event table rows. Also credits #44508 by @harrisonmedmedmetrics (inbound reaction_added handling — same plumbing class, superseded by this consolidated shape). Co-authored-by: johnkattenhorn <john.kattenhorn.personal@gmail.com> Co-authored-by: Kev-fs <kevin@fleetsmarts.net> Co-authored-by: harrisonmedmedmetrics <harrison@medmetricsrx.com>
Slack reaction_added events were explicitly acked and dropped, so a user reacting to a bot message (👍 to approve, ✅ to acknowledge) produced nothing. Forward them through the normal message pipeline as synthesized MessageEvents whose text is the reaction emoji (translated to unicode for common names), keeping the downstream auth gate, thread-context fetch, dedup, and skill routing unchanged. - Self-reactions and non-message items are dropped; reactions on messages not sent by this bot are dropped (Feishu-adapter parity). - The reacted-to message's thread parent becomes the synthesized thread_ts so the reaction lands in the same session as a reply would. - Manifest gains reactions:read scope + reaction_added bot event. Salvaged from PR #29916 by @bpross. Related: #33111, #44508, #45265 (same cluster).
… handoff Build the full reaction pipeline on top of the #29916 base: - Opt-in gate: slack.reaction_triggers (default OFF — reaction events stay acked-and-dropped so busy channels don't wake the agent on every emoji). 'true' routes reactions on the bot's OWN messages; an explicit emoji-name list routes those emojis from any message (handoff flows). - reaction_removed events now route too, distinguished by the cross-platform text convention reaction:added:<emoji> / reaction:removed:<emoji> (matches the Feishu and Photon adapters, so agents and skills see one shape everywhere). - Authorization: the reactor becomes the synthesized message's user, so the early _is_user_authorized gate and allowed_channels whitelist apply exactly as for typed messages. _hermes_force_process only skips the mention requirement (a reaction on the bot's own message is definitionally addressed to the bot), mirroring Feishu/Photon. - Gateway hooks (#33111 by @johnkattenhorn): every human reaction on a message item fires reaction:added / reaction:removed through the new BasePlatformAdapter.set_reaction_handler → GatewayRunner ._handle_reaction_event → HookRegistry.emit, independent of the routing opt-in. Documented in hooks.md. - Channel handoff (#45265 by @Kev-fs): slack.reaction_trigger_target routes the reaction turn to a configured channel (top-level via _hermes_no_thread_response + reply-anchor suppression in gateway/platforms/base.py) or C123:<ts> thread. - Manifest: reaction_removed event subscription added alongside reaction_added/reactions:read. - Docs: slack.md Reaction Triggers section; hooks.md event table rows. Also credits #44508 by @harrisonmedmedmetrics (inbound reaction_added handling — same plumbing class, superseded by this consolidated shape). Co-authored-by: johnkattenhorn <john.kattenhorn.personal@gmail.com> Co-authored-by: Kev-fs <kevin@fleetsmarts.net> Co-authored-by: harrisonmedmedmetrics <harrison@medmetricsrx.com>
Slack reaction_added events were explicitly acked and dropped, so a user reacting to a bot message (👍 to approve, ✅ to acknowledge) produced nothing. Forward them through the normal message pipeline as synthesized MessageEvents whose text is the reaction emoji (translated to unicode for common names), keeping the downstream auth gate, thread-context fetch, dedup, and skill routing unchanged. - Self-reactions and non-message items are dropped; reactions on messages not sent by this bot are dropped (Feishu-adapter parity). - The reacted-to message's thread parent becomes the synthesized thread_ts so the reaction lands in the same session as a reply would. - Manifest gains reactions:read scope + reaction_added bot event. Salvaged from PR NousResearch#29916 by @bpross. Related: NousResearch#33111, NousResearch#44508, NousResearch#45265 (same cluster).
… handoff Build the full reaction pipeline on top of the NousResearch#29916 base: - Opt-in gate: slack.reaction_triggers (default OFF — reaction events stay acked-and-dropped so busy channels don't wake the agent on every emoji). 'true' routes reactions on the bot's OWN messages; an explicit emoji-name list routes those emojis from any message (handoff flows). - reaction_removed events now route too, distinguished by the cross-platform text convention reaction:added:<emoji> / reaction:removed:<emoji> (matches the Feishu and Photon adapters, so agents and skills see one shape everywhere). - Authorization: the reactor becomes the synthesized message's user, so the early _is_user_authorized gate and allowed_channels whitelist apply exactly as for typed messages. _hermes_force_process only skips the mention requirement (a reaction on the bot's own message is definitionally addressed to the bot), mirroring Feishu/Photon. - Gateway hooks (NousResearch#33111 by @johnkattenhorn): every human reaction on a message item fires reaction:added / reaction:removed through the new BasePlatformAdapter.set_reaction_handler → GatewayRunner ._handle_reaction_event → HookRegistry.emit, independent of the routing opt-in. Documented in hooks.md. - Channel handoff (NousResearch#45265 by @Kev-fs): slack.reaction_trigger_target routes the reaction turn to a configured channel (top-level via _hermes_no_thread_response + reply-anchor suppression in gateway/platforms/base.py) or C123:<ts> thread. - Manifest: reaction_removed event subscription added alongside reaction_added/reactions:read. - Docs: slack.md Reaction Triggers section; hooks.md event table rows. Also credits NousResearch#44508 by @harrisonmedmedmetrics (inbound reaction_added handling — same plumbing class, superseded by this consolidated shape). Co-authored-by: johnkattenhorn <john.kattenhorn.personal@gmail.com> Co-authored-by: Kev-fs <kevin@fleetsmarts.net> Co-authored-by: harrisonmedmedmetrics <harrison@medmetricsrx.com>
- john.kattenhorn.personal@gmail.com -> johnkattenhorn (NousResearch#33111) - harrison@medmetricsrx.com -> harrisonmedmedmetrics (NousResearch#44508) - kevin@fleetsmarts.net -> Kev-fs (NousResearch#45265)
Summary
reaction_addedandreaction_removedevents and forwards them, with a resolved permalink for message targets, to a newset_reaction_handlercallback onBasePlatformAdapter.HookRegistry.emit, exposingreaction:added/reaction:removedevents to user hooks alongside the existingagent:*/command:*families.:task:reaction → webhook example.Why
There's currently no way to receive reaction events in a hook handler without locally patching the adapter. Concrete use case: emoji-driven task capture — react with
:task:on a Slack message and have the bot push it to Todoist / Linear / your own tracker.Event payload
platform"slack"reactiontask)user_iditem_user_iditem_typemessage/file/file_commentchannel_iditem_type == "message"message_tsitem_type == "message"permalinkchat.getPermalink, best-effortevent_tsraw_eventSlack app config required
reactions:readreaction_addedandreaction_removed/invite)Adapter coverage
Slack only here.
set_reaction_handleris a no-op for adapters that don't call it, so Discord and Telegram can opt in later as separate PRs without changing this surface.Tests
Not added in this PR — the change is additive, syntax-checked, and
tests/gateway/platforms/has no Slack file today. Happy to add unit tests for the dispatch path (_forward_reaction_event→ handler,_handle_reaction_event→hooks.emit) in a follow-up if it's a merge prerequisite.Local testing
Patched into a running Hermes v0.14.0 / Slack adapter container, reacted in a channel the bot is in, confirmed
reaction:addedfires with the documented payload + a working permalink. Removed reaction →reaction:removedfires symmetrically.Out of scope
Test plan
main(4 files, +153 lines)reactions:readscope + subscribe toreaction_added/reaction_removedin a Slack app config, then add a hook subscribing toreaction:addedand verify it fires on emoji reactions