From dda6830d1bf06e428f056b79f15baf34547f0b36 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 22 May 2026 07:10:59 -0600 Subject: [PATCH 1/8] feat: add slack-channel-monitor skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a skill that guides users through creating a cron automation to monitor up to 10 Slack channels and start OpenHands conversations when a configurable trigger phrase is detected. Key features: - Polls channels every minute via cron (* * * * *) - Trigger phrase detection with configurable default (@openhands) - Resolves channel names to IDs; handles permission errors gracefully - Single search.messages call for multi-channel user tokens with search:read; falls back to per-channel conversations.history - Thread replies forwarded to running conversations - 👀 reaction on trigger messages - Conversation link posted immediately in the Slack thread - Terminal/idle state detection with debounce; posts agent final response as summary; error/stuck states post a clear error notice - State persisted across runs in automation-state/slack_poller_*.json Includes: - scripts/main.py - pure stdlib automation script (no SDK needed) - references/slack-api.md - token types, scopes, endpoints, limits - references/state-schema.md - JSON state schema and lifecycle diagram Co-authored-by: openhands --- README.md | 5 +- marketplaces/openhands-extensions.json | 15 + skills/slack-channel-monitor/SKILL.md | 249 ++++++++ .../references/slack-api.md | 207 ++++++ .../references/state-schema.md | 156 +++++ skills/slack-channel-monitor/scripts/main.py | 595 ++++++++++++++++++ 6 files changed, 1225 insertions(+), 2 deletions(-) create mode 100644 skills/slack-channel-monitor/SKILL.md create mode 100644 skills/slack-channel-monitor/references/slack-api.md create mode 100644 skills/slack-channel-monitor/references/state-schema.md create mode 100644 skills/slack-channel-monitor/scripts/main.py 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/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..26d225f7 --- /dev/null +++ b/skills/slack-channel-monitor/scripts/main.py @@ -0,0 +1,595 @@ +""" +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" + +# How far back (seconds) to look when there is no previous poll timestamp. +# Slightly over 60 s to avoid missing messages at cron boundaries. +INITIAL_LOOKBACK = 70 + +# Minimum seconds since last activity before treating a conversation as done. +# Guards against posting a summary in the same run that created the conversation. +DONE_DEBOUNCE = 15 + +# Maximum bot message timestamps to keep in state (rolling window). +MAX_BOT_TS = 2000 + +# Maximum context messages to include when creating a new conversation. +CONTEXT_MESSAGE_LIMIT = 15 + + +# ── 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 get_bot_user_id(token: str) -> str: + return slack_get(token, "auth.test").get("user_id", "") + + +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(token: str) -> bool: + try: + slack_get(token, "search.messages", {"query": "test", "count": 1}) + return True + except RuntimeError as exc: + return "missing_scope" not in str(exc) + + +# ── 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 create_conversation(agent_url: str, api_key: str, initial_message: str) -> str: + """Create a conversation, start it running, and return its ID.""" + result = _oh_request(agent_url, api_key, "POST", "/api/conversations", { + "initial_message": {"content": [{"text": initial_message}]}, + }) + conv_id = result["id"] + _oh_request(agent_url, api_key, "POST", f"/api/conversations/{conv_id}/run") + return conv_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 + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main() -> None: # noqa: C901 (complexity is inherent here) + state_path = _state_file_path() + state = load_state(state_path) + + agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/") + api_key = _get_env_key() + + # ── Resolve Slack token ──────────────────────────────────────────────────── + slack_token = "" + token_is_user = False + for secret_name, is_user in [("SLACK_USER_TOKEN", True), ("SLACK_BOT_TOKEN", False)]: + try: + val = get_secret(secret_name) + if val: + slack_token = val + token_is_user = is_user + print(f"Using {secret_name}") + break + except Exception: + pass + + if not slack_token: + raise RuntimeError( + "No Slack token found. Set SLACK_BOT_TOKEN or SLACK_USER_TOKEN in " + "OpenHands Settings → Secrets." + ) + + # ── Resolve OpenHands base URL for conversation links ───────────────────── + try: + openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL + except Exception: + openhands_url = DEFAULT_OPENHANDS_URL + + # ── Cache bot user ID (to skip self-messages) ────────────────────────────── + if not state.get("bot_user_id"): + try: + state["bot_user_id"] = get_bot_user_id(slack_token) + print(f"Bot user ID: {state['bot_user_id']}") + except Exception as exc: + print(f"Warning: could not resolve bot user ID: {exc}") + + 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()) + + # ── Determine polling strategy ───────────────────────────────────────────── + use_search = ( + token_is_user + and len(CHANNEL_IDS) > 1 + and has_search_permission(slack_token) + ) + print(f"Polling strategy: {'search.messages' if use_search else 'conversations.history'}") + + # ── Collect earliest last_poll across all channels (for search) ─────────── + 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()) + + # ── Poll for new top-level / trigger messages ────────────────────────────── + # Messages are (channel_id, message_dict) + 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}") + + # ── Poll for new replies in active threads ───────────────────────────────── + active_convs: dict[str, dict] = state.get("conversations", {}) + 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}") + + # ── Update last_poll to now ──────────────────────────────────────────────── + for cid in CHANNEL_IDS: + state["last_poll"][cid] = now_ts + + # ── Process new messages (sorted chronologically) ───────────────────────── + all_incoming = sorted( + new_messages + reply_messages, + key=lambda x: float(x[1].get("ts", "0")), + ) + + 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: + add_reaction(slack_token, channel_id, msg_ts) + continue + + # ── Case B: message contains trigger phrase → create a new conversation ─ + if has_trigger: + print(f" Trigger detected in {channel_id} at {msg_ts}: {text[:80]}") + add_reaction(slack_token, channel_id, msg_ts) + + # Gather recent channel context for the agent + context_lines: list[str] = [] + try: + ctx_msgs = channel_history(slack_token, channel_id, + str(float(msg_ts) - 3600), CONTEXT_MESSAGE_LIMIT) + for cm in reversed(ctx_msgs): + if _is_human_message(cm, bot_user_id, bot_message_ts): + context_lines.append(f"[{cm.get('user','?')}]: {cm.get('text','')}") + except Exception: + pass # context is best-effort + + 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}" + + # Store the conversation + active_convs[conv_key] = { + "conversation_id": conv_id, + "channel_id": channel_id, + "thread_ts": thread_root, + "status": "active", + "last_activity": time.time(), + } + + # Post conversation link back to the Slack thread + 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}") + + # ── Check active conversations for completion ────────────────────────────── + for conv_key, rec in list(active_convs.items()): + if rec.get("status") == "closed": + continue + + # Debounce: don't check in the same run that triggered the last activity + last_activity: float = rec.get("last_activity", 0.0) + if (time.time() - last_activity) < DONE_DEBOUNCE: + continue + + 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}") + continue + + print(f" {conv_key} → status={status}") + + # Terminal or idle (agent waiting for input after finishing its turn) + 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}") + + # ── Housekeeping ─────────────────────────────────────────────────────────── + # Trim bot message timestamp list + 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) From 86c15dc9cf5801968a8b676255e7fe51c0d3fe1c Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 22 May 2026 07:15:33 -0600 Subject: [PATCH 2/8] fix: add missing README.md to slack-channel-monitor skill Required by tests/test_skills_have_readme.py. Co-authored-by: openhands --- skills/slack-channel-monitor/README.md | 91 ++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 skills/slack-channel-monitor/README.md 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 From c3598e398106995b476e565eb20ba1da78be90f3 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 22 May 2026 09:18:10 -0600 Subject: [PATCH 3/8] fix: scope checks, reactions guard, and missing workspace field - Replace get_bot_user_id with _slack_auth_test which reads the X-OAuth-Scopes response header from auth.test. Fail fast with a clear RuntimeError if the token lacks channels:history (or equivalent) or chat:write - no point running the poll loop without read/write access. - Set can_react from the resolved scopes and guard both add_reaction call sites behind it, so a missing reactions:write scope causes a one-time note rather than a warning on every trigger message. - Add workspace field to create_conversation (POST /api/conversations requires it); use WORKSPACE_BASE env var, defaulting to /workspace. Co-authored-by: openhands --- skills/slack-channel-monitor/scripts/main.py | 64 ++++++++++++++++---- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/skills/slack-channel-monitor/scripts/main.py b/skills/slack-channel-monitor/scripts/main.py index 26d225f7..14411418 100644 --- a/skills/slack-channel-monitor/scripts/main.py +++ b/skills/slack-channel-monitor/scripts/main.py @@ -177,8 +177,27 @@ def slack_post(token: str, endpoint: str, body: dict) -> dict: return _slack_call(token, "POST", endpoint, body=body) -def get_bot_user_id(token: str) -> str: - return slack_get(token, "auth.test").get("user_id", "") +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: @@ -274,7 +293,9 @@ def _oh_request( def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str: """Create a conversation, start it running, and return its ID.""" + workspace_dir = os.environ.get("WORKSPACE_BASE", "/workspace") result = _oh_request(agent_url, api_key, "POST", "/api/conversations", { + "workspace": {"working_dir": workspace_dir}, "initial_message": {"content": [{"text": initial_message}]}, }) conv_id = result["id"] @@ -353,13 +374,33 @@ def main() -> None: # noqa: C901 (complexity is inherent here) except Exception: openhands_url = DEFAULT_OPENHANDS_URL - # ── Cache bot user ID (to skip self-messages) ────────────────────────────── - if not state.get("bot_user_id"): - try: - state["bot_user_id"] = get_bot_user_id(slack_token) - print(f"Bot user ID: {state['bot_user_id']}") - except Exception as exc: - print(f"Warning: could not resolve bot user ID: {exc}") + # ── Verify token and check required scopes (fail fast if insufficient) ──────── + # 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}") + + if scopes: + # Scopes the token must have to do anything useful + 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") + else: + # X-OAuth-Scopes header absent (unusual); proceed and let the API + # return errors at the point of use rather than blocking everything. + can_react = True bot_user_id: str = state.get("bot_user_id") or "" bot_message_ts: list[str] = state.get("bot_message_ts", []) @@ -468,14 +509,15 @@ def main() -> None: # noqa: C901 (complexity is inherent here) rec["last_activity"] = time.time() except Exception as exc: print(f" Warning: failed to forward reply: {exc}") - if has_trigger: + 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: print(f" Trigger detected in {channel_id} at {msg_ts}: {text[:80]}") - add_reaction(slack_token, channel_id, msg_ts) + if can_react: + add_reaction(slack_token, channel_id, msg_ts) # Gather recent channel context for the agent context_lines: list[str] = [] From 916705701984e7694e2cbab52276f3a79ff582e5 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 22 May 2026 09:21:13 -0600 Subject: [PATCH 4/8] fix: add .plugin/plugin.json manifest and vendor symlinks Required by tests/test_skill_plugin_loading.py: - .plugin/plugin.json (Codex and Claude Code manifest) - .claude-plugin -> .plugin (symlink) - .codex-plugin -> .plugin (symlink) Co-authored-by: openhands --- skills/slack-channel-monitor/.claude-plugin | 1 + skills/slack-channel-monitor/.codex-plugin | 1 + .../slack-channel-monitor/.plugin/plugin.json | 21 +++++++++++++++++++ 3 files changed, 23 insertions(+) create mode 120000 skills/slack-channel-monitor/.claude-plugin create mode 120000 skills/slack-channel-monitor/.codex-plugin create mode 100644 skills/slack-channel-monitor/.plugin/plugin.json 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" + ] +} From b43cd53baeb94decc4e3e9018ad455b1967b70f6 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 22 May 2026 09:42:32 -0600 Subject: [PATCH 5/8] fix: fetch agent_settings from server before creating a conversation POST /api/conversations requires either 'agent' or 'agent_settings'. Add _get_agent_settings() which calls GET /api/settings and extracts the configured agent_settings block (LLM model, agent kind, etc.), then pass it through in create_conversation so the new conversation inherits the server's LLM configuration rather than failing validation. Co-authored-by: openhands --- skills/slack-channel-monitor/scripts/main.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/skills/slack-channel-monitor/scripts/main.py b/skills/slack-channel-monitor/scripts/main.py index 14411418..74a7a5af 100644 --- a/skills/slack-channel-monitor/scripts/main.py +++ b/skills/slack-channel-monitor/scripts/main.py @@ -291,11 +291,19 @@ def _oh_request( raise RuntimeError(f"Agent API {method} {path} → {exc.code}: {body_text}") from exc +def _get_agent_settings(agent_url: str, api_key: str) -> dict: + """Fetch the server's configured agent_settings for use in new conversations.""" + result = _oh_request(agent_url, api_key, "GET", "/api/settings") + return result.get("agent_settings", {}) + + def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str: """Create a conversation, start it running, and return its ID.""" workspace_dir = os.environ.get("WORKSPACE_BASE", "/workspace") + agent_settings = _get_agent_settings(agent_url, api_key) result = _oh_request(agent_url, api_key, "POST", "/api/conversations", { "workspace": {"working_dir": workspace_dir}, + "agent_settings": agent_settings, "initial_message": {"content": [{"text": initial_message}]}, }) conv_id = result["id"] From 90135056a4138381b35bd19c7a5f72765c7e4c37 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 22 May 2026 10:04:51 -0600 Subject: [PATCH 6/8] fix: use agent field + plaintext secrets to avoid LLM registry conflict The agent_settings code path has a double-registration bug: Pydantic calls create_agent() during StartConversationRequest validation to populate the agent field, then StoredConversation construction runs the same initialisation again - both attempts try to register the LLM with usage_id='default' in the per-conversation registry, and the second call raises ValueError('Usage ID already exists in registry'). Fix by switching to the same approach the SDK uses: - Fetch GET /api/settings with X-Expose-Secrets: plaintext to get the real (unmasked) LLM api_key - Build an 'agent' dict directly {kind: Agent, llm: } - Pass it as the 'agent' field (not 'agent_settings') so Pydantic validates the dict via AgentBase.model_validate() without triggering create_agent(), and StoredConversation construction only registers the LLM once Also drops secrets_encrypted: True (no longer needed) and removes the now-unused uuid import. Co-authored-by: openhands --- skills/slack-channel-monitor/scripts/main.py | 28 +++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/skills/slack-channel-monitor/scripts/main.py b/skills/slack-channel-monitor/scripts/main.py index 74a7a5af..e7d5b528 100644 --- a/skills/slack-channel-monitor/scripts/main.py +++ b/skills/slack-channel-monitor/scripts/main.py @@ -291,19 +291,35 @@ def _oh_request( raise RuntimeError(f"Agent API {method} {path} → {exc.code}: {body_text}") from exc -def _get_agent_settings(agent_url: str, api_key: str) -> dict: - """Fetch the server's configured agent_settings for use in new conversations.""" - result = _oh_request(agent_url, api_key, "GET", "/api/settings") - return result.get("agent_settings", {}) +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, start it running, and return its ID.""" workspace_dir = os.environ.get("WORKSPACE_BASE", "/workspace") - agent_settings = _get_agent_settings(agent_url, api_key) + agent = _get_agent_dict(agent_url, api_key) result = _oh_request(agent_url, api_key, "POST", "/api/conversations", { "workspace": {"working_dir": workspace_dir}, - "agent_settings": agent_settings, + "agent": agent, "initial_message": {"content": [{"text": initial_message}]}, }) conv_id = result["id"] From 930b082fce3c5f0388e2e00b247b162466870d87 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 22 May 2026 10:39:44 -0600 Subject: [PATCH 7/8] fix: remove redundant /run call after POST /api/conversations When initial_message is provided, conversation_service.py calls send_message(message, run=True) which starts the agent immediately. Our subsequent POST to /api/conversations/{id}/run therefore always returned 409 'Conversation already running', which propagated as an exception out of create_conversation before conv_id was returned. The knock-on effects were: - active_convs was never updated, so the conversation was never tracked - The 'On it!' Slack reply and all follow-up (completion detection, posting the verdict) were silently skipped Fix: drop the /run call entirely - the server handles it. Co-authored-by: openhands --- skills/slack-channel-monitor/scripts/main.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/skills/slack-channel-monitor/scripts/main.py b/skills/slack-channel-monitor/scripts/main.py index e7d5b528..77bad4cf 100644 --- a/skills/slack-channel-monitor/scripts/main.py +++ b/skills/slack-channel-monitor/scripts/main.py @@ -314,7 +314,12 @@ def _get_agent_dict(agent_url: str, api_key: str) -> dict: def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str: - """Create a conversation, start it running, and return its ID.""" + """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", { @@ -322,9 +327,7 @@ def create_conversation(agent_url: str, api_key: str, initial_message: str) -> s "agent": agent, "initial_message": {"content": [{"text": initial_message}]}, }) - conv_id = result["id"] - _oh_request(agent_url, api_key, "POST", f"/api/conversations/{conv_id}/run") - return conv_id + return result["id"] def send_to_conversation(agent_url: str, api_key: str, conv_id: str, text: str) -> None: From 74a0be3bbfe7ae6718e36edf4ec80c144cb5fc0d Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 22 May 2026 11:15:25 -0600 Subject: [PATCH 8/8] refactor(slack-channel-monitor): address review bot feedback - Improve constant comments (INITIAL_LOOKBACK, DONE_DEBOUNCE, MAX_BOT_TS, CONTEXT_MESSAGE_LIMIT) with fuller explanations of intent - Add CONTEXT_LOOKBACK_SECONDS constant to name the magic 3600 value - Change has_search_permission to accept scopes set[str] instead of making a redundant API call (scopes already available from auth.test) - Extract _gather_channel_context() helper to eliminate 5-level nesting - Break main() (284 lines) into focused helper functions: _resolve_slack_token(), _verify_token_scopes(), _poll_new_messages(), _process_trigger_message(), _check_conversation_completion() Co-authored-by: openhands --- skills/slack-channel-monitor/scripts/main.py | 432 +++++++++++-------- 1 file changed, 241 insertions(+), 191 deletions(-) diff --git a/skills/slack-channel-monitor/scripts/main.py b/skills/slack-channel-monitor/scripts/main.py index 77bad4cf..b0603a47 100644 --- a/skills/slack-channel-monitor/scripts/main.py +++ b/skills/slack-channel-monitor/scripts/main.py @@ -44,20 +44,24 @@ CHANNEL_IDS: list[str] = [] # e.g. ["C0123456789", "C9876543210"] DEFAULT_OPENHANDS_URL = "http://localhost:8000" -# How far back (seconds) to look when there is no previous poll timestamp. -# Slightly over 60 s to avoid missing messages at cron boundaries. +# Lookback slightly over 60s to avoid missing messages at cron boundaries +# when poll interval jitter causes slight delays. INITIAL_LOOKBACK = 70 -# Minimum seconds since last activity before treating a conversation as done. -# Guards against posting a summary in the same run that created the conversation. +# Prevent posting summaries in the same run that created the conversation, +# avoiding race conditions with conversation startup. DONE_DEBOUNCE = 15 -# Maximum bot message timestamps to keep in state (rolling window). +# Rolling window size for bot message deduplication - sized to handle +# ~1 week of continuous operation at high message rates. MAX_BOT_TS = 2000 -# Maximum context messages to include when creating a new conversation. +# 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 ───────────────────────────────────────────────────────────── @@ -265,12 +269,8 @@ def search_trigger_messages( return [m for m in matches if float(m.get("ts", "0")) > float(oldest_ts)] -def has_search_permission(token: str) -> bool: - try: - slack_get(token, "search.messages", {"query": "test", "count": 1}) - return True - except RuntimeError as exc: - return "missing_scope" not in str(exc) +def has_search_permission(scopes: set[str]) -> bool: + return "search:read" in scopes # ── OpenHands Agent Server helpers ──────────────────────────────────────────── @@ -366,90 +366,82 @@ def _is_human_message(msg: dict, bot_user_id: str, bot_message_ts: list[str]) -> return True -# ── Main ─────────────────────────────────────────────────────────────────────── - -def main() -> None: # noqa: C901 (complexity is inherent here) - state_path = _state_file_path() - state = load_state(state_path) - - agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/") - api_key = _get_env_key() +# ── Polling helpers ──────────────────────────────────────────────────────────── - # ── Resolve Slack token ──────────────────────────────────────────────────── - slack_token = "" - token_is_user = False +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: - slack_token = val - token_is_user = is_user print(f"Using {secret_name}") - break + 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." + ) + - if not slack_token: +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( - "No Slack token found. Set SLACK_BOT_TOKEN or SLACK_USER_TOKEN in " - "OpenHands Settings → Secrets." + "Slack token is missing a read scope. " + f"Required: one of {sorted(read_scopes)}. " + f"Token has: {sorted(scopes)}" ) - - # ── Resolve OpenHands base URL for conversation links ───────────────────── + 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: - openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL + 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: - openhands_url = DEFAULT_OPENHANDS_URL - - # ── Verify token and check required scopes (fail fast if insufficient) ──────── - # 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}") - - if scopes: - # Scopes the token must have to do anything useful - 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") - else: - # X-OAuth-Scopes header absent (unusual); proceed and let the API - # return errors at the point of use rather than blocking everything. - can_react = True - - 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()) - - # ── Determine polling strategy ───────────────────────────────────────────── - use_search = ( - token_is_user - and len(CHANNEL_IDS) > 1 - and has_search_permission(slack_token) - ) - print(f"Polling strategy: {'search.messages' if use_search else 'conversations.history'}") - - # ── Collect earliest last_poll across all channels (for search) ─────────── - 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()) - - # ── Poll for new top-level / trigger messages ────────────────────────────── - # Messages are (channel_id, message_dict) + 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: @@ -477,11 +469,8 @@ def main() -> None: # noqa: C901 (complexity is inherent here) except Exception as exc: print(f" Warning: could not fetch history for {cid}: {exc}") - # ── Poll for new replies in active threads ───────────────────────────────── - active_convs: dict[str, dict] = state.get("conversations", {}) reply_messages: list[tuple[str, dict]] = [] - - for conv_key, rec in active_convs.items(): + for _conv_key, rec in active_convs.items(): if rec.get("status") == "closed": continue cid = rec["channel_id"] @@ -494,16 +483,167 @@ def main() -> None: # noqa: C901 (complexity is inherent here) except Exception as exc: print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}") - # ── Update last_poll to now ──────────────────────────────────────────────── - for cid in CHANNEL_IDS: - state["last_poll"][cid] = now_ts - - # ── Process new messages (sorted chronologically) ───────────────────────── - all_incoming = sorted( + 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 @@ -542,108 +682,18 @@ def main() -> None: # noqa: C901 (complexity is inherent here) # ── Case B: message contains trigger phrase → create a new conversation ─ if has_trigger: - print(f" Trigger detected in {channel_id} at {msg_ts}: {text[:80]}") - if can_react: - add_reaction(slack_token, channel_id, msg_ts) - - # Gather recent channel context for the agent - context_lines: list[str] = [] - try: - ctx_msgs = channel_history(slack_token, channel_id, - str(float(msg_ts) - 3600), CONTEXT_MESSAGE_LIMIT) - for cm in reversed(ctx_msgs): - if _is_human_message(cm, bot_user_id, bot_message_ts): - context_lines.append(f"[{cm.get('user','?')}]: {cm.get('text','')}") - except Exception: - pass # context is best-effort - - 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." + _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, ) - try: - conv_id = create_conversation(agent_url, api_key, initial_prompt) - conv_url = f"{openhands_url}/conversations/{conv_id}" - - # Store the conversation - active_convs[conv_key] = { - "conversation_id": conv_id, - "channel_id": channel_id, - "thread_ts": thread_root, - "status": "active", - "last_activity": time.time(), - } - - # Post conversation link back to the Slack thread - 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}") - - # ── Check active conversations for completion ────────────────────────────── for conv_key, rec in list(active_convs.items()): - if rec.get("status") == "closed": - continue - - # Debounce: don't check in the same run that triggered the last activity - last_activity: float = rec.get("last_activity", 0.0) - if (time.time() - last_activity) < DONE_DEBOUNCE: - continue - - 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}") - continue - - print(f" {conv_key} → status={status}") + if rec.get("status") != "closed": + _check_conversation_completion( + conv_key, rec, agent_url, api_key, slack_token, bot_message_ts, + ) - # Terminal or idle (agent waiting for input after finishing its turn) - 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}") - - # ── Housekeeping ─────────────────────────────────────────────────────────── - # Trim bot message timestamp list if len(bot_message_ts) > MAX_BOT_TS: state["bot_message_ts"] = bot_message_ts[-MAX_BOT_TS:] else: