Skip to content
Merged
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
9 changes: 9 additions & 0 deletions claw/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
19 changes: 19 additions & 0 deletions claw/core/slack_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
69 changes: 67 additions & 2 deletions claw/core/slack_poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
66 changes: 66 additions & 0 deletions openspec/changes/reaction-trigger/spec.md
Original file line number Diff line number Diff line change
@@ -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.
146 changes: 146 additions & 0 deletions tests/test_slack_poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading