From b6d63a0a28232ed9feac0bc2d39f891ed610563e Mon Sep 17 00:00:00 2001 From: Tanner Date: Tue, 31 Mar 2026 13:41:14 -0400 Subject: [PATCH] feat: spawn thread via emoji reaction trigger (#85) Add Phase 3 to the poll loop that watches for a configurable emoji reaction (default: "onit") on messages. When the authenticated user adds the trigger reaction, Claw starts a new thread with context and removes the reaction. Includes dedup via session lookup. Co-Authored-By: Claude Sonnet 4.6 --- claw/core/config.py | 9 ++ claw/core/slack_client.py | 19 +++ claw/core/slack_poller.py | 69 +++++++++- openspec/changes/reaction-trigger/spec.md | 66 ++++++++++ tests/test_slack_poller.py | 146 ++++++++++++++++++++++ 5 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/reaction-trigger/spec.md diff --git a/claw/core/config.py b/claw/core/config.py index f06d7ce..07d822e 100644 --- a/claw/core/config.py +++ b/claw/core/config.py @@ -24,6 +24,15 @@ class AppConfig(BaseSettings): ENABLE_PROGRESS_UPDATES: bool = True CLAW_REPLY_HEADER: bool = True CLAW_CLOSE_COMMANDS: str = "\\close,close,close thread,close session,bye,goodbye,done" + CLAW_TRIGGER_REACTION: str = "onit" + CLAW_REACTION_CHANNELS: str = "" + + @property + def reaction_channels(self) -> set[str]: + """Split comma-separated CLAW_REACTION_CHANNELS into a set.""" + if not self.CLAW_REACTION_CHANNELS: + return set() + return {c.strip() for c in self.CLAW_REACTION_CHANNELS.split(",") if c.strip()} @property def close_commands(self) -> set[str]: diff --git a/claw/core/slack_client.py b/claw/core/slack_client.py index a443f5b..9e08bf0 100644 --- a/claw/core/slack_client.py +++ b/claw/core/slack_client.py @@ -155,3 +155,22 @@ async def reply_to_thread( ) -> dict: """Post a threaded reply to an existing Slack message.""" return await post_message(ws, config, channel, text, thread_ts=thread_ts) + + +async def remove_reaction( + ws: Any, + config: Any, + channel: str, + name: str, + timestamp: str, +) -> dict: + """Remove an emoji reaction from a message.""" + data = _slack_request( + ws, config.SLACK_UC_CONNECTION, "POST", "/reactions.remove", + body={"channel": channel, "name": name, "timestamp": timestamp}, + ) + + if not data.get("ok"): + raise SlackApiError(data.get("error", "unknown_error")) + + return data diff --git a/claw/core/slack_poller.py b/claw/core/slack_poller.py index 963f778..1ecaab9 100644 --- a/claw/core/slack_poller.py +++ b/claw/core/slack_poller.py @@ -13,7 +13,7 @@ from claw.models import Session as SessionModel from claw.core.mcp_mapper import build_mcp_servers from claw.core.sessions import close_session, get_active_sessions, get_recent_messages, get_session, get_sessions_to_evict, is_message_handled, save_message, update_last_poll_ts, upsert_session -from claw.core.slack_client import fetch_history, fetch_thread_replies, open_self_dm, reply_to_thread, resolve_identity +from claw.core.slack_client import fetch_history, fetch_thread_replies, open_self_dm, remove_reaction, reply_to_thread, resolve_identity logger = logging.getLogger(__name__) @@ -194,7 +194,16 @@ async def _poll_once(self) -> None: with DbSession(self._db_engine) as db: update_last_poll_ts(db, session.thread_ts, max_reply_ts) - # --- Phase 3: Evict stale sessions --- + # --- Phase 3: Reaction trigger detection --- + if self._config.CLAW_TRIGGER_REACTION: + channels = self._config.reaction_channels or {self._channel_id} + for ch in channels: + try: + await self._check_reaction_triggers(ch) + except Exception: + logger.exception("Error checking reaction triggers for channel %s", ch) + + # --- Phase 4: Evict stale sessions --- with DbSession(self._db_engine) as db: to_evict = get_sessions_to_evict(db, days=self._config.ACTIVE_THREAD_WINDOW_DAYS) for session in to_evict: @@ -222,6 +231,62 @@ async def _poll_once(self) -> None: with DbSession(self._db_engine) as db: close_session(db, session.thread_ts) + # ------------------------------------------------------------------ + # Reaction trigger detection + # ------------------------------------------------------------------ + + async def _check_reaction_triggers(self, channel: str) -> None: + """Scan recent messages in *channel* for the trigger reaction.""" + trigger = self._config.CLAW_TRIGGER_REACTION + messages = await fetch_history( + self._ws, self._config, channel, self._last_seen_ts, + ) + + for msg in messages: + reactions = msg.get("reactions", []) + for reaction in reactions: + if reaction["name"] != trigger: + continue + if self._user_id not in reaction.get("users", []): + continue + + # Found our trigger reaction — check dedup + msg_ts = msg["ts"] + with DbSession(self._db_engine) as db: + existing = get_session(db, msg_ts) + if existing is not None: + continue + + # Create session + with DbSession(self._db_engine) as db: + upsert_session(db, msg_ts, channel) + + # Build synthetic message with context prefix + msg_text = msg.get("text", "") + context = ( + f"User <@{self._user_id}> reacted to start this thread.\n\n" + f"Original message: {msg_text}" + ) + synthetic = { + "text": context, + "ts": msg_ts, + "thread_ts": msg_ts, + "user": self._user_id, + } + await self._handle_message(synthetic) + + # Remove the trigger reaction + try: + await remove_reaction( + self._ws, self._config, channel, trigger, msg_ts, + ) + except Exception: + logger.exception( + "Failed to remove reaction %s from message %s", trigger, msg_ts, + ) + + break # Only process one trigger reaction per message + # ------------------------------------------------------------------ # Message handling # ------------------------------------------------------------------ diff --git a/openspec/changes/reaction-trigger/spec.md b/openspec/changes/reaction-trigger/spec.md new file mode 100644 index 0000000..7018390 --- /dev/null +++ b/openspec/changes/reaction-trigger/spec.md @@ -0,0 +1,66 @@ +# OpenSpec: Spawn Thread via Emoji Reaction Trigger + +## Summary +Add a Phase 3 to the poll loop that watches for a configurable emoji reaction (default: `onit`) +on messages. When the authenticated user adds the trigger reaction to any message in a watched +channel, Claw starts a new thread on that message, sends Claude an opening message with context, +and removes the reaction. + +## Motivation +Currently Claw only responds to explicit `<@user_id>` mentions. Reaction triggers let users +silently invoke Claw on any existing message by adding an emoji — a much lighter-weight UX +that works well on mobile and doesn't clutter the conversation with mentions. + +## Changes + +### 1. `claw/core/config.py` +- Add `CLAW_TRIGGER_REACTION: str = "onit"` — the emoji name to watch for. Empty string disables. +- Add `CLAW_REACTION_CHANNELS: str = ""` — comma-separated channel IDs to poll for reactions. + Empty string = use the self-DM channel (same as existing polling). +- Add `reaction_channels` property returning `set[str]` — splits on comma, strips whitespace, + filters empty strings. + +### 2. `claw/core/slack_client.py` +- Add `remove_reaction(ws, config, channel, name, timestamp)` async function. + Calls Slack `reactions.remove` via POST with body `{channel, name, timestamp}`. + +### 3. `claw/core/slack_poller.py` +- Add Phase 3 to `_poll_once`, BEFORE the existing Phase 3 (eviction), renumbering eviction + to Phase 4. +- New Phase 3: Reaction trigger detection + - Skip entirely if `self._config.CLAW_TRIGGER_REACTION` is empty. + - Determine channels: `self._config.reaction_channels` if non-empty, else `{self._channel_id}`. + - For each channel, call `_check_reaction_triggers(channel)`. +- New method `_check_reaction_triggers(channel: str)`: + - Fetch recent messages via `fetch_history(ws, config, channel, oldest)` with a reasonable + oldest timestamp (use `self._last_seen_ts` or similar). + - For each message, check `msg.get("reactions", [])`. + - Look for a reaction where `reaction["name"] == self._config.CLAW_TRIGGER_REACTION` + AND `self._user_id in reaction.get("users", [])`. + - If found and no existing session for that `msg["ts"]` (dedup via `get_session`): + 1. `upsert_session(db, msg["ts"], channel)` — creates the session. + 2. Build context prefix: `"User <@{self._user_id}> reacted to start this thread.\n\nOriginal message: {msg_text}"` + 3. Construct a synthetic message dict with the context prefix as text, `ts=msg["ts"]`, + `thread_ts=msg["ts"]` so `_handle_message` treats it as a thread root. + 4. Call `_handle_message(synthetic_msg)`. + 5. Call `remove_reaction(ws, config, channel, trigger_name, msg["ts"])` to clean up. + - If session already exists for that ts — skip (already triggered). + +### Slack API calls +- `conversations.history` (already used) — returns messages with `reactions` array included. +- `reactions.remove` (new) — POST with `{channel, name, timestamp}`. + +### Session dedup strategy +- `get_session(db, msg["ts"])` returning non-None means a session already exists for that + thread root — skip. This prevents re-triggering on subsequent polls. + +## Test Plan (`tests/test_slack_poller.py` — `TestReactionTrigger` class) + +1. `test_reaction_trigger_creates_session` — authenticated user's reaction creates session + and calls `_handle_message`. +2. `test_reaction_trigger_ignores_other_user` — reaction by a different user is ignored. +3. `test_reaction_trigger_dedup_existing_session` — if session already exists, skip. +4. `test_reaction_trigger_disabled_when_empty` — `CLAW_TRIGGER_REACTION=""` skips Phase 3. +5. `test_reaction_trigger_removes_reaction` — `remove_reaction` called after triggering. +6. `test_reaction_trigger_context_prefix` — opening message includes context prefix with + original message text. diff --git a/tests/test_slack_poller.py b/tests/test_slack_poller.py index c3203d8..4f42abc 100644 --- a/tests/test_slack_poller.py +++ b/tests/test_slack_poller.py @@ -2418,3 +2418,149 @@ async def test_close_command_also_blocked_by_running_task(self): # Guard fires before close-command check, so close_session is NOT called mock_close.assert_not_called() + + +# --------------------------------------------------------------------------- +# Phase 3: Reaction trigger detection +# --------------------------------------------------------------------------- + +class TestReactionTrigger: + """Tests for emoji-reaction-based thread spawning (Phase 3).""" + + def _make_poller(self): + ws = _make_ws({"ok": True, "messages": []}) + cfg = _make_config() + cfg.CLAW_TRIGGER_REACTION = "onit" + cfg.CLAW_REACTION_CHANNELS = "" + cfg.reaction_channels = set() + cfg.close_commands = {"\\close", "close", "bye", "done"} + cfg.ENABLE_PROGRESS_UPDATES = False + poller = SlackPoller(ws, cfg, _make_db_engine()) + poller._user_id = "U_BOT" + poller._channel_id = "C123" + poller._last_seen_ts = "99.0" + return poller + + def _reaction_msg(self, text="hello world", ts="300.001", reaction_name="onit", users=None): + """Build a message with a reactions array.""" + if users is None: + users = ["U_BOT"] + return { + "text": text, + "user": "U_OTHER", + "ts": ts, + "reactions": [{"name": reaction_name, "users": users}], + } + + @pytest.mark.asyncio + async def test_reaction_trigger_creates_session(self): + """Authenticated user's reaction creates a session and calls _handle_message.""" + poller = self._make_poller() + msg = self._reaction_msg() + + with patch("claw.core.slack_poller.fetch_history", new_callable=AsyncMock, return_value=[msg]), \ + patch("claw.core.slack_poller.get_active_sessions", return_value=[]), \ + patch("claw.core.slack_poller.get_sessions_to_evict", return_value=[]), \ + patch("claw.core.slack_poller.get_session", return_value=None), \ + patch("claw.core.slack_poller.upsert_session") as mock_upsert, \ + patch("claw.core.slack_poller.remove_reaction", new_callable=AsyncMock) as mock_remove, \ + patch.object(poller, "_handle_message", new_callable=AsyncMock) as mock_handle, \ + patch("claw.core.slack_poller.DbSession"): + await poller._poll_once() + + # _handle_message should have been called with the context prefix + mock_handle.assert_called() + call_msg = mock_handle.call_args[0][0] + assert "reacted to start this thread" in call_msg["text"] + assert "hello world" in call_msg["text"] + + @pytest.mark.asyncio + async def test_reaction_trigger_ignores_other_user(self): + """Reaction by a different user (not the bot) is ignored.""" + poller = self._make_poller() + msg = self._reaction_msg(users=["U_SOMEONE_ELSE"]) + + with patch("claw.core.slack_poller.fetch_history", new_callable=AsyncMock, return_value=[msg]), \ + patch("claw.core.slack_poller.get_active_sessions", return_value=[]), \ + patch("claw.core.slack_poller.get_sessions_to_evict", return_value=[]), \ + patch.object(poller, "_handle_message", new_callable=AsyncMock) as mock_handle, \ + patch("claw.core.slack_poller.DbSession"): + await poller._poll_once() + + mock_handle.assert_not_called() + + @pytest.mark.asyncio + async def test_reaction_trigger_dedup_existing_session(self): + """If a session already exists for that ts, skip (dedup).""" + poller = self._make_poller() + msg = self._reaction_msg() + existing_session = MagicMock() + existing_session.thread_ts = "300.001" + + with patch("claw.core.slack_poller.fetch_history", new_callable=AsyncMock, return_value=[msg]), \ + patch("claw.core.slack_poller.get_active_sessions", return_value=[]), \ + patch("claw.core.slack_poller.get_sessions_to_evict", return_value=[]), \ + patch("claw.core.slack_poller.get_session", return_value=existing_session), \ + patch.object(poller, "_handle_message", new_callable=AsyncMock) as mock_handle, \ + patch("claw.core.slack_poller.DbSession"): + await poller._poll_once() + + mock_handle.assert_not_called() + + @pytest.mark.asyncio + async def test_reaction_trigger_disabled_when_empty(self): + """CLAW_TRIGGER_REACTION="" disables reaction detection entirely.""" + poller = self._make_poller() + poller._config.CLAW_TRIGGER_REACTION = "" + msg = self._reaction_msg() + + with patch("claw.core.slack_poller.fetch_history", new_callable=AsyncMock, return_value=[msg]), \ + patch("claw.core.slack_poller.get_active_sessions", return_value=[]), \ + patch("claw.core.slack_poller.get_sessions_to_evict", return_value=[]), \ + patch.object(poller, "_handle_message", new_callable=AsyncMock) as mock_handle, \ + patch("claw.core.slack_poller.DbSession"): + await poller._poll_once() + + mock_handle.assert_not_called() + + @pytest.mark.asyncio + async def test_reaction_trigger_removes_reaction(self): + """remove_reaction is called after triggering to clean up the emoji.""" + poller = self._make_poller() + msg = self._reaction_msg() + + with patch("claw.core.slack_poller.fetch_history", new_callable=AsyncMock, return_value=[msg]), \ + patch("claw.core.slack_poller.get_active_sessions", return_value=[]), \ + patch("claw.core.slack_poller.get_sessions_to_evict", return_value=[]), \ + patch("claw.core.slack_poller.get_session", return_value=None), \ + patch("claw.core.slack_poller.upsert_session"), \ + patch("claw.core.slack_poller.remove_reaction", new_callable=AsyncMock) as mock_remove, \ + patch.object(poller, "_handle_message", new_callable=AsyncMock), \ + patch("claw.core.slack_poller.DbSession"): + await poller._poll_once() + + mock_remove.assert_called_once_with( + poller._ws, poller._config, "C123", "onit", "300.001", + ) + + @pytest.mark.asyncio + async def test_reaction_trigger_context_prefix(self): + """Opening message includes context prefix with original message text.""" + poller = self._make_poller() + msg = self._reaction_msg(text="Please review this PR") + + with patch("claw.core.slack_poller.fetch_history", new_callable=AsyncMock, return_value=[msg]), \ + patch("claw.core.slack_poller.get_active_sessions", return_value=[]), \ + patch("claw.core.slack_poller.get_sessions_to_evict", return_value=[]), \ + patch("claw.core.slack_poller.get_session", return_value=None), \ + patch("claw.core.slack_poller.upsert_session"), \ + patch("claw.core.slack_poller.remove_reaction", new_callable=AsyncMock), \ + patch.object(poller, "_handle_message", new_callable=AsyncMock) as mock_handle, \ + patch("claw.core.slack_poller.DbSession"): + await poller._poll_once() + + call_msg = mock_handle.call_args[0][0] + assert f"User <@U_BOT> reacted to start this thread" in call_msg["text"] + assert "Please review this PR" in call_msg["text"] + assert call_msg["ts"] == "300.001" + assert call_msg["thread_ts"] == "300.001"