feat(slack): opt-in reaction triggers, removed events, hooks, channel 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>
This commit is contained in:
Teknium 2026-07-23 08:26:28 -07:00
parent 9c9b057b73
commit 558dab0e3e
8 changed files with 607 additions and 27 deletions

View file

@ -105,6 +105,18 @@ def _reply_anchor_for_event(event) -> str | None:
source = getattr(event, "source", None)
platform = _platform_name(getattr(source, "platform", None))
thread_id = getattr(source, "thread_id", None)
raw_message = getattr(event, "raw_message", None)
if (
platform == "slack"
and isinstance(raw_message, dict)
and raw_message.get("_hermes_no_thread_response")
):
# Slack reaction handoffs into a configured target channel are meant
# to create a new top-level message there. Returning the synthetic
# event's message_id as reply_to would make
# SlackAdapter._resolve_thread_ts() treat it as a thread anchor and
# reply in a (nonexistent) thread anyway.
return None
if platform == "telegram" and thread_id and getattr(source, "chat_type", None) == "dm":
# Reply to the triggering user message. Replying to Telegram's earlier
# topic seed/anchor can render the bot response outside the active lane.
@ -2443,6 +2455,11 @@ class BasePlatformAdapter(ABC):
self.config = config
self.platform = platform
self._message_handler: Optional[MessageHandler] = None
# Optional gateway-supplied fan-out for platform-native emoji
# reaction events (see ``set_reaction_handler``).
self._reaction_handler: Optional[
Callable[[Dict[str, Any]], Awaitable[None]]
] = None
# Optional hook (e.g. Telegram DM topic recovery) that rewrites
# ``event.source.thread_id`` before session keying. Returns the
# corrected thread_id or None to leave the source untouched.
@ -2988,6 +3005,25 @@ class BasePlatformAdapter(ABC):
"""Set an optional handler for messages arriving during active sessions."""
self._busy_session_handler = handler
def set_reaction_handler(
self, handler: Optional[Callable[[Dict[str, Any]], Awaitable[None]]]
) -> None:
"""Set the handler for emoji-reaction events on platform messages.
Called by adapters that subscribe to platform-native reaction events
(currently the Slack adapter's ``reaction_added``/``reaction_removed``).
The handler receives a normalised event dict ``platform``,
``event_name`` ("reaction:added"/"reaction:removed"), ``reaction``,
``user_id``, ``item_user_id``, ``channel_id``, ``message_ts``,
``event_ts``, ``raw_event`` and fans out via
``HookRegistry.emit(event_name, ...)``.
Adapters without reaction support simply never call the handler.
"""
# Assign defensively: subclasses initialized via ``object.__new__``
# in tests never run ``BasePlatformAdapter.__init__``.
self._reaction_handler = handler # type: ignore[attr-defined]
def set_authorization_check(
self,
callback: Optional[Callable[[str, Optional[str], Optional[str]], bool]],

View file

@ -4438,6 +4438,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception:
logger.debug("Failed to sync gateway session model metadata", exc_info=True)
async def _handle_reaction_event(self, ctx: Dict[str, Any]) -> None:
"""Fan a normalised platform reaction event out to the HookRegistry.
Adapters call this via ``set_reaction_handler`` for every
platform-native reaction event they surface. The adapter-supplied
``event_name`` ("reaction:added" / "reaction:removed") becomes the
hook event so user hooks subscribe with the same name scheme as the
existing ``agent:*`` family. Errors never block the adapter's event
loop the hook contract is non-blocking.
"""
event_name = str(ctx.get("event_name") or "reaction:added")
try:
await self.hooks.emit(event_name, ctx)
except Exception:
logger.debug("[Gateway] reaction hook emit failed", exc_info=True)
async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None:
"""React to an adapter failure after startup.
@ -7892,6 +7908,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)
adapter.set_authorization_check(self._make_adapter_auth_check(adapter.platform))
adapter._busy_text_mode = self._busy_text_mode
@ -8871,6 +8888,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)
adapter.set_authorization_check(self._make_adapter_auth_check(adapter.platform))
adapter._busy_text_mode = self._busy_text_mode
@ -9745,6 +9763,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
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.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)
adapter.set_authorization_check(
self._make_adapter_auth_check(platform, profile_name=profile_name)

View file

@ -97,6 +97,7 @@ def _build_full_manifest(
"message.im",
"message.mpim",
"reaction_added",
"reaction_removed",
]
if messaging_experience == "assistant":

View file

@ -1785,7 +1785,7 @@ class SlackAdapter(BasePlatformAdapter):
@self._app.event("reaction_removed")
async def handle_reaction_removed(event, say):
pass
await self._handle_slack_reaction(event, removed=True)
@self._app.event("assistant_thread_started")
async def handle_assistant_thread_started(event, say, body):
@ -4305,8 +4305,8 @@ class SlackAdapter(BasePlatformAdapter):
"wave": "👋",
}
async def _handle_slack_reaction(self, event: dict) -> None:
"""Forward ``reaction_added`` events through the normal message pipeline.
async def _handle_slack_reaction(self, event: dict, removed: bool = False) -> None:
"""Forward reaction events through the normal message pipeline.
The reactor's user_id becomes the synthesized message's user, so the
downstream auth gate (``_is_user_authorized``) applies as it does for
@ -4316,17 +4316,29 @@ class SlackAdapter(BasePlatformAdapter):
confirmation-style proposals (``react 👍 to proceed``) treat
reactions as real responses.
Common reactions are translated to unicode emoji in the text field
(👍, 👎, , etc.) so skill bodies that match on the typed character
also fire on the equivalent reaction without needing to know about
Slack-specific names.
The synthesized text follows the cross-platform convention already
used by the Feishu and Photon adapters ``reaction:added:<emoji>`` /
``reaction:removed:<emoji>`` with common Slack reaction names
translated to unicode emoji (👍, 👎, , ) so agents and skills see
the same shape on every platform. Because the synthesized event is
threaded under the reacted-to message, the existing reply-context
plumbing injects the target message's text as ``reply_to_text`` and
the agent sees WHAT was reacted to.
Message-pipeline routing is OPT-IN via ``slack.reaction_triggers``
(default off) so busy channels don't wake the agent on every emoji.
Gateway hooks (``reaction:added`` / ``reaction:removed``) fire for
every non-self reaction on a message item regardless of the opt-in,
so hook consumers can observe reactions without enabling agent
routing.
Self-reactions (the bot reacting to its own messages, e.g. the
:eyes: lifecycle reaction) are dropped here to prevent feedback
loops. file-targeted reactions are ignored only ``item.type ==
"message"`` is forwarded. Reactions on messages not sent by this
bot are dropped so a reaction on an unrelated human message can't
enter the agent loop.
"message"`` is forwarded. Unless an explicit emoji allowlist is
configured, reactions on messages not sent by this bot are dropped
so a reaction on an unrelated human message can't enter the agent
loop.
"""
item = event.get("item") or {}
if item.get("type") != "message":
@ -4345,6 +4357,43 @@ class SlackAdapter(BasePlatformAdapter):
team_id = next(iter(self._team_clients))
client = self._team_clients.get(team_id) if team_id else None
action = "removed" if removed else "added"
# Fire the gateway hook surface first (reaction:added/removed) so
# hook consumers observe every human reaction even when agent
# routing below is disabled. getattr-guard: tests build adapters
# via object.__new__ without running __init__.
reaction_handler = getattr(self, "_reaction_handler", None)
if reaction_handler is not None:
try:
await reaction_handler(
{
"platform": "slack",
"event_name": f"reaction:{action}",
"reaction": reaction_name,
"user_id": user_id,
"item_user_id": event.get("item_user"),
"item_type": item.get("type"),
"channel_id": channel_id,
"message_ts": msg_ts,
"team_id": team_id,
"event_ts": event.get("event_ts"),
"raw_event": event,
}
)
except Exception: # pragma: no cover - hook contract is non-blocking
logger.debug("[Slack] reaction hook forwarding failed", exc_info=True)
# Opt-in gate for message-pipeline routing. None → disabled (ack
# only, the pre-existing behavior); empty set → all emoji route;
# non-empty set → only the allowlisted emoji names route.
triggers = self._slack_reaction_triggers()
if triggers is None:
return
explicit_allowlist = bool(triggers)
if explicit_allowlist and reaction_name.strip(":") not in triggers:
return
# Look up the reacted-to message so we can route the synthesized
# event into the right thread and verify the target belongs to this
# bot (matching the Feishu adapter's target-sender check). If the
@ -4366,18 +4415,34 @@ class SlackAdapter(BasePlatformAdapter):
# (matching the Feishu adapter's target-sender check).
# ``item_user`` on the event is the author of the
# reacted-to message; if absent, fall back to the
# fetched message's ``user`` field.
# fetched message's ``user`` field. Operators that
# configure an explicit emoji allowlist deliberately
# chose trigger emojis, so those may target any
# message (emoji-handoff workflows).
item_user = event.get("item_user") or first.get("user") or ""
bot_uid = self._team_bot_user_ids.get(team_id) or self._bot_user_id
if item_user and bot_uid and item_user != bot_uid:
if (
not explicit_allowlist
and item_user
and bot_uid
and item_user != bot_uid
):
return
except Exception as e: # pragma: no cover - network path
logger.debug(
"[Slack] reaction thread_ts lookup failed for %s: %s",
msg_ts, e,
)
elif not explicit_allowlist:
# No client to verify the target message's sender — without an
# explicit allowlist we cannot prove the reaction targets our
# own message, so verify via the event's item_user alone.
item_user = event.get("item_user") or ""
bot_uid = self._team_bot_user_ids.get(team_id) or self._bot_user_id
if item_user and bot_uid and item_user != bot_uid:
return
emoji_text = self._REACTION_EMOJI_MAP.get(reaction_name, f":{reaction_name}:")
emoji_text = self._REACTION_EMOJI_MAP.get(reaction_name, reaction_name)
# Use the reaction's own event_ts as the synthesized message ts so
# the deduplicator in _handle_slack_message treats this reaction
@ -4387,15 +4452,21 @@ class SlackAdapter(BasePlatformAdapter):
synthetic: dict = {
"type": "message",
"user": user_id,
"text": emoji_text,
"text": f"reaction:{action}:{emoji_text}",
"channel": channel_id,
"ts": synthetic_ts,
"thread_ts": thread_ts,
# A reaction on the bot's own message (or an operator-allowlisted
# trigger emoji) is definitionally addressed to the bot — skip
# the mention requirement the way Feishu/Photon reaction routing
# does. User authorization and allowed_channels still apply.
"_hermes_force_process": True,
# Surfaced for any downstream code that wants to know this was a
# reaction rather than a typed message; not used by the default
# pipeline.
"_hermes_reaction": {
"name": reaction_name,
"action": action,
"reacted_to_ts": msg_ts,
"event_ts": event.get("event_ts"),
},
@ -4403,8 +4474,75 @@ class SlackAdapter(BasePlatformAdapter):
if team_id:
synthetic["team"] = team_id
# Optional handoff target (#45265): route the reaction-triggered
# turn into a configured channel (and optionally thread) instead of
# the source thread. A channel-only target is a handoff, not a
# reply — respond top-level there.
target_channel, target_thread = self._slack_reaction_trigger_target()
if target_channel:
synthetic["channel"] = target_channel
synthetic["channel_type"] = (
"im" if target_channel.startswith("D") else "channel"
)
synthetic["_hermes_reaction_source_channel"] = channel_id
if target_thread:
synthetic["thread_ts"] = target_thread
else:
synthetic.pop("thread_ts", None)
synthetic["_hermes_no_thread_response"] = True
await self._handle_slack_message(synthetic)
def _slack_reaction_triggers(self) -> Optional[set]:
"""Return the reaction-routing opt-in state.
``None`` disabled (default): reaction events are acked and
dropped, preserving the historical behavior.
empty set enabled for all emoji (``reaction_triggers: true``),
limited to reactions on the bot's own messages.
non-empty set enabled for exactly these emoji names, on any
message (operator-curated handoff emojis).
Sources: ``slack.reaction_triggers`` in config.yaml (bool or list),
or the ``SLACK_REACTION_TRIGGERS`` env var (``true``/``all`` or a
comma-separated emoji-name list).
"""
raw = self.config.extra.get("reaction_triggers")
if raw is None:
raw = os.getenv("SLACK_REACTION_TRIGGERS") or None
if raw is None:
return None
if isinstance(raw, bool):
return set() if raw else None
if isinstance(raw, (list, tuple, set)):
names = {str(p).strip().strip(":") for p in raw if str(p).strip().strip(":")}
return names or set()
text = str(raw or "").strip()
if not text or text.lower() in {"false", "0", "no", "off"}:
return None
if text.lower() in {"true", "1", "yes", "on", "all", "*"}:
return set()
return {p.strip().strip(":") for p in re.split(r"[,\s]+", text) if p.strip().strip(":")}
def _slack_reaction_trigger_target(self) -> Tuple[str, str]:
"""Return the optional (channel, thread) handoff target for reactions.
``slack.reaction_trigger_target`` accepts ``C123`` (respond
top-level in that channel) or ``C123:1710000000.000100`` (respond
in that thread). Empty by default reactions route into the
thread of the reacted-to message.
"""
raw = self.config.extra.get("reaction_trigger_target")
if raw is None:
raw = os.getenv("SLACK_REACTION_TRIGGER_TARGET", "")
target = str(raw or "").strip()
if not target:
return "", ""
if ":" in target:
channel, thread = target.split(":", 1)
return channel.strip(), thread.strip()
return target, ""
async def _handle_slack_file_shared(
self, event: dict, body: Optional[dict] = None
) -> None:
@ -4915,6 +5053,12 @@ class SlackAdapter(BasePlatformAdapter):
thread_ts = event.get("thread_ts") or assistant_meta.get("thread_ts")
if not thread_ts and self._dm_top_level_threads_as_sessions():
thread_ts = ts
elif event.get("_hermes_no_thread_response"):
# Reaction handoff into a configured target channel (#45265):
# the response should be a new top-level message in the target
# channel, never a thread under the synthetic ts (which is the
# reaction's event_ts — not a real message there).
thread_ts = event.get("thread_ts") or None
else:
# Channel message session scoping.
#
@ -4970,6 +5114,10 @@ class SlackAdapter(BasePlatformAdapter):
)
event_thread_ts = event.get("thread_ts")
is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)
# Internal routing paths (reaction triggers) are pre-authorized as
# "addressed to the bot" — they skip the mention requirement but NOT
# the allowed_channels whitelist or user authorization above.
force_process = bool(event.get("_hermes_force_process"))
# Some Slack bot posts arrive as ordinary-looking message events with a
# bot *user* id but without ``bot_id``/``subtype=bot_message``. This is
@ -5022,7 +5170,9 @@ class SlackAdapter(BasePlatformAdapter):
)
return
if (
if force_process:
pass # Explicit internal routing path (reaction trigger).
elif (
channel_id not in self._slack_require_mention_channels()
and (
channel_id in self._slack_free_response_channels()
@ -8253,6 +8403,14 @@ def _apply_yaml_config(yaml_cfg: dict, slack_cfg: dict) -> dict | None:
os.environ["SLACK_REQUIRE_MENTION_CHANNELS"] = str(rmc)
if "reactions" in slack_cfg and not os.getenv("SLACK_REACTIONS"):
os.environ["SLACK_REACTIONS"] = str(slack_cfg["reactions"]).lower()
rt = slack_cfg.get("reaction_triggers")
if rt is not None and not os.getenv("SLACK_REACTION_TRIGGERS"):
if isinstance(rt, (list, tuple, set)):
rt = ",".join(str(v) for v in rt)
os.environ["SLACK_REACTION_TRIGGERS"] = str(rt)
rtt = slack_cfg.get("reaction_trigger_target")
if rtt is not None and not os.getenv("SLACK_REACTION_TRIGGER_TARGET"):
os.environ["SLACK_REACTION_TRIGGER_TARGET"] = str(rtt)
ac = slack_cfg.get("allowed_channels")
if ac is not None and not os.getenv("SLACK_ALLOWED_CHANNELS"):
if isinstance(ac, list):

View file

@ -1160,15 +1160,44 @@ class TestThreadEngagement:
# ===========================================================================
class TestSlackReactionForwarding:
"""Reactions should flow through the same pipeline as typed messages."""
"""Reactions should flow through the same pipeline as typed messages,
gated by the ``reaction_triggers`` opt-in (default: off)."""
@staticmethod
def _enable_triggers(adapter, value=True):
adapter.config.extra["reaction_triggers"] = value
@pytest.mark.asyncio
async def test_default_off_reaction_acked_and_dropped(self):
"""Without the reaction_triggers opt-in, reaction events are acked
and dropped the historical behavior so busy channels don't wake
the agent on every emoji."""
adapter = _make_adapter()
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "2000.0"},
"item_user": "U_BOT",
"event_ts": "3000.0",
})
assert forwarded == []
@pytest.mark.asyncio
async def test_reaction_synthesizes_message_in_thread(self):
"""A 👍 reaction on a message in a thread should produce a synthesized
MessageEvent that lands in that thread with text ``👍``, going through
_handle_slack_message so the auth gate, thread-context fetch, and
skill routing all apply unchanged."""
MessageEvent that lands in that thread with text ``reaction:added:👍``,
going through _handle_slack_message so the auth gate, thread-context
fetch, and skill routing all apply unchanged."""
adapter = _make_adapter()
self._enable_triggers(adapter)
mock_client = adapter._team_clients["T1"]
# Reacted-to message is itself a reply inside a thread; its thread_ts
# points at the thread parent.
@ -1196,7 +1225,7 @@ class TestSlackReactionForwarding:
synth = forwarded[0]
assert synth["type"] == "message"
assert synth["user"] == "U1"
assert synth["text"] == "👍"
assert synth["text"] == "reaction:added:👍"
assert synth["channel"] == "C1"
# Threaded back to the parent of the reacted-to message, not to the
# reacted-to message itself.
@ -1205,13 +1234,49 @@ class TestSlackReactionForwarding:
assert synth["ts"] == "3000.0"
# Reaction metadata preserved for downstream introspection.
assert synth["_hermes_reaction"]["name"] == "thumbsup"
assert synth["_hermes_reaction"]["action"] == "added"
assert synth["_hermes_reaction"]["reacted_to_ts"] == "2000.0"
# Pre-authorized as addressed-to-the-bot (skips mention gate only).
assert synth["_hermes_force_process"] is True
@pytest.mark.asyncio
async def test_reaction_removed_synthesizes_removed_text(self):
"""reaction_removed events route with the reaction:removed: prefix so
the agent can distinguish an un-react from a react."""
adapter = _make_adapter()
self._enable_triggers(adapter)
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_BOT", "text": "Parent"}]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction(
{
"type": "reaction_removed",
"user": "U1",
"reaction": "white_check_mark",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3000.0",
},
removed=True,
)
assert len(forwarded) == 1
assert forwarded[0]["text"] == "reaction:removed:✅"
assert forwarded[0]["_hermes_reaction"]["action"] == "removed"
@pytest.mark.asyncio
async def test_self_reaction_dropped(self):
"""The bot's own reactions (e.g. the :eyes: lifecycle marker on
incoming messages) must not feed back into the pipeline."""
adapter = _make_adapter()
self._enable_triggers(adapter)
forwarded: list[dict] = []
async def _capture(event):
@ -1230,10 +1295,11 @@ class TestSlackReactionForwarding:
assert forwarded == []
@pytest.mark.asyncio
async def test_unknown_reaction_uses_colon_name(self):
"""Reactions outside the unicode emoji map still forward, with text
set to the Slack short name in colons. Skills can match on those."""
async def test_unknown_reaction_passes_name_through(self):
"""Reactions outside the unicode emoji map still forward, with the
Slack short name in the text. Skills can match on those."""
adapter = _make_adapter()
self._enable_triggers(adapter)
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_BOT", "text": "Parent"}]
@ -1254,13 +1320,14 @@ class TestSlackReactionForwarding:
})
assert len(forwarded) == 1
assert forwarded[0]["text"] == ":moov-rocket:"
assert forwarded[0]["text"] == "reaction:added:moov-rocket"
@pytest.mark.asyncio
async def test_non_message_reaction_ignored(self):
"""File reactions and other non-message item types are dropped — we
only forward message reactions."""
adapter = _make_adapter()
self._enable_triggers(adapter)
forwarded: list[dict] = []
async def _capture(event):
@ -1283,6 +1350,7 @@ class TestSlackReactionForwarding:
thread_ts of its own), the synthesized event uses the message ts
as thread_ts."""
adapter = _make_adapter()
self._enable_triggers(adapter)
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_BOT", "text": "Parent"}]
@ -1303,7 +1371,7 @@ class TestSlackReactionForwarding:
})
assert len(forwarded) == 1
assert forwarded[0]["text"] == "👍"
assert forwarded[0]["text"] == "reaction:added:👍"
assert forwarded[0]["thread_ts"] == "1000.0"
@pytest.mark.asyncio
@ -1311,6 +1379,7 @@ class TestSlackReactionForwarding:
"""A reaction on a message not sent by this bot must not enter the
agent loop matching the Feishu adapter's target-sender check."""
adapter = _make_adapter()
self._enable_triggers(adapter)
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_OTHER", "text": "Not our bot"}]
@ -1332,3 +1401,252 @@ class TestSlackReactionForwarding:
assert forwarded == []
@pytest.mark.asyncio
async def test_allowlisted_emoji_routes_from_any_message(self):
"""An explicit emoji allowlist deliberately targets any message
(emoji-handoff workflows), and non-listed emoji stay dropped."""
adapter = _make_adapter()
self._enable_triggers(adapter, ["task"])
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_OTHER", "text": "Human message"}]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
# Listed emoji on a HUMAN message → routes.
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "task",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_OTHER",
"event_ts": "3000.0",
})
# Unlisted emoji → dropped even on the bot's own message.
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3001.0",
})
assert len(forwarded) == 1
assert forwarded[0]["text"] == "reaction:added:task"
@pytest.mark.asyncio
async def test_target_channel_handoff_routes_top_level(self):
"""reaction_trigger_target=C_TRAIN routes the synthesized turn to the
target channel as a new top-level message (#45265)."""
adapter = _make_adapter()
self._enable_triggers(adapter, ["task"])
adapter.config.extra["reaction_trigger_target"] = "C_TRAIN"
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_OTHER", "text": "Handoff me"}]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "task",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_OTHER",
"event_ts": "3000.0",
})
assert len(forwarded) == 1
synth = forwarded[0]
assert synth["channel"] == "C_TRAIN"
assert "thread_ts" not in synth
assert synth["_hermes_no_thread_response"] is True
assert synth["_hermes_reaction_source_channel"] == "C1"
@pytest.mark.asyncio
async def test_target_channel_with_thread_routes_to_thread(self):
"""A C123:<ts> target routes into that thread of the target channel."""
adapter = _make_adapter()
self._enable_triggers(adapter, ["task"])
adapter.config.extra["reaction_trigger_target"] = "C_TRAIN:1719.0001"
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_OTHER", "text": "Handoff me"}]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "task",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_OTHER",
"event_ts": "3000.0",
})
assert len(forwarded) == 1
assert forwarded[0]["channel"] == "C_TRAIN"
assert forwarded[0]["thread_ts"] == "1719.0001"
assert "_hermes_no_thread_response" not in forwarded[0]
@pytest.mark.asyncio
async def test_hook_fires_even_when_routing_disabled(self):
"""The gateway reaction handler fires for every human reaction on a
message item, independent of the reaction_triggers opt-in."""
adapter = _make_adapter() # opt-in NOT set
hook_events: list[dict] = []
async def _hook(ctx):
hook_events.append(ctx)
adapter.set_reaction_handler(_hook)
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3000.0",
})
await adapter._handle_slack_reaction(
{
"type": "reaction_removed",
"user": "U1",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3001.0",
},
removed=True,
)
assert forwarded == [] # routing stays off
assert [e["event_name"] for e in hook_events] == [
"reaction:added",
"reaction:removed",
]
assert hook_events[0]["platform"] == "slack"
assert hook_events[0]["reaction"] == "thumbsup"
assert hook_events[0]["user_id"] == "U1"
assert hook_events[0]["channel_id"] == "C1"
assert hook_events[0]["message_ts"] == "1000.0"
@pytest.mark.asyncio
async def test_hook_not_fired_for_self_reactions(self):
"""The bot's own lifecycle reactions never reach the hook surface."""
adapter = _make_adapter()
hook_events: list[dict] = []
async def _hook(ctx):
hook_events.append(ctx)
adapter.set_reaction_handler(_hook)
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U_BOT",
"reaction": "eyes",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"event_ts": "3000.0",
})
assert hook_events == []
def test_trigger_config_parsing(self):
"""reaction_triggers accepts bool / list / string forms."""
adapter = _make_adapter()
assert adapter._slack_reaction_triggers() is None # default off
adapter.config.extra["reaction_triggers"] = False
assert adapter._slack_reaction_triggers() is None
adapter.config.extra["reaction_triggers"] = True
assert adapter._slack_reaction_triggers() == set()
adapter.config.extra["reaction_triggers"] = ["thumbsup", ":task:"]
assert adapter._slack_reaction_triggers() == {"thumbsup", "task"}
adapter.config.extra["reaction_triggers"] = "thumbsup, task"
assert adapter._slack_reaction_triggers() == {"thumbsup", "task"}
adapter.config.extra["reaction_triggers"] = "all"
assert adapter._slack_reaction_triggers() == set()
adapter.config.extra["reaction_triggers"] = "false"
assert adapter._slack_reaction_triggers() is None
def test_trigger_env_fallback(self, monkeypatch):
"""SLACK_REACTION_TRIGGERS env enables routing when config is unset."""
adapter = _make_adapter()
monkeypatch.setenv("SLACK_REACTION_TRIGGERS", "true")
assert adapter._slack_reaction_triggers() == set()
monkeypatch.setenv("SLACK_REACTION_TRIGGERS", "task,karen")
assert adapter._slack_reaction_triggers() == {"task", "karen"}
def test_target_config_parsing(self):
adapter = _make_adapter()
assert adapter._slack_reaction_trigger_target() == ("", "")
adapter.config.extra["reaction_trigger_target"] = "C123"
assert adapter._slack_reaction_trigger_target() == ("C123", "")
adapter.config.extra["reaction_trigger_target"] = "C123:1710.0001"
assert adapter._slack_reaction_trigger_target() == ("C123", "1710.0001")
class TestSlackReactionAuthorizationGate:
"""The synthesized reaction event must pass through the same early
authorization rejection as typed messages an unauthorized reactor
cannot wake the agent."""
@pytest.mark.asyncio
async def test_unauthorized_reactor_rejected_in_pipeline(self):
adapter = _make_adapter()
adapter.config.extra["reaction_triggers"] = True
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_BOT", "text": "Proposal"}]
})
class _Runner:
def __init__(self):
self.handled = []
self.auth_checked = []
async def handle(self, event):
self.handled.append(event)
return None
def _is_user_authorized(self, source):
self.auth_checked.append(source.user_id)
return False # reject everyone
runner = _Runner()
adapter.set_message_handler(runner.handle)
adapter.handle_message = AsyncMock()
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U_RANDO",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3000.0",
})
# The auth gate saw the reactor's user id and rejected it before
# any MessageEvent reached the gateway.
assert "U_RANDO" in runner.auth_checked
assert runner.handled == []
adapter.handle_message.assert_not_called()

View file

@ -150,14 +150,16 @@ class TestSlackFullManifest:
assert event in bot_events
def test_reaction_scope_and_event_included(self):
"""reaction_added event + reactions:read scope must be in the manifest
so the adapter can forward reactions into the message pipeline."""
"""reaction_added/removed events + reactions:read scope must be in the
manifest so the adapter can forward reactions into the message
pipeline and gateway hooks."""
manifest = _build_full_manifest("Hermes", "Your Hermes agent on Slack")
bot_scopes = manifest["oauth_config"]["scopes"]["bot"]
bot_events = manifest["settings"]["event_subscriptions"]["bot_events"]
assert "reactions:read" in bot_scopes
assert "reaction_added" in bot_events
assert "reaction_removed" in bot_events
def test_reaction_scope_survives_no_assistant(self):
manifest = _build_full_manifest(

View file

@ -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` | An emoji reaction was added to a message the bot can see (Slack adapter currently). Requires the `reactions:read` scope + the `reaction_added` bot event subscription; the bot must be a member of the channel. | `platform`, `reaction`, `user_id`, `item_user_id`, `item_type`, `channel_id`, `message_ts`, `team_id`, `event_ts`, `raw_event` |
| `reaction:removed` | An emoji reaction was removed from a message the bot can see. Requires the `reaction_removed` bot event subscription. | same shape as `reaction:added` |
| `command:*` | Any slash command executed | `platform`, `user_id`, `command`, `args` |
#### Wildcard Matching

View file

@ -625,6 +625,50 @@ How `mentions` mode gates:
For strict multi-bot deployments, pair with `require_mention: true` and `strict_mention: true` — see the smoke-check profile below.
### Reaction Triggers (`reaction_triggers`)
By default, emoji reactions are acknowledged and dropped — a 👍 on a bot
message does nothing. Set `slack.reaction_triggers` to route reactions into
the agent loop (requires the `reactions:read` scope plus the
`reaction_added`/`reaction_removed` bot event subscriptions in your Slack app
manifest — regenerate with `hermes slack manifest`):
```yaml
slack:
# Opt-in. false/absent (default) = reactions are acked and dropped.
# true = any reaction ON THE BOT'S OWN MESSAGES routes to the agent.
reaction_triggers: true
# Or an explicit emoji allowlist — only these names route, and they may
# target ANY message (emoji-handoff workflows, e.g. :task: to capture):
# reaction_triggers: [white_check_mark, thumbsup, task]
# Optional handoff target: respond in this channel (top-level) or thread
# (C123:<thread_ts>) instead of the reacted-to message's thread.
# reaction_trigger_target: C0123456789
```
Environment equivalents: `SLACK_REACTION_TRIGGERS` (`true`/`all` or a
comma-separated list) and `SLACK_REACTION_TRIGGER_TARGET`.
Behavior:
- The reaction arrives as a normal agent turn with text
`reaction:added:👍` / `reaction:removed:👍` (common Slack names are
translated to unicode; unknown names pass through as-is, e.g.
`reaction:added:custom-emoji`), threaded under
the reacted-to message so the agent sees what was reacted to and the
turn lands in the same session as a reply would.
- The reactor becomes the message's user, so **user authorization and
`allowed_channels` gating apply exactly as for typed messages** — a
random user's reaction cannot trigger the agent anywhere their message
couldn't.
- With `reaction_triggers: true`, only reactions on the bot's **own**
messages route (approve/acknowledge flows). With an explicit emoji
allowlist, the listed emojis route from any message.
- The bot's own lifecycle reactions (`:eyes:` etc.) never feed back.
- Independent of this opt-in, every human reaction fires the
`reaction:added`/`reaction:removed` [gateway hooks](../features/hooks.md#available-events)
for observers that don't need agent turns.
### Peer-Agent Smoke Check
For multi-bot Slack deployments that rely on strict per-turn mentions, keep the following profile: