mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
9c9b057b73
commit
558dab0e3e
8 changed files with 607 additions and 27 deletions
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue