diff --git a/README.md b/README.md index 7959cad4..5bf3bbc5 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ See [`mcps/README.md`](mcps/README.md) and [`automations/README.md`](automations ## Extensions Catalog -This repository contains **2 marketplace(s)** with **48 extensions** (38 skills, 10 plugins). +This repository contains **2 marketplace(s)** with **49 extensions** (39 skills, 10 plugins). ### large-codebase @@ -69,7 +69,7 @@ OpenHands skills for interacting, improving, and refactoring large codebases Official skills and plugins for OpenHands — the open-source AI software engineer. -**44 extensions** (36 skills, 8 plugins) +**45 extensions** (37 skills, 8 plugins) | Name | Type | Description | Commands | |------|------|-------------|----------| @@ -111,6 +111,7 @@ Official skills and plugins for OpenHands — the open-source AI software engine | release-notes | plugin | Generate consistent, well-structured release notes from git history. Produces categorized changelog with breaking cha... | `/release-notes` | | security | skill | Security best practices for secure coding, authentication, authorization, and data protection. Use when developing fe... | — | | skill-creator | skill | Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an ex... | — | +| slack-channel-monitor | skill | Create a cron automation that polls up to 10 Slack channels every minute and starts an OpenHands conversation when a ... | — | | ssh | skill | Establish and manage SSH connections to remote machines, including key generation, configuration, and file transfers.... | — | | swift-linux | skill | Install and configure Swift programming language on Debian Linux for server-side development. Use when building Swift... | — | | theme-factory | skill | Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc.... | — | diff --git a/marketplaces/openhands-extensions.json b/marketplaces/openhands-extensions.json index 6bc1e6a3..92a4b844 100644 --- a/marketplaces/openhands-extensions.json +++ b/marketplaces/openhands-extensions.json @@ -473,6 +473,21 @@ "create" ] }, + { + "name": "slack-channel-monitor", + "source": "./skills/slack-channel-monitor", + "description": "Create a cron automation that polls up to 10 Slack channels every minute and starts an OpenHands conversation when a configurable trigger phrase is detected. Forwards thread replies to running conversations and posts summaries back to Slack when the agent finishes.", + "category": "productivity", + "keywords": [ + "slack", + "monitor", + "channel", + "trigger", + "cron", + "automation", + "integration" + ] + }, { "name": "ssh", "source": "./skills/ssh", diff --git a/skills/slack-channel-monitor/.claude-plugin b/skills/slack-channel-monitor/.claude-plugin new file mode 120000 index 00000000..665797f0 --- /dev/null +++ b/skills/slack-channel-monitor/.claude-plugin @@ -0,0 +1 @@ +.plugin \ No newline at end of file diff --git a/skills/slack-channel-monitor/.codex-plugin b/skills/slack-channel-monitor/.codex-plugin new file mode 120000 index 00000000..665797f0 --- /dev/null +++ b/skills/slack-channel-monitor/.codex-plugin @@ -0,0 +1 @@ +.plugin \ No newline at end of file diff --git a/skills/slack-channel-monitor/.plugin/plugin.json b/skills/slack-channel-monitor/.plugin/plugin.json new file mode 100644 index 00000000..63e5db96 --- /dev/null +++ b/skills/slack-channel-monitor/.plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "slack-channel-monitor", + "version": "1.0.0", + "description": "Create a cron automation that polls up to 10 Slack channels every minute and starts an OpenHands conversation when a configurable trigger phrase is detected. Forwards thread replies to running conversations and posts summaries back to Slack when the agent finishes.", + "author": { + "name": "OpenHands", + "email": "contact@all-hands.dev" + }, + "homepage": "https://github.com/OpenHands/extensions", + "repository": "https://github.com/OpenHands/extensions", + "license": "MIT", + "keywords": [ + "slack", + "monitor", + "channel", + "trigger", + "cron", + "automation", + "integration" + ] +} diff --git a/skills/slack-channel-monitor/README.md b/skills/slack-channel-monitor/README.md new file mode 100644 index 00000000..f2780a32 --- /dev/null +++ b/skills/slack-channel-monitor/README.md @@ -0,0 +1,91 @@ +# Slack Channel Monitor + +Create a cron automation that polls up to 10 Slack channels every minute and +starts an OpenHands conversation whenever a configurable trigger phrase is +detected. + +## Triggers + +This skill is activated by keywords: + +- `monitor a Slack channel` +- `watch Slack for messages` +- `Slack bot that responds to mentions` +- `OpenHands Slack integration` +- `trigger OpenHands from Slack` +- `respond to @openhands in Slack` +- `poll Slack channels` + +## Features + +- **Token auto-detection**: works with a bot token (`SLACK_BOT_TOKEN`) or a + user token (`SLACK_USER_TOKEN`); informs the user if neither is present +- **Channel name resolution**: resolves `#channel-name` to IDs, with graceful + handling of permission errors +- **Configurable trigger phrase**: defaults to `@openhands`; any low-collision + phrase works (e.g. `jazz hands`, `take-me-to-funky-town`) +- **Efficient polling**: single `search.messages` call for multi-channel user + tokens with `search:read`; falls back to one `conversations.history` call + per channel for bot tokens +- **Thread tracking**: new replies in a triggered thread are forwarded to the + running OpenHands conversation +- **Reaction acknowledgement**: adds a 👀 to every message containing the + trigger phrase +- **Conversation link**: posts a link to the new conversation in the Slack + thread immediately on trigger detection +- **Automatic summaries**: when the conversation reaches a terminal state the + agent's final response is posted back to the thread; error/stuck states + receive a clear error notice +- **Persistent state**: conversation tracking and poll timestamps are stored + in `automation-state/slack_poller_{automation_id}.json` across runs + +## Prerequisites + +Set at least one of the following in **OpenHands Settings - Secrets**: + +| Secret | Token type | Minimum scopes | +|--------|-----------|----------------| +| `SLACK_BOT_TOKEN` | Bot (`xoxb-`) | `channels:history`, `channels:read`, `reactions:write`, `chat:write` | +| `SLACK_USER_TOKEN` | User (`xoxp-`) | Same as bot, plus `search:read` for multi-channel efficiency | + +Optional: + +| Secret | Default | Purpose | +|--------|---------|---------| +| `OPENHANDS_URL` | `http://localhost:8000` | Base URL for conversation links posted in Slack | + +## Quick Start + +Ask OpenHands: + +> "Monitor the #dev-help and #support Slack channels and start a conversation +> whenever someone says @openhands" + +The skill will: + +1. Verify your Slack token is available +2. Resolve channel names to IDs +3. Confirm the trigger phrase (or use the default `@openhands`) +4. Generate and upload a customised automation script +5. Create the automation with cron schedule `* * * * *` + +## How It Works + +Each cron run (every minute): + +1. Fetches new messages from all monitored channels +2. Adds 👀 to any message containing the trigger phrase +3. Creates an OpenHands conversation with the message and recent channel + context as the initial prompt; posts a link to the conversation in the + Slack thread +4. Forwards new replies in tracked threads to the running conversation +5. Checks active conversations - posts the agent's final response back to + Slack when the conversation completes + +## See Also + +- [SKILL.md](SKILL.md) - Full setup workflow and runtime behaviour reference +- [references/slack-api.md](references/slack-api.md) - Token types, required + scopes, endpoint reference, and rate limits +- [references/state-schema.md](references/state-schema.md) - State file schema + and conversation lifecycle diagram diff --git a/skills/slack-channel-monitor/SKILL.md b/skills/slack-channel-monitor/SKILL.md new file mode 100644 index 00000000..82cfd457 --- /dev/null +++ b/skills/slack-channel-monitor/SKILL.md @@ -0,0 +1,249 @@ +--- +name: slack-channel-monitor +description: > + This skill should be used when the user asks to "monitor a Slack channel", + "watch Slack for messages", "create a Slack bot that responds to mentions", + "set up an OpenHands Slack integration", "trigger OpenHands from Slack", + "respond to @openhands in Slack", or "poll Slack channels for a trigger + phrase". Guides the user through creating a cron automation that watches up + to 10 Slack channels and starts an OpenHands conversation whenever a + configurable trigger phrase is detected. +--- + +# Slack Channel Monitor + +Create a cron automation that polls up to 10 Slack channels every minute. +When a message containing the **trigger phrase** (default: `@openhands`) is +detected it: + +1. Adds a 👀 reaction to the triggering message. +2. Opens an OpenHands conversation with the message and recent channel context. +3. Posts a reply in the Slack thread with a link to the conversation. + +On every subsequent run: +- Replies in the thread are forwarded to the running conversation. +- When the conversation finishes (or errors), the agent's final response is + posted back to the Slack thread. + +> **Local mode only.** This automation targets the local OpenHands setup +> (`dev:automation` stack). A cloud/webhook-based variant is out of scope here. + +--- + +## Prerequisites + +### Required secrets + +Verify that at least one of the following secrets is set in +**OpenHands Settings → Secrets** before proceeding: + +| Secret name | Token type | Minimum scopes | +|---|---|---| +| `SLACK_BOT_TOKEN` | Bot (`xoxb-…`) | `channels:history`, `channels:read`, `reactions:write`, `chat:write` | +| `SLACK_USER_TOKEN` | User (`xoxp-…`) | Same as bot, plus `search:read` for multi-channel efficiency | + +Check with: +```bash +# For bot token: +curl -s https://slack.com/api/auth.test -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))" + +# For user token: +curl -s https://slack.com/api/auth.test -H "Authorization: Bearer $SLACK_USER_TOKEN" \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))" +``` + +If neither token is present, inform the user and stop - the automation cannot +function without Slack credentials. + +### Optional secret + +| Secret name | Default | Purpose | +|---|---|---| +| `OPENHANDS_URL` | `http://localhost:8000` | Base URL used to build conversation links posted in Slack | + +--- + +## Setup Workflow + +Follow these steps in order. + +### Step 1 - Collect channels + +Ask the user: *"Which Slack channels should be monitored? You can provide +channel names (e.g. `#general`) or IDs (e.g. `C0123456789`)."* + +**If the user provides channel names**, resolve them to IDs: + +```bash +SLACK_TOKEN="${SLACK_BOT_TOKEN:-$SLACK_USER_TOKEN}" +curl -s "https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200&exclude_archived=true" \ + -H "Authorization: Bearer $SLACK_TOKEN" \ + | python3 -c " +import json, sys +data = json.load(sys.stdin) +if not data.get('ok'): + print('ERROR:', data.get('error')) + exit(1) +names = set(n.lstrip('#') for n in ['CHANNEL_NAMES_HERE'.split(',')]) +for ch in data.get('channels', []): + if ch['name'] in names: + print(f\"{ch['name']} → {ch['id']}\") +" +``` + +Replace `CHANNEL_NAMES_HERE` with the comma-separated names the user provided. + +**If `conversations.list` returns `missing_scope` or `not_authed`:** +Inform the user: *"The token doesn't have permission to list channels. Please +provide the channel IDs directly (right-click a channel in Slack → Copy link - +the last path segment starting with `C` is the ID)."* + +**If the bot token lacks `channels:read`** for private channels, the user can +either invite the bot first (`/invite @botname`) or switch to a user token. + +Collect up to 10 channel IDs. Record them as a Python list literal, e.g.: +```python +["C0123456789", "C9876543210"] +``` + +### Step 2 - Collect trigger phrase + +Ask the user: *"What trigger phrase should OpenHands respond to? +(Press Enter to use the default: `@openhands`)"* + +Accepted values: any non-empty string unlikely to appear accidentally, e.g. +`@openhands`, `jazz hands`, `take-me-to-funky-town`. + +### Step 3 - Generate the automation script + +Read `scripts/main.py` from this skill's directory. Apply exactly three +constant substitutions near the top of the file: + +| Placeholder | Replace with | +|---|---| +| `TRIGGER_PHRASE = "@openhands"` | `TRIGGER_PHRASE = "{user_phrase}"` | +| `CHANNEL_IDS: list[str] = []` | `CHANNEL_IDS: list[str] = {channel_id_list}` | +| `DEFAULT_OPENHANDS_URL = "http://localhost:8000"` | `DEFAULT_OPENHANDS_URL = "{url}"` (keep default if user has no preference) | + +Write the customised script to a temporary directory: +```bash +mkdir -p /tmp/slack-monitor-build +# (write the customised main.py to /tmp/slack-monitor-build/main.py) +``` + +Validate syntax before packaging: +```bash +python3 -m py_compile /tmp/slack-monitor-build/main.py && echo "Syntax OK" +``` + +Fix any syntax errors before proceeding. + +### Step 4 - Package and upload + +```bash +tar -czf /tmp/slack-monitor.tar.gz -C /tmp/slack-monitor-build . + +# Determine the API host (use from the system prompt, else localhost:8000) +OPENHANDS_HOST="http://localhost:8000" + +TARBALL_PATH=$(curl -s -X POST \ + "${OPENHANDS_HOST}/api/automation/v1/uploads?name=slack-channel-monitor" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + -H "Content-Type: application/gzip" \ + --data-binary @/tmp/slack-monitor.tar.gz \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['tarball_path'])") + +echo "Uploaded: $TARBALL_PATH" +``` + +If the upload fails with a size error, the tarball must be under 1 MB. +`main.py` is under 15 KB so this should never trigger. + +### Step 5 - Create the automation + +```bash +curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"Slack Channel Monitor\", + \"trigger\": {\"type\": \"cron\", \"schedule\": \"* * * * *\"}, + \"tarball_path\": \"$TARBALL_PATH\", + \"entrypoint\": \"python3 main.py\", + \"timeout\": 55 + }" | python3 -m json.tool +``` + +A 55-second timeout keeps runs well within the 60-second cron window. + +Record the returned `id` - share it with the user as confirmation. + +### Step 6 - Confirm + +Tell the user: + +> ✅ **Slack Channel Monitor** is running! +> +> - Automation ID: `{id}` +> - Channels: `{channel list}` +> - Trigger phrase: `{phrase}` +> - Polling every minute via cron `* * * * *` +> - State file: `~/.openhands/workspaces/automation-state/slack_poller_{id}.json` +> +> Send a message containing `{phrase}` in any monitored channel to test it. +> The bot will react with 👀 and reply with a link to the new conversation. + +--- + +## Runtime Behaviour (per poll) + +Each cron run executes `main.py`, which: + +1. **Loads state** from the JSON file (see `references/state-schema.md`). +2. **Resolves the Slack token** - checks `SLACK_USER_TOKEN` then `SLACK_BOT_TOKEN`. +3. **Fetches new messages:** + - User token + `search:read` + > 1 channel → single `search.messages` call + (searches for the trigger phrase across all channels). + - Otherwise → one `conversations.history` call per channel. +4. **Fetches thread replies** - one `conversations.replies` call per active thread. +5. **Processes messages** in chronological order: + - Skips bot messages and any `ts` in `bot_message_ts`. + - Reply in a tracked thread → forwards to the existing conversation. + - Contains trigger phrase → 👀 reaction, create conversation, post link. +6. **Checks conversation statuses** - for each active conversation where + `time.time() - last_activity > 15 s`: + - If status is `idle`, `finished`, `error`, or `stuck` → fetch the agent's + final response via `/api/conversations/{id}/agent_final_response` and post + it to the Slack thread. Mark conversation `closed`. +7. **Saves state** and fires the completion callback. + +--- + +## Additional Resources + +### Reference Files + +- **`references/slack-api.md`** - Slack token types, required scopes, API + endpoint reference, rate limits, and common error codes. +- **`references/state-schema.md`** - State JSON schema, field definitions, + example file, and conversation lifecycle diagram. + +### Script Template + +- **`scripts/main.py`** - The complete automation script. Customise the three + constants at the top (`TRIGGER_PHRASE`, `CHANNEL_IDS`, `DEFAULT_OPENHANDS_URL`) + before packaging. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| Bot doesn't react to messages | Token missing or bot not in channel | Verify token with `auth.test`; `/invite @botname` | +| `not_in_channel` error in run logs | Bot token used but bot not a member | Invite bot or switch to user token | +| `missing_scope` error | Token lacks required scopes | Re-install Slack app with correct scopes (see `references/slack-api.md`) | +| No messages detected | `last_poll` timestamp is in the future | Delete the state file to reset; it will be recreated on next run | +| Conversation link 404 | `OPENHANDS_URL` points to wrong host | Set the `OPENHANDS_URL` secret to the correct base URL | +| Summary never posted | Conversation stuck in `running` state | Check conversation in the OpenHands UI; the agent may need intervention | diff --git a/skills/slack-channel-monitor/references/slack-api.md b/skills/slack-channel-monitor/references/slack-api.md new file mode 100644 index 00000000..d8b825d4 --- /dev/null +++ b/skills/slack-channel-monitor/references/slack-api.md @@ -0,0 +1,207 @@ +# Slack API Reference + +Reference material for the `slack-channel-monitor` skill. Consult this file +when resolving token issues, diagnosing permission errors, or adjusting the +polling strategy. + +--- + +## Token Types + +| Type | Prefix | Typical source | Relevant scopes | +|------|--------|---------------|-----------------| +| **Bot token** | `xoxb-` | OAuth install / Slack App → Install App | `channels:history`, `channels:read`, `reactions:write`, `chat:write` | +| **User token** | `xoxp-` | OAuth flow on behalf of a workspace member | Same as bot + `search:read` for multi-channel search | + +### Choosing a token + +- **Prefer a bot token** for single-channel monitoring or when `search:read` is + unavailable. One `conversations.history` call per channel per minute is fine + for < 10 channels. +- **Use a user token** with `search:read` when monitoring multiple channels, to + reduce API calls by querying all channels in a single `search.messages` request. + +### Checking token type at runtime + +The script detects token type by checking which secret name is set: + +1. `SLACK_USER_TOKEN` (checked first - user token preferred for multi-channel) +2. `SLACK_BOT_TOKEN` + +Set the appropriate secret in **OpenHands Settings → Secrets**. + +--- + +## Required Scopes + +### Bot token (`xoxb-`) + +| Scope | Used for | +|-------|----------| +| `channels:history` | Read messages from public channels | +| `groups:history` | Read messages from private channels (if monitoring any) | +| `channels:read` | Resolve channel names → IDs | +| `reactions:write` | Add 👀 reaction to trigger messages | +| `chat:write` | Post conversation links and summaries back to Slack | + +### User token (`xoxp-`) - additional scope + +| Scope | Used for | +|-------|----------| +| `search:read` | `search.messages` across multiple channels in one request | + +--- + +## Relevant API Endpoints + +### `conversations.history` +Fetch messages from a single channel newer than a timestamp. + +``` +GET https://slack.com/api/conversations.history + ?channel=CHANNEL_ID + &oldest=UNIX_TIMESTAMP (exclusive - messages strictly after this) + &limit=100 + &inclusive=false +``` + +Returns a `messages` array. Each message has `ts`, `user`, `text`, `thread_ts` +(present if the message is in a thread or is a threaded reply). + +**Bot must be invited to the channel** (or the token must have `channels:history` +for public channels without joining). + +--- + +### `conversations.replies` +Fetch replies inside a specific thread newer than a timestamp. + +``` +GET https://slack.com/api/conversations.replies + ?channel=CHANNEL_ID + &ts=THREAD_ROOT_TS + &oldest=UNIX_TIMESTAMP + &limit=100 + &inclusive=false +``` + +The first item in `messages` is always the parent message - the script drops it +when comparing `ts == thread_root_ts`. + +--- + +### `search.messages` +Search for messages matching a query across channels (user token only). + +``` +GET https://slack.com/api/search.messages + ?query=QUERY_STRING + &count=100 + &sort=timestamp + &sort_dir=asc +``` + +**Query syntax used by this skill:** + +``` +"@openhands" in:<#C0123456> in:<#C9876543> after:2026-01-01 +``` + +- `in:<#CHANNEL_ID>` - restrict to a channel (channel ID or name both work) +- `after:YYYY-MM-DD` - date-level precision only (the script post-filters by + precise Unix timestamp) +- Phrase in quotes - exact match + +**Limitations:** +- Date-only precision for `after:` - cannot filter to the minute +- Results sorted by relevance by default; use `sort=timestamp` to get chronological order +- `count` max is 100 per page (pagination supported via `page` parameter) +- Requires `search:read` scope - not available to bot tokens + +--- + +### `reactions.add` +Add an emoji reaction to a message. + +``` +POST https://slack.com/api/reactions.add +{ + "channel": "CHANNEL_ID", + "name": "eyes", + "timestamp": "MESSAGE_TS" +} +``` + +Error `already_reacted` is safe to ignore. + +--- + +### `chat.postMessage` +Post a message to a channel, optionally within a thread. + +``` +POST https://slack.com/api/chat.postMessage +{ + "channel": "CHANNEL_ID", + "text": "Message text", + "thread_ts": "THREAD_ROOT_TS" // omit for top-level messages +} +``` + +Returns `ts` of the posted message - **store this in `bot_message_ts`** in the +state file to prevent the bot from processing its own messages. + +--- + +### `conversations.list` +List channels visible to the token (used to resolve names → IDs during setup). + +``` +GET https://slack.com/api/conversations.list + ?types=public_channel,private_channel + &limit=200 + &exclude_archived=true +``` + +Supports cursor-based pagination via the `response_metadata.next_cursor` field. + +--- + +### `auth.test` +Verify a token and retrieve the associated user/bot ID. + +``` +GET https://slack.com/api/auth.test +``` + +Returns `user_id` (used by the script to detect and skip its own messages). + +--- + +## Common Errors + +| Error | Cause | Fix | +|-------|-------|-----| +| `not_in_channel` | Bot hasn't been invited | `/invite @botname` in the channel | +| `missing_scope` | Token lacks a required scope | Re-install the Slack app with the correct scopes | +| `channel_not_found` | Channel ID is wrong | Use `conversations.list` to verify the ID | +| `ratelimited` | Too many API calls | Slack allows ~50 requests/min per token; < 10 channels is well within limits | +| `invalid_auth` | Token expired or revoked | Regenerate the token and update the secret | + +--- + +## Rate Limits + +Slack applies per-method rate limits (Tier 2 = ~20 req/min, Tier 3 = ~50 req/min). +With < 10 channels polled every minute: + +| Method | Tier | Calls/min | Headroom | +|--------|------|-----------|---------| +| `conversations.history` | Tier 3 | ≤ 10 | Comfortable | +| `conversations.replies` | Tier 3 | ≤ active threads | Fine unless hundreds of threads | +| `search.messages` | Tier 2 | 1 | Fine | +| `reactions.add` | Tier 2 | ≤ triggers/min | Fine | +| `chat.postMessage` | Tier 3 | ≤ triggers + summaries | Fine | + +No rate-limit handling is implemented in the script. If you hit limits the +run will fail and retry on the next cron tick. diff --git a/skills/slack-channel-monitor/references/state-schema.md b/skills/slack-channel-monitor/references/state-schema.md new file mode 100644 index 00000000..82ee19b5 --- /dev/null +++ b/skills/slack-channel-monitor/references/state-schema.md @@ -0,0 +1,156 @@ +# State File Schema + +The automation maintains a JSON state file that persists across polling runs. +This file is the source of truth for which conversations are active, which +timestamps have been processed, and which messages were posted by the bot. + +--- + +## File Location + +``` +{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_{automation_id}.json +``` + +Where `WORKSPACE_BASE_ROOT` is derived by going two levels up from the +`WORKSPACE_BASE` environment variable (stripping `automation-runs/{run_id}`). + +Example on a local install: + +``` +~/.openhands/workspaces/automation-state/slack_poller_abc12345-….json +``` + +The `automation_id` is read from the `AUTOMATION_EVENT_PAYLOAD` environment +variable (field `automation_id`). + +--- + +## Top-Level Schema + +```jsonc +{ + "version": 1, // schema version (integer) + "bot_user_id": "UBOTID123", // Slack user_id of the bot/token owner + // cached from auth.test; null until first run + "last_poll": { + "C0123456789": "1716576000.123456" // channel_id → float Unix timestamp (string) + // updated at the START of each run so that + // the NEXT run fetches everything after it + }, + "conversations": { ... }, // see ConversationRecord below + "bot_message_ts": [ // rolling list of Slack 'ts' values for + "1716576100.000200" // messages THIS bot posted; used to skip + ] // self-messages during processing +} +``` + +--- + +## `conversations` Map + +Key: `"{channel_id}:{thread_root_ts}"` - uniquely identifies a Slack thread. + +Value: **ConversationRecord** + +```jsonc +{ + // Required fields + "conversation_id": "550e8400-e29b-41d4-a716-446655440000", + // OpenHands conversation UUID + "channel_id": "C0123456789", // Slack channel ID + "thread_ts": "1716576000.000100", + // Slack thread root timestamp + // (= msg_ts for top-level trigger messages) + "status": "active", // "active" | "closed" + "last_activity": 1716576060.0, // float Unix timestamp of the last time the + // script sent a message to this conversation + // (creation time, or time a reply was forwarded) +} +``` + +### `status` values + +| Value | Meaning | +|-------|---------| +| `active` | Conversation is running or awaiting more input; replies will be forwarded | +| `closed` | Summary has been posted to Slack; no further processing | + +Closed conversations are retained in the map indefinitely (the map stays small +since there are < 10 channels and the trigger rate is typically low). If the +map grows unexpectedly, closed entries older than a configurable TTL can be +pruned. + +--- + +## `bot_message_ts` List + +A rolling list (max `MAX_BOT_TS = 2000` entries) of Slack `ts` values for +messages posted BY the bot. This prevents the script from treating its own +replies as user messages. + +Entries are added when: +- The bot posts a conversation link (on trigger detection) +- The bot posts a summary (on conversation completion) + +--- + +## Transition Diagram + +``` +[trigger detected] + │ + ▼ + status = "active" + last_activity = now + │ + (next run or later runs) + │ + ┌─────┴──────────────────────────────────────────┐ + │ User sends a reply in the thread │ + │ → send_to_conversation() called │ + │ → last_activity = now │ + └─────────────────────────────────────────────────┘ + │ + (when time.time() - last_activity > DONE_DEBOUNCE + AND conversation_status ∈ {idle, finished, error, stuck}) + │ + ▼ + Post summary to Slack thread + status = "closed" +``` + +--- + +## Example State File + +```json +{ + "version": 1, + "bot_user_id": "U04AB1CDEF", + "last_poll": { + "C0123456789": "1716576060.000000", + "C9876543210": "1716576060.000000" + }, + "conversations": { + "C0123456789:1716575900.000100": { + "conversation_id": "550e8400-e29b-41d4-a716-446655440000", + "channel_id": "C0123456789", + "thread_ts": "1716575900.000100", + "status": "active", + "last_activity": 1716575902.3 + }, + "C9876543210:1716570000.000500": { + "conversation_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "channel_id": "C9876543210", + "thread_ts": "1716570000.000500", + "status": "closed", + "last_activity": 1716572100.0 + } + }, + "bot_message_ts": [ + "1716575903.000200", + "1716572105.000100" + ] +} +``` diff --git a/skills/slack-channel-monitor/scripts/main.py b/skills/slack-channel-monitor/scripts/main.py new file mode 100644 index 00000000..b0603a47 --- /dev/null +++ b/skills/slack-channel-monitor/scripts/main.py @@ -0,0 +1,714 @@ +""" +Slack Channel Monitor - OpenHands Automation Script + +Polls monitored Slack channels every minute. When a message containing the +trigger phrase is detected it: + 1. Adds a 👀 reaction to acknowledge the message. + 2. Creates an OpenHands conversation pre-loaded with the message and recent + channel context. + 3. Posts a reply in the Slack thread with a link to the conversation. + +On subsequent runs: + - New replies in a tracked thread are forwarded to the running conversation. + - When the conversation reaches a terminal/idle state the agent's final + response (or an error notice) is posted back to the Slack thread. + +Configuration constants are embedded at automation-creation time by the skill. +See SKILL.md for the full setup workflow. + +Required secrets (set in OpenHands Settings → Secrets): + SLACK_BOT_TOKEN - bot token (xoxb-…) with scopes: + channels:history, channels:read, + reactions:write, chat:write + OR + SLACK_USER_TOKEN - user token (xoxp-…) with scopes: + channels:history, search:read (for multi-channel), + reactions:write, chat:write + +Optional secret: + OPENHANDS_URL - base URL of your OpenHands instance for conversation + links (default: http://localhost:8000) +""" + +import json +import os +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from urllib.parse import urlencode + +# ── Embedded configuration (filled in by the skill at creation time) ────────── +TRIGGER_PHRASE = "@openhands" +CHANNEL_IDS: list[str] = [] # e.g. ["C0123456789", "C9876543210"] +DEFAULT_OPENHANDS_URL = "http://localhost:8000" + +# Lookback slightly over 60s to avoid missing messages at cron boundaries +# when poll interval jitter causes slight delays. +INITIAL_LOOKBACK = 70 + +# Prevent posting summaries in the same run that created the conversation, +# avoiding race conditions with conversation startup. +DONE_DEBOUNCE = 15 + +# Rolling window size for bot message deduplication - sized to handle +# ~1 week of continuous operation at high message rates. +MAX_BOT_TS = 2000 + +# Limit context to avoid overwhelming the agent with too much history. +CONTEXT_MESSAGE_LIMIT = 15 + +# How far back (seconds) to look for context when creating a new conversation. +CONTEXT_LOOKBACK_SECONDS = 3600 # 1 hour of recent messages for context + + +# ── Stdlib helpers ───────────────────────────────────────────────────────────── + +def _get_env_key() -> str: + return ( + os.environ.get("SESSION_API_KEY") + or os.environ.get("OH_SESSION_API_KEYS_0") + or "" + ) + + +def get_secret(name: str) -> str: + """Fetch a named secret from the agent server.""" + url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/") + key = _get_env_key() + req = urllib.request.Request( + f"{url}/api/settings/secrets/{name}", + headers={"X-Session-API-Key": key}, + ) + with urllib.request.urlopen(req) as r: + return r.read().decode().strip() + + +def fire_callback(status: str = "COMPLETED", error: str | None = None) -> None: + """Signal run completion to the automation service.""" + url = os.environ.get("AUTOMATION_CALLBACK_URL", "") + if not url: + return + body: dict = {"status": status, "run_id": os.environ.get("AUTOMATION_RUN_ID", "")} + if error: + body["error"] = error + req = urllib.request.Request( + url, + data=json.dumps(body).encode(), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}", + }, + ) + try: + urllib.request.urlopen(req) + except Exception as exc: + print(f"Callback error (non-fatal): {exc}") + + +# ── State management ─────────────────────────────────────────────────────────── + +def _state_file_path() -> str: + """Derive a persistent storage path from WORKSPACE_BASE. + + WORKSPACE_BASE = {root}/automation-runs/{run_id} + State lives two levels up at {root}/automation-state/. + """ + workspace_base = os.environ.get("WORKSPACE_BASE", "") + event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}")) + automation_id = event_payload.get("automation_id", "default") + + if workspace_base: + root = os.path.dirname(os.path.dirname(os.path.abspath(workspace_base))) + else: + root = os.path.expanduser("~/.openhands/workspaces") + + state_dir = os.path.join(root, "automation-state") + os.makedirs(state_dir, exist_ok=True) + return os.path.join(state_dir, f"slack_poller_{automation_id}.json") + + +def load_state(path: str) -> dict: + if os.path.exists(path): + with open(path) as f: + return json.load(f) + return { + "version": 1, + "bot_user_id": None, + "last_poll": {}, # channel_id → float timestamp string + "conversations": {}, # conv_key → ConversationRecord (see schema docs) + "bot_message_ts": [], # ts strings of messages posted by this bot + } + + +def save_state(path: str, state: dict) -> None: + with open(path, "w") as f: + json.dump(state, f, indent=2) + + +# ── Slack API helpers ────────────────────────────────────────────────────────── + +def _slack_call( + token: str, + method: str, + endpoint: str, + params: dict | None = None, + body: dict | None = None, +) -> dict: + """Low-level Slack API call. Raises RuntimeError on API errors.""" + url = f"https://slack.com/api/{endpoint}" + if params: + url = f"{url}?{urlencode(params)}" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req) as r: + result = json.loads(r.read()) + if not result.get("ok"): + raise RuntimeError(f"Slack {endpoint}: {result.get('error', 'unknown_error')}") + return result + + +def slack_get(token: str, endpoint: str, params: dict | None = None) -> dict: + return _slack_call(token, "GET", endpoint, params=params) + + +def slack_post(token: str, endpoint: str, body: dict) -> dict: + return _slack_call(token, "POST", endpoint, body=body) + + +def _slack_auth_test(token: str) -> tuple[str, set[str]]: + """Call auth.test, verify the token, and return (user_id, scopes). + + Reads the X-OAuth-Scopes response header so callers can gate behaviour on + individual scopes without making extra API calls. Raises RuntimeError if + the token is rejected by Slack. + """ + req = urllib.request.Request( + "https://slack.com/api/auth.test", + headers={"Authorization": f"Bearer {token}"}, + ) + with urllib.request.urlopen(req) as r: + scopes_header: str = r.headers.get("X-OAuth-Scopes", "") + result = json.loads(r.read()) + if not result.get("ok"): + raise RuntimeError(f"Slack token rejected: {result.get('error')}") + scopes = ( + {s.strip() for s in scopes_header.split(",") if s.strip()} + if scopes_header else set() + ) + return result.get("user_id", ""), scopes + + +def add_reaction(token: str, channel: str, ts: str, emoji: str = "eyes") -> None: + try: + slack_post(token, "reactions.add", {"channel": channel, "name": emoji, "timestamp": ts}) + except RuntimeError as exc: + if "already_reacted" not in str(exc): + print(f" Warning: reactions.add failed: {exc}") + + +def post_message(token: str, channel: str, text: str, thread_ts: str | None = None) -> str: + """Post a Slack message and return its timestamp.""" + body: dict = {"channel": channel, "text": text} + if thread_ts: + body["thread_ts"] = thread_ts + return slack_post(token, "chat.postMessage", body).get("ts", "") + + +def channel_history(token: str, channel: str, oldest: str, limit: int = 100) -> list[dict]: + result = slack_get(token, "conversations.history", { + "channel": channel, + "oldest": oldest, + "limit": limit, + "inclusive": "false", + }) + return result.get("messages", []) + + +def thread_replies(token: str, channel: str, thread_ts: str, oldest: str) -> list[dict]: + """Fetch replies in a thread newer than oldest.""" + result = slack_get(token, "conversations.replies", { + "channel": channel, + "ts": thread_ts, + "oldest": oldest, + "limit": 100, + "inclusive": "false", + }) + messages = result.get("messages", []) + # conversations.replies includes the parent; drop it + return [m for m in messages if m.get("ts") != thread_ts] + + +def search_trigger_messages( + token: str, channel_ids: list[str], trigger: str, oldest_ts: str +) -> list[dict]: + """Search for trigger messages across channels (user token with search:read). + + Uses the search query approach which avoids N per-channel history calls. + Results are post-filtered by timestamp since search only supports date-level + precision in the 'after:' modifier. + """ + channel_filter = " ".join(f"in:<#{cid}>" for cid in channel_ids) + oldest_dt = datetime.fromtimestamp(float(oldest_ts), tz=timezone.utc) + # Use yesterday's date to ensure we catch all messages since our timestamp + date_str = oldest_dt.strftime("%Y-%m-%d") + query = f'"{trigger}" {channel_filter} after:{date_str}' + result = slack_get(token, "search.messages", { + "query": query, + "count": 100, + "sort": "timestamp", + "sort_dir": "asc", + }) + matches = result.get("messages", {}).get("matches", []) + # Post-filter to our precise oldest timestamp + return [m for m in matches if float(m.get("ts", "0")) > float(oldest_ts)] + + +def has_search_permission(scopes: set[str]) -> bool: + return "search:read" in scopes + + +# ── OpenHands Agent Server helpers ──────────────────────────────────────────── + +def _oh_request( + agent_url: str, api_key: str, method: str, path: str, body: dict | None = None +) -> dict: + url = f"{agent_url}{path}" + headers = {"X-Session-API-Key": api_key, "Content-Type": "application/json"} + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as r: + raw = r.read() + return json.loads(raw) if raw.strip() else {} + except urllib.error.HTTPError as exc: + body_text = exc.read().decode() + raise RuntimeError(f"Agent API {method} {path} → {exc.code}: {body_text}") from exc + + +def _get_agent_dict(agent_url: str, api_key: str) -> dict: + """Fetch configured agent settings and return a serialised Agent dict. + + Uses X-Expose-Secrets: plaintext so the LLM api_key is a real string + rather than a masked placeholder. The result is passed as the 'agent' + field (not 'agent_settings') to avoid a double-registration bug: the + agent_settings code path calls create_agent() during request validation + AND again during StoredConversation construction, both of which try to + register the same usage_id in the LLM registry. + """ + url = f"{agent_url}/api/settings" + headers = {"X-Session-API-Key": api_key, "X-Expose-Secrets": "plaintext"} + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req) as r: + data = json.loads(r.read()) + except urllib.error.HTTPError as exc: + raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc + llm = data.get("agent_settings", {}).get("llm", {}) + return {"kind": "Agent", "llm": llm} + + +def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str: + """Create a conversation and return its ID. + + The server auto-starts the agent when initial_message is provided + (conversation_service calls send_message(..., run=True)), so no + separate POST to /run is needed or wanted — it would 409. + """ + workspace_dir = os.environ.get("WORKSPACE_BASE", "/workspace") + agent = _get_agent_dict(agent_url, api_key) + result = _oh_request(agent_url, api_key, "POST", "/api/conversations", { + "workspace": {"working_dir": workspace_dir}, + "agent": agent, + "initial_message": {"content": [{"text": initial_message}]}, + }) + return result["id"] + + +def send_to_conversation(agent_url: str, api_key: str, conv_id: str, text: str) -> None: + """Send a user message to an existing conversation and resume the agent.""" + _oh_request(agent_url, api_key, "POST", f"/api/conversations/{conv_id}/events", { + "role": "user", + "content": [{"text": text}], + "run": True, + }) + + +def conversation_status(agent_url: str, api_key: str, conv_id: str) -> str: + result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}") + return result.get("execution_status", "unknown") + + +def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str: + result = _oh_request( + agent_url, api_key, "GET", f"/api/conversations/{conv_id}/agent_final_response" + ) + return result.get("response", "") + + +# ── Message filtering ────────────────────────────────────────────────────────── + +def _is_human_message(msg: dict, bot_user_id: str, bot_message_ts: list[str]) -> bool: + """Return True if the message was posted by a human and not by this bot.""" + if msg.get("bot_id"): + return False + if msg.get("subtype"): + return False + if msg.get("user") == bot_user_id: + return False + if msg.get("ts") in bot_message_ts: + return False + return True + + +# ── Polling helpers ──────────────────────────────────────────────────────────── + +def _resolve_slack_token() -> tuple[str, bool]: + """Try SLACK_USER_TOKEN then SLACK_BOT_TOKEN; return (token, is_user). + Raises RuntimeError if neither is set. + """ + for secret_name, is_user in [("SLACK_USER_TOKEN", True), ("SLACK_BOT_TOKEN", False)]: + try: + val = get_secret(secret_name) + if val: + print(f"Using {secret_name}") + return val, is_user + except Exception: + pass + raise RuntimeError( + "No Slack token found. Set SLACK_BOT_TOKEN or SLACK_USER_TOKEN in " + "OpenHands Settings → Secrets." + ) + + +def _verify_token_scopes(scopes: set[str]) -> bool: + """Validate required scopes; return can_react. + Raises RuntimeError if a mandatory scope is absent. + If scopes header was absent, allows the API to fail at point of use. + """ + if not scopes: + # X-OAuth-Scopes header absent (unusual); proceed and let the API + # return errors at the point of use rather than blocking everything. + return True + read_scopes = {"channels:history", "groups:history", "im:history", "mpim:history"} + if not (scopes & read_scopes): + raise RuntimeError( + "Slack token is missing a read scope. " + f"Required: one of {sorted(read_scopes)}. " + f"Token has: {sorted(scopes)}" + ) + if "chat:write" not in scopes: + raise RuntimeError( + "Slack token is missing the chat:write scope. " + f"Token has: {sorted(scopes)}" + ) + can_react: bool = "reactions:write" in scopes + if not can_react: + print("Note: reactions:write scope absent - 👀 reactions will be skipped") + return can_react + + +def _gather_channel_context( + slack_token: str, + channel_id: str, + before_ts: str, + bot_user_id: str, + bot_message_ts: list[str], + limit: int = CONTEXT_MESSAGE_LIMIT, +) -> list[str]: + """Gather recent human messages from a channel for context.""" + context_lines: list[str] = [] + try: + cutoff = str(float(before_ts) - CONTEXT_LOOKBACK_SECONDS) + msgs = channel_history(slack_token, channel_id, cutoff, limit) + for msg in reversed(msgs): + if _is_human_message(msg, bot_user_id, bot_message_ts): + context_lines.append(f"[{msg.get('user','?')}]: {msg.get('text','')}") + except Exception: + pass # context is best-effort + return context_lines + + +def _poll_new_messages( + slack_token: str, + use_search: bool, + oldest_by_channel: dict[str, str], + global_oldest: str, + active_convs: dict[str, dict], +) -> list[tuple[str, dict]]: + """Collect and sort new top-level messages and thread replies from Slack.""" + new_messages: list[tuple[str, dict]] = [] + + if use_search: + try: + matches = search_trigger_messages(slack_token, CHANNEL_IDS, TRIGGER_PHRASE, global_oldest) + for m in matches: + cid = m.get("channel", {}).get("id", "") + if cid in CHANNEL_IDS: + ch_oldest = oldest_by_channel.get(cid, global_oldest) + if float(m.get("ts", "0")) > float(ch_oldest): + new_messages.append((cid, m)) + print(f"search.messages returned {len(new_messages)} trigger candidate(s)") + except Exception as exc: + print(f"search.messages failed ({exc}), falling back to conversations.history") + use_search = False + + if not use_search: + for cid in CHANNEL_IDS: + oldest = oldest_by_channel[cid] + try: + msgs = channel_history(slack_token, cid, oldest) + for m in msgs: + new_messages.append((cid, m)) + print(f" {cid}: {len(msgs)} new message(s) since {oldest}") + except Exception as exc: + print(f" Warning: could not fetch history for {cid}: {exc}") + + reply_messages: list[tuple[str, dict]] = [] + for _conv_key, rec in active_convs.items(): + if rec.get("status") == "closed": + continue + cid = rec["channel_id"] + thread_ts = rec["thread_ts"] + oldest = oldest_by_channel.get(cid, global_oldest) + try: + replies = thread_replies(slack_token, cid, thread_ts, oldest) + for r in replies: + reply_messages.append((cid, r)) + except Exception as exc: + print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}") + + return sorted( + new_messages + reply_messages, + key=lambda x: float(x[1].get("ts", "0")), + ) + + +def _process_trigger_message( + slack_token: str, + agent_url: str, + api_key: str, + openhands_url: str, + channel_id: str, + msg_ts: str, + text: str, + thread_root: str, + conv_key: str, + active_convs: dict[str, dict], + bot_message_ts: list[str], + bot_user_id: str, + can_react: bool, +) -> None: + """React to a trigger message, create an OpenHands conversation, and post a link.""" + print(f" Trigger detected in {channel_id} at {msg_ts}: {text[:80]}") + if can_react: + add_reaction(slack_token, channel_id, msg_ts) + + context_lines = _gather_channel_context( + slack_token, channel_id, msg_ts, bot_user_id, bot_message_ts + ) + context_block = "\n".join(context_lines) if context_lines else "(no recent context)" + + initial_prompt = ( + f"You are an AI assistant responding to a message in a Slack channel.\n\n" + f"Channel ID : {channel_id}\n" + f"Thread root: {thread_root}\n" + f"Trigger msg: {text}\n\n" + f"Recent channel context (oldest → newest):\n" + f"---\n{context_block}\n---\n\n" + f"Please analyse the request and take the appropriate action. " + f"When you are finished, summarise what you did clearly - that " + f"summary will be posted back to the Slack thread." + ) + + try: + conv_id = create_conversation(agent_url, api_key, initial_prompt) + conv_url = f"{openhands_url}/conversations/{conv_id}" + + active_convs[conv_key] = { + "conversation_id": conv_id, + "channel_id": channel_id, + "thread_ts": thread_root, + "status": "active", + "last_activity": time.time(), + } + + link_text = f"🤖 On it! View progress here: {conv_url}" + ts_back = post_message(slack_token, channel_id, link_text, thread_ts=thread_root) + if ts_back: + bot_message_ts.append(ts_back) + + print(f" Created conversation {conv_id} ({conv_url})") + except Exception as exc: + print(f" Error creating conversation for {conv_key}: {exc}") + + +def _check_conversation_completion( + conv_key: str, + rec: dict, + agent_url: str, + api_key: str, + slack_token: str, + bot_message_ts: list[str], +) -> None: + """Post the agent's final response to the Slack thread when the conversation finishes.""" + last_activity: float = rec.get("last_activity", 0.0) + if (time.time() - last_activity) < DONE_DEBOUNCE: + return + + conv_id = rec["conversation_id"] + channel_id = rec["channel_id"] + thread_ts = rec["thread_ts"] + + try: + status = conversation_status(agent_url, api_key, conv_id) + except Exception as exc: + print(f" Warning: could not get status for {conv_id}: {exc}") + return + + print(f" {conv_key} → status={status}") + + if status in ("idle", "finished", "error", "stuck"): + try: + final = conversation_final_response(agent_url, api_key, conv_id) + except Exception: + final = "" + + if status in ("error", "stuck"): + summary = ( + f"⚠️ The agent encountered a problem (status: *{status}*)." + + (f"\n\n{final}" if final else "") + ) + else: + summary = f"✅ Done!\n\n{final}" if final else "✅ Task complete (no summary available)." + + ts_back = post_message(slack_token, channel_id, summary, thread_ts=thread_ts) + if ts_back: + bot_message_ts.append(ts_back) + + rec["status"] = "closed" + print(f" Posted summary for {conv_key}") + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main() -> None: + state_path = _state_file_path() + state = load_state(state_path) + + agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/") + api_key = _get_env_key() + + slack_token, token_is_user = _resolve_slack_token() + + try: + openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL + except Exception: + openhands_url = DEFAULT_OPENHANDS_URL + + # Raises RuntimeError immediately if the token is invalid - no point polling. + bot_user_id_new, scopes = _slack_auth_test(slack_token) + state["bot_user_id"] = bot_user_id_new + print(f"Bot user ID: {bot_user_id_new}") + + can_react = _verify_token_scopes(scopes) + + bot_user_id: str = state.get("bot_user_id") or "" + bot_message_ts: list[str] = state.get("bot_message_ts", []) + now_ts = str(time.time()) + + use_search = ( + token_is_user + and len(CHANNEL_IDS) > 1 + and has_search_permission(scopes) + ) + print(f"Polling strategy: {'search.messages' if use_search else 'conversations.history'}") + + oldest_by_channel: dict[str, str] = { + cid: state["last_poll"].get(cid, str(time.time() - INITIAL_LOOKBACK)) + for cid in CHANNEL_IDS + } + global_oldest = min(oldest_by_channel.values()) + + active_convs: dict[str, dict] = state.get("conversations", {}) + + all_incoming = _poll_new_messages( + slack_token, use_search, oldest_by_channel, global_oldest, active_convs + ) + + for cid in CHANNEL_IDS: + state["last_poll"][cid] = now_ts + + for channel_id, msg in all_incoming: + if not _is_human_message(msg, bot_user_id, bot_message_ts): + continue + + msg_ts: str = msg.get("ts", "") + text: str = msg.get("text", "") or "" + thread_ts: str | None = msg.get("thread_ts") + + # thread_root is the TS we use as the conversation key. + # For top-level messages it's the message itself; for replies it's the parent. + thread_root: str = thread_ts if thread_ts and thread_ts != msg_ts else msg_ts + conv_key = f"{channel_id}:{thread_root}" + + has_trigger = TRIGGER_PHRASE.lower() in text.lower() + is_reply_in_tracked = ( + thread_ts is not None + and thread_ts != msg_ts + and conv_key in active_convs + and active_convs[conv_key].get("status") != "closed" + ) + + # ── Case A: reply in a thread that has an active conversation ────────── + if is_reply_in_tracked: + rec = active_convs[conv_key] + print(f" Forwarding reply {msg_ts} → conversation {rec['conversation_id']}") + try: + send_to_conversation(agent_url, api_key, rec["conversation_id"], + f"User replied in Slack thread: {text}") + rec["status"] = "active" + rec["last_activity"] = time.time() + except Exception as exc: + print(f" Warning: failed to forward reply: {exc}") + if has_trigger and can_react: + add_reaction(slack_token, channel_id, msg_ts) + continue + + # ── Case B: message contains trigger phrase → create a new conversation ─ + if has_trigger: + _process_trigger_message( + slack_token, agent_url, api_key, openhands_url, + channel_id, msg_ts, text, thread_root, conv_key, + active_convs, bot_message_ts, bot_user_id, can_react, + ) + + for conv_key, rec in list(active_convs.items()): + if rec.get("status") != "closed": + _check_conversation_completion( + conv_key, rec, agent_url, api_key, slack_token, bot_message_ts, + ) + + if len(bot_message_ts) > MAX_BOT_TS: + state["bot_message_ts"] = bot_message_ts[-MAX_BOT_TS:] + else: + state["bot_message_ts"] = bot_message_ts + + state["conversations"] = active_convs + save_state(state_path, state) + print(f"State saved to {state_path}") + + +try: + main() + fire_callback("COMPLETED") +except Exception as exc: + import traceback + traceback.print_exc() + fire_callback("FAILED", str(exc)) + sys.exit(1)