@openhands/extensions 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/automations/catalog/slack-channel-monitor.json +2 -2
- package/integrations/catalog/github.json +8 -24
- package/integrations/index.d.ts +2 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/index.js +1 -1
- package/skills/slack-channel-monitor/README.md +8 -4
- package/skills/slack-channel-monitor/SKILL.md +15 -6
- package/skills/slack-channel-monitor/references/slack-api.md +5 -3
- package/skills/slack-channel-monitor/references/state-schema.md +59 -32
- package/skills/slack-channel-monitor/scripts/main.py +242 -40
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"id": "slack-channel-monitor",
|
|
3
3
|
"name": "Slack channel monitor",
|
|
4
4
|
"category": "Team communication",
|
|
5
|
-
"description": "Watch Slack channels for @openhands mentions, open a conversation with the message context,
|
|
5
|
+
"description": "Watch Slack channels for @openhands mentions, open a conversation with the message context, reply when the agent finishes, and continue the same conversation from triggered Slack thread follow-ups.",
|
|
6
6
|
"requiredIntegrationIds": ["slack"],
|
|
7
7
|
"popularityRank": 92,
|
|
8
8
|
"estimatedSetupMinutes": 7,
|
|
9
9
|
"prompt": "/slack-monitor:poll",
|
|
10
|
-
"exampleImplementation": "Trigger: cron, every minute\nRequired secret: SLACK_BOT_TOKEN or SLACK_USER_TOKEN\n\n1. Poll up to 10 configured Slack channels for new messages since the last run.\n2. Match messages containing the trigger phrase (default: @openhands).\n3. React with 👀 and start an OpenHands conversation with the message and recent channel context.\n4. Post a thread reply with a link to the conversation.\n5.
|
|
10
|
+
"exampleImplementation": "Trigger: cron, every minute\nRequired secret: SLACK_BOT_TOKEN or SLACK_USER_TOKEN\n\n1. Poll up to 10 configured Slack channels for new messages since the last run.\n2. Match messages containing the trigger phrase (default: @openhands).\n3. React with 👀 and start an OpenHands conversation with the message and recent channel context.\n4. Post a thread reply with a link to the conversation, then post the agent response when done.\n5. Keep the thread in a short follow-up watch window and forward only replies that repeat the trigger phrase to the same conversation, with per-thread backoff for quiet threads."
|
|
11
11
|
}
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"code"
|
|
13
13
|
],
|
|
14
14
|
"popularityRank": 100,
|
|
15
|
-
"installHint": "Requires Docker because the legacy npm package was deprecated upstream.",
|
|
16
15
|
"kind": "mcp",
|
|
17
16
|
"defaultConnectionOptionId": "api",
|
|
18
17
|
"connectionOptions": [
|
|
@@ -20,31 +19,16 @@
|
|
|
20
19
|
"id": "api",
|
|
21
20
|
"provider": "mcp",
|
|
22
21
|
"transport": {
|
|
23
|
-
"kind": "
|
|
24
|
-
"
|
|
25
|
-
"command": "docker",
|
|
26
|
-
"args": [
|
|
27
|
-
"run",
|
|
28
|
-
"-i",
|
|
29
|
-
"--rm",
|
|
30
|
-
"-e",
|
|
31
|
-
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
32
|
-
"ghcr.io/github/github-mcp-server"
|
|
33
|
-
],
|
|
34
|
-
"envFields": [
|
|
35
|
-
{
|
|
36
|
-
"key": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
37
|
-
"label": "Personal access token",
|
|
38
|
-
"type": "password",
|
|
39
|
-
"placeholder": "github_pat_...",
|
|
40
|
-
"required": true,
|
|
41
|
-
"helperText": "Classic or fine-grained personal access token from GitHub settings.",
|
|
42
|
-
"helperLink": "https://github.com/settings/tokens"
|
|
43
|
-
}
|
|
44
|
-
]
|
|
22
|
+
"kind": "shttp",
|
|
23
|
+
"url": "https://api.githubcopilot.com/mcp/"
|
|
45
24
|
},
|
|
46
25
|
"auth": {
|
|
47
|
-
"strategy": "api_key"
|
|
26
|
+
"strategy": "api_key",
|
|
27
|
+
"credentialLabel": "Personal access token",
|
|
28
|
+
"credentialPlaceholder": "github_pat_...",
|
|
29
|
+
"credentialHelp": "Classic or fine-grained personal access token from GitHub settings.",
|
|
30
|
+
"credentialSecretName": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
31
|
+
"saveCredentialAsSecretByDefault": true
|
|
48
32
|
}
|
|
49
33
|
}
|
|
50
34
|
]
|
package/integrations/index.d.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface IntegrationAuthConfig {
|
|
|
59
59
|
credentialLabel?: string;
|
|
60
60
|
credentialPlaceholder?: string;
|
|
61
61
|
credentialHelp?: string;
|
|
62
|
+
credentialSecretName?: string;
|
|
63
|
+
saveCredentialAsSecretByDefault?: boolean;
|
|
62
64
|
apiKeyHeaderName?: string;
|
|
63
65
|
apiKeyOptional?: boolean;
|
|
64
66
|
oauth?: IntegrationOAuthConfig;
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/skills/index.js
CHANGED
|
@@ -398,7 +398,7 @@ export const SKILLS_CATALOG = [
|
|
|
398
398
|
"triggers": [
|
|
399
399
|
"/slack-monitor:poll"
|
|
400
400
|
],
|
|
401
|
-
"content": "# Slack Channel Monitor\n\nCreate a cron automation that polls up to 10 Slack channels every minute.\nWhen a message containing the **trigger phrase** (default: `@openhands`) is\ndetected it:\n\n1. Adds a 👀 reaction to the triggering message.\n2. Opens an OpenHands conversation with the message and recent channel context.\n3. Posts a reply in the Slack thread with a link to the conversation.\n\nOn every subsequent run:\n- Replies in the thread are forwarded to the running conversation.\n- When the conversation finishes (or errors), the agent's final response is\n posted back to the Slack thread.\n\n> **Local mode only.** This automation targets the local OpenHands setup\n> (`dev:automation` stack). A cloud/webhook-based variant is out of scope here.\n\n---\n\n## Prerequisites\n\n### Required secrets\n\nVerify that at least one of the following secrets is set in\n**OpenHands Settings → Secrets** before proceeding:\n\n| Secret name | Token type | Minimum scopes |\n|---|---|---|\n| `SLACK_BOT_TOKEN` | Bot (`xoxb-…`) | `channels:history`, `channels:read`, `reactions:write`, `chat:write` |\n| `SLACK_USER_TOKEN` | User (`xoxp-…`) | Same as bot, plus `search:read` for multi-channel efficiency |\n\nCheck with:\n```bash\n# For bot token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_BOT_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n\n# For user token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_USER_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n```\n\nIf neither token is present, inform the user and stop - the automation cannot\nfunction without Slack credentials.\n\n### Optional secret\n\n| Secret name | Default | Purpose |\n|---|---|---|\n| `OPENHANDS_URL` | `http://localhost:8000` | Base URL used to build conversation links posted in Slack |\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Collect channels\n\nAsk the user: *\"Which Slack channels should be monitored? You can provide\nchannel names (e.g. `#general`) or IDs (e.g. `C0123456789`).\"*\n\n**If the user provides channel names**, resolve them to IDs:\n\n```bash\nSLACK_TOKEN=\"${SLACK_BOT_TOKEN:-$SLACK_USER_TOKEN}\"\ncurl -s \"https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200&exclude_archived=true\" \\\n -H \"Authorization: Bearer $SLACK_TOKEN\" \\\n | python3 -c \"\nimport json, sys\ndata = json.load(sys.stdin)\nif not data.get('ok'):\n print('ERROR:', data.get('error'))\n exit(1)\nnames = set(n.lstrip('#') for n in ['CHANNEL_NAMES_HERE'.split(',')])\nfor ch in data.get('channels', []):\n if ch['name'] in names:\n print(f\\\"{ch['name']} → {ch['id']}\\\")\n\"\n```\n\nReplace `CHANNEL_NAMES_HERE` with the comma-separated names the user provided.\n\n**If `conversations.list` returns `missing_scope` or `not_authed`:**\nInform the user: *\"The token doesn't have permission to list channels. Please\nprovide the channel IDs directly (right-click a channel in Slack → Copy link - \nthe last path segment starting with `C` is the ID).\"*\n\n**If the bot token lacks `channels:read`** for private channels, the user can\neither invite the bot first (`/invite @botname`) or switch to a user token.\n\nCollect up to 10 channel IDs. Record them as a Python list literal, e.g.:\n```python\n[\"C0123456789\", \"C9876543210\"]\n```\n\n### Step 2 - Collect trigger phrase\n\nAsk the user: *\"What trigger phrase should OpenHands respond to?\n(Press Enter to use the default: `@openhands`)\"*\n\nAccepted values: any non-empty string unlikely to appear accidentally, e.g.\n`@openhands`, `jazz hands`, `take-me-to-funky-town`.\n\n### Step 3 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory and **copy it verbatim**.\nApply exactly three constant substitutions near the top of the file:\n\n> **Do not reimplement, simplify, or hand-write a replacement script.**\n> The template already contains the correct secret-loading, state-path,\n> conversation-creation, and context-forwarding logic. Only the three\n> configuration constants below should change unless syntax validation fails.\n\n| Placeholder | Replace with |\n|---|---|\n| `TRIGGER_PHRASE = \"@openhands\"` | `TRIGGER_PHRASE = \"{user_phrase}\"` |\n| `CHANNEL_IDS: list[str] = []` | `CHANNEL_IDS: list[str] = {channel_id_list}` |\n| `DEFAULT_OPENHANDS_URL = \"http://localhost:8000\"` | `DEFAULT_OPENHANDS_URL = \"{url}\"` (keep default if user has no preference) |\n\nWrite the customised script to a temporary directory:\n```bash\nmkdir -p /tmp/slack-monitor-build\n# copy scripts/main.py to /tmp/slack-monitor-build/main.py\n# then replace only the three constants above\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/slack-monitor-build/main.py && echo \"Syntax OK\"\n```\n\nThen run a quick integrity check to confirm the template structure is still\npresent and only the configuration block was customised:\n```bash\ngrep -n 'TRIGGER_PHRASE = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'CHANNEL_IDS: list\\[str\\] =' /tmp/slack-monitor-build/main.py\ngrep -n 'DEFAULT_OPENHANDS_URL = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'def get_secret' /tmp/slack-monitor-build/main.py\ngrep -n 'def _state_file_path' /tmp/slack-monitor-build/main.py\ngrep -n 'def create_conversation' /tmp/slack-monitor-build/main.py\n```\n\nIf any of those checks fail, stop and re-copy the template instead of trying to\nrepair a hand-written variant.\n\n### Step 4 - Package and upload\n\nDetermine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`\nblock in your system context:\n- Use the **Automation backend** `url_from_agent` as `OPENHANDS_HOST`\n- Auth: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`\n\nIf no Automation backend is listed in `<RUNTIME_SERVICES>`, stop and tell\nthe user to start the full automation stack.\n\n```bash\ntar -czf /tmp/slack-monitor.tar.gz -C /tmp/slack-monitor-build .\n\n# OPENHANDS_HOST: read from <RUNTIME_SERVICES> Automation backend url_from_agent\nOPENHANDS_HOST=\"<automation-url-from-runtime-services>\"\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=slack-channel-monitor\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/slack-monitor.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\nIf the upload fails with a size error, the tarball must be under 1 MB.\n`main.py` is under 15 KB so this should never trigger.\n\n### Step 5 - Create the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"Slack Channel Monitor\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"* * * * *\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 55\n }\" | python3 -m json.tool\n```\n\nA 55-second timeout keeps runs well within the 60-second cron window.\n\nRecord the returned `id` - share it with the user as confirmation.\n\n### Step 6 - Confirm\n\nTell the user:\n\n> ✅ **Slack Channel Monitor** is running!\n>\n> - Automation ID: `{id}`\n> - Channels: `{channel list}`\n> - Trigger phrase: `{phrase}`\n> - Polling every minute via cron `* * * * *`\n> - State file: `~/.openhands/workspaces/automation-state/slack_poller_{id}.json`\n>\n> Send a message containing `{phrase}` in any monitored channel to test it.\n> The bot will react with 👀 and reply with a link to the new conversation.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which runs **10 polling iterations** (every\n5 seconds) within the 55-second timeout window. Each iteration:\n\n1. **Loads state** from the JSON file (see `references/state-schema.md`).\n2. **Resolves the Slack token** - checks `SLACK_USER_TOKEN` then `SLACK_BOT_TOKEN`.\n3. **Fetches new messages:**\n - User token + `search:read` + > 1 channel → single `search.messages` call\n (searches for the trigger phrase across all channels).\n - Otherwise → one `conversations.history` call per channel.\n4. **Fetches thread replies** - one `conversations.replies` call per tracked thread.\n5. **Processes messages** in chronological order:\n - Skips messages already in `processed_ts` (dedup across the overlap window).\n - Skips bot messages and any `ts` in `bot_message_ts`.\n - Reply in a tracked thread (active **or** closed) → forwards to the existing\n conversation; re-opens closed conversations automatically.\n - Contains trigger phrase → 👀 reaction, create conversation, post link.\n - Thread replies: agent receives full thread history for context.\n - Root messages: agent receives the trigger text only.\n6. **Checks conversation statuses** - for each active conversation where\n `time.time() - last_activity > 15 s`:\n - If status is `idle`, `finished`, `error`, or `stuck` → fetch the agent's\n final response via `/api/conversations/{id}/agent_final_response` and post\n it to the Slack thread. Mark conversation `closed`.\n7. **Advances `last_poll`** to `now - 10 s` (overlap window prevents boundary\n races). If a conversation creation failed, pins `last_poll` further back to\n retry on the next iteration.\n8. **Saves state** (including `processed_ts`) and continues to the next iteration.\n9. After all iterations, fires the completion callback.\n\nDebug output is written to both stdout and a persistent log at:\n```\n{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log\n```\n\n---\n\n## Additional Resources\n\n### Reference Files\n\n- **`references/slack-api.md`** - Slack token types, required scopes, API\n endpoint reference, rate limits, and common error codes.\n- **`references/state-schema.md`** - State JSON schema, field definitions,\n example file, and conversation lifecycle diagram.\n\n### Script Template\n\n- **`scripts/main.py`** - The complete automation script. Customise the three\n constants at the top (`TRIGGER_PHRASE`, `CHANNEL_IDS`, `DEFAULT_OPENHANDS_URL`)\n before packaging.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Bot doesn't react to messages | Token missing or bot not in channel | Verify token with `auth.test`; `/invite @botname` |\n| `not_in_channel` error in run logs | Bot token used but bot not a member | Invite bot or switch to user token |\n| `missing_scope` error | Token lacks required scopes | Re-install Slack app with correct scopes (see `references/slack-api.md`) |\n| No messages detected | `last_poll` timestamp is in the future | Delete the state file to reset; it will be recreated on next run |\n| Conversation link 404 | `OPENHANDS_URL` points to wrong host | Set the `OPENHANDS_URL` secret to the correct base URL |\n| Summary never posted | Conversation stuck in `running` state | Check conversation in the OpenHands UI; the agent may need intervention |\n| Duplicate conversations created | `processed_ts` state missing or corrupted | Delete the state file to reset; dedup will rebuild on next run |\n| Trigger message processed on each cron run | State file deleted between runs | Ensure `automation-state/` directory is persistent across runs |\n| Debug info needed | Need detailed per-message trace | Check `{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log` |"
|
|
401
|
+
"content": "# Slack Channel Monitor\n\nCreate a cron automation that polls up to 10 Slack channels every minute.\nWhen a message containing the **trigger phrase** (default: `@openhands`) is\ndetected it:\n\n1. Adds a 👀 reaction to the triggering message.\n2. Opens an OpenHands conversation with the message and recent channel context.\n3. Posts a reply in the Slack thread with a link to the conversation.\n\nOn every subsequent run:\n- New Slack thread replies are forwarded only when they contain the trigger\n phrase, so unrelated conversation in the thread is ignored.\n- When the conversation finishes (or errors), the agent's final response is\n posted back to the Slack thread.\n- Completed conversations stay in a short follow-up watch window, allowing\n triggered Slack replies to continue the same OpenHands conversation.\n\n> **Local mode only.** This automation targets the local OpenHands setup\n> (`dev:automation` stack). A cloud/webhook-based variant is out of scope here.\n\n---\n\n## Prerequisites\n\n### Required secrets\n\nVerify that at least one of the following secrets is set in\n**OpenHands Settings → Secrets** before proceeding:\n\n| Secret name | Token type | Minimum scopes |\n|---|---|---|\n| `SLACK_BOT_TOKEN` | Bot (`xoxb-…`) | `channels:history`, `channels:read`, `reactions:write`, `chat:write` |\n| `SLACK_USER_TOKEN` | User (`xoxp-…`) | Same as bot, plus `search:read` for multi-channel efficiency |\n\nCheck with:\n```bash\n# For bot token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_BOT_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n\n# For user token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_USER_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n```\n\nIf neither token is present, inform the user and stop - the automation cannot\nfunction without Slack credentials.\n\n### Optional secret\n\n| Secret name | Default | Purpose |\n|---|---|---|\n| `OPENHANDS_URL` | `http://localhost:8000` | Base URL used to build conversation links posted in Slack |\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Collect channels\n\nAsk the user: *\"Which Slack channels should be monitored? You can provide\nchannel names (e.g. `#general`) or IDs (e.g. `C0123456789`).\"*\n\n**If the user provides channel names**, resolve them to IDs:\n\n```bash\nSLACK_TOKEN=\"${SLACK_BOT_TOKEN:-$SLACK_USER_TOKEN}\"\ncurl -s \"https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200&exclude_archived=true\" \\\n -H \"Authorization: Bearer $SLACK_TOKEN\" \\\n | python3 -c \"\nimport json, sys\ndata = json.load(sys.stdin)\nif not data.get('ok'):\n print('ERROR:', data.get('error'))\n exit(1)\nnames = set(n.lstrip('#') for n in ['CHANNEL_NAMES_HERE'.split(',')])\nfor ch in data.get('channels', []):\n if ch['name'] in names:\n print(f\\\"{ch['name']} → {ch['id']}\\\")\n\"\n```\n\nReplace `CHANNEL_NAMES_HERE` with the comma-separated names the user provided.\n\n**If `conversations.list` returns `missing_scope` or `not_authed`:**\nInform the user: *\"The token doesn't have permission to list channels. Please\nprovide the channel IDs directly (right-click a channel in Slack → Copy link - \nthe last path segment starting with `C` is the ID).\"*\n\n**If the bot token lacks `channels:read`** for private channels, the user can\neither invite the bot first (`/invite @botname`) or switch to a user token.\n\nCollect up to 10 channel IDs. Record them as a Python list literal, e.g.:\n```python\n[\"C0123456789\", \"C9876543210\"]\n```\n\n### Step 2 - Collect trigger phrase\n\nAsk the user: *\"What trigger phrase should OpenHands respond to?\n(Press Enter to use the default: `@openhands`)\"*\n\nAccepted values: any non-empty string unlikely to appear accidentally, e.g.\n`@openhands`, `jazz hands`, `take-me-to-funky-town`.\n\n### Step 3 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory and **copy it verbatim**.\nApply exactly three constant substitutions near the top of the file:\n\n> **Do not reimplement, simplify, or hand-write a replacement script.**\n> The template already contains the correct secret-loading, state-path,\n> conversation-creation, and context-forwarding logic. Only the three\n> configuration constants below should change unless syntax validation fails.\n\n| Placeholder | Replace with |\n|---|---|\n| `TRIGGER_PHRASE = \"@openhands\"` | `TRIGGER_PHRASE = \"{user_phrase}\"` |\n| `CHANNEL_IDS: list[str] = []` | `CHANNEL_IDS: list[str] = {channel_id_list}` |\n| `DEFAULT_OPENHANDS_URL = \"http://localhost:8000\"` | `DEFAULT_OPENHANDS_URL = \"{url}\"` (keep default if user has no preference) |\n\nWrite the customised script to a temporary directory:\n```bash\nmkdir -p /tmp/slack-monitor-build\n# copy scripts/main.py to /tmp/slack-monitor-build/main.py\n# then replace only the three constants above\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/slack-monitor-build/main.py && echo \"Syntax OK\"\n```\n\nThen run a quick integrity check to confirm the template structure is still\npresent and only the configuration block was customised:\n```bash\ngrep -n 'TRIGGER_PHRASE = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'CHANNEL_IDS: list\\[str\\] =' /tmp/slack-monitor-build/main.py\ngrep -n 'DEFAULT_OPENHANDS_URL = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'def get_secret' /tmp/slack-monitor-build/main.py\ngrep -n 'def _state_file_path' /tmp/slack-monitor-build/main.py\ngrep -n 'def create_conversation' /tmp/slack-monitor-build/main.py\n```\n\nIf any of those checks fail, stop and re-copy the template instead of trying to\nrepair a hand-written variant.\n\n### Step 4 - Package and upload\n\nDetermine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`\nblock in your system context:\n- Use the **Automation backend** `url_from_agent` as `OPENHANDS_HOST`\n- Auth: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`\n\nIf no Automation backend is listed in `<RUNTIME_SERVICES>`, stop and tell\nthe user to start the full automation stack.\n\n```bash\ntar -czf /tmp/slack-monitor.tar.gz -C /tmp/slack-monitor-build .\n\n# OPENHANDS_HOST: read from <RUNTIME_SERVICES> Automation backend url_from_agent\nOPENHANDS_HOST=\"<automation-url-from-runtime-services>\"\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=slack-channel-monitor\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/slack-monitor.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\nIf the upload fails with a size error, the tarball must be under 1 MB.\n`main.py` is under 15 KB so this should never trigger.\n\n### Step 5 - Create the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"Slack Channel Monitor\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"* * * * *\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 55\n }\" | python3 -m json.tool\n```\n\nA 55-second timeout keeps runs well within the 60-second cron window.\n\nRecord the returned `id` - share it with the user as confirmation.\n\n### Step 6 - Confirm\n\nTell the user:\n\n> ✅ **Slack Channel Monitor** is running!\n>\n> - Automation ID: `{id}`\n> - Channels: `{channel list}`\n> - Trigger phrase: `{phrase}`\n> - Polling every minute via cron `* * * * *`\n> - State file: `~/.openhands/workspaces/automation-state/slack_poller_{id}.json`\n>\n> Send a message containing `{phrase}` in any monitored channel to test it.\n> The bot will react with 👀 and reply with a link to the new conversation.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which runs **10 polling iterations** (every\n5 seconds) within the 55-second timeout window. Each iteration:\n\n1. **Loads state** from the JSON file (see `references/state-schema.md`).\n2. **Resolves the Slack token** - checks `SLACK_USER_TOKEN` then `SLACK_BOT_TOKEN`.\n3. **Fetches new messages:**\n - User token + `search:read` + > 1 channel → single `search.messages` call\n (searches for the trigger phrase across all channels).\n - Otherwise → one `conversations.history` call per channel.\n4. **Fetches due thread replies** - polls at most one tracked thread per\n iteration using per-thread exponential backoff to stay within Slack rate\n limits.\n5. **Processes messages** in chronological order:\n - Skips messages already in `processed_ts` (dedup across the overlap window).\n - Skips bot messages and any `ts` in `bot_message_ts`.\n - Reply in a tracked thread whose text contains the trigger phrase → forwards\n a follow-up request to the existing conversation and resets the follow-up\n watch window. Replies without the trigger phrase are marked processed and\n ignored.\n - Contains trigger phrase outside a tracked conversation → 👀 reaction, create\n a new conversation, post link.\n - Thread replies: agent receives full thread history for context.\n - Root messages: agent receives the trigger text only.\n6. **Checks conversation statuses** - for each active conversation where\n `time.time() - last_activity > 15 s`:\n - If status is `idle`, `finished`, `error`, or `stuck` → fetch the agent's\n final response via `/api/conversations/{id}/agent_final_response` and post\n it to the Slack thread. Mark the record `watching` for five minutes so\n triggered follow-up replies can continue the same conversation.\n7. **Advances `last_poll`** to `now - 10 s` (overlap window prevents boundary\n races). If a conversation creation failed, pins `last_poll` further back to\n retry on the next iteration.\n8. **Saves state** (including `processed_ts`) and continues to the next iteration.\n9. After all iterations, fires the completion callback.\n\nDebug output is written to both stdout and a persistent log at:\n```\n{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log\n```\n\n---\n\n## Additional Resources\n\n### Reference Files\n\n- **`references/slack-api.md`** - Slack token types, required scopes, API\n endpoint reference, rate limits, and common error codes.\n- **`references/state-schema.md`** - State JSON schema, field definitions,\n example file, and conversation lifecycle diagram.\n\n### Script Template\n\n- **`scripts/main.py`** - The complete automation script. Customise the three\n constants at the top (`TRIGGER_PHRASE`, `CHANNEL_IDS`, `DEFAULT_OPENHANDS_URL`)\n before packaging.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Bot doesn't react to messages | Token missing or bot not in channel | Verify token with `auth.test`; `/invite @botname` |\n| `not_in_channel` error in run logs | Bot token used but bot not a member | Invite bot or switch to user token |\n| `missing_scope` error | Token lacks required scopes | Re-install Slack app with correct scopes (see `references/slack-api.md`) |\n| No messages detected | `last_poll` timestamp is in the future | Delete the state file to reset; it will be recreated on next run |\n| Conversation link 404 | `OPENHANDS_URL` points to wrong host | Set the `OPENHANDS_URL` secret to the correct base URL |\n| Summary never posted | Conversation stuck in `running` state | Check conversation in the OpenHands UI; the agent may need intervention |\n| Duplicate conversations created | `processed_ts` state missing or corrupted | Delete the state file to reset; dedup will rebuild on next run |\n| Trigger message processed on each cron run | State file deleted between runs | Ensure `automation-state/` directory is persistent across runs |\n| Debug info needed | Need detailed per-message trace | Check `{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log` |"
|
|
402
402
|
},
|
|
403
403
|
{
|
|
404
404
|
"name": "slack-standup-digest",
|
|
@@ -27,8 +27,10 @@ This skill is activated by keywords:
|
|
|
27
27
|
- **Efficient polling**: single `search.messages` call for multi-channel user
|
|
28
28
|
tokens with `search:read`; falls back to one `conversations.history` call
|
|
29
29
|
per channel for bot tokens
|
|
30
|
-
- **
|
|
31
|
-
|
|
30
|
+
- **Triggered follow-ups**: Slack thread replies that repeat the trigger phrase
|
|
31
|
+
are forwarded to the existing OpenHands conversation
|
|
32
|
+
- **Watch window**: completed conversations keep watching their Slack thread for
|
|
33
|
+
short follow-up periods, with quiet threads backed off automatically
|
|
32
34
|
- **Reaction acknowledgement**: adds a 👀 to every message containing the
|
|
33
35
|
trigger phrase
|
|
34
36
|
- **Conversation link**: posts a link to the new conversation in the Slack
|
|
@@ -79,9 +81,11 @@ Each cron run (every minute):
|
|
|
79
81
|
3. Creates an OpenHands conversation with the message and recent channel
|
|
80
82
|
context as the initial prompt; posts a link to the conversation in the
|
|
81
83
|
Slack thread
|
|
82
|
-
4. Forwards
|
|
84
|
+
4. Forwards triggered replies in tracked threads to the existing conversation,
|
|
85
|
+
while ignoring replies that do not contain the trigger phrase
|
|
83
86
|
5. Checks active conversations - posts the agent's final response back to
|
|
84
|
-
Slack when the conversation completes
|
|
87
|
+
Slack when the conversation completes, then watches briefly for triggered
|
|
88
|
+
follow-up replies
|
|
85
89
|
|
|
86
90
|
## See Also
|
|
87
91
|
|
|
@@ -23,9 +23,12 @@ detected it:
|
|
|
23
23
|
3. Posts a reply in the Slack thread with a link to the conversation.
|
|
24
24
|
|
|
25
25
|
On every subsequent run:
|
|
26
|
-
-
|
|
26
|
+
- New Slack thread replies are forwarded only when they contain the trigger
|
|
27
|
+
phrase, so unrelated conversation in the thread is ignored.
|
|
27
28
|
- When the conversation finishes (or errors), the agent's final response is
|
|
28
29
|
posted back to the Slack thread.
|
|
30
|
+
- Completed conversations stay in a short follow-up watch window, allowing
|
|
31
|
+
triggered Slack replies to continue the same OpenHands conversation.
|
|
29
32
|
|
|
30
33
|
> **Local mode only.** This automation targets the local OpenHands setup
|
|
31
34
|
> (`dev:automation` stack). A cloud/webhook-based variant is out of scope here.
|
|
@@ -235,20 +238,26 @@ Each cron run executes `main.py`, which runs **10 polling iterations** (every
|
|
|
235
238
|
- User token + `search:read` + > 1 channel → single `search.messages` call
|
|
236
239
|
(searches for the trigger phrase across all channels).
|
|
237
240
|
- Otherwise → one `conversations.history` call per channel.
|
|
238
|
-
4. **Fetches thread replies** -
|
|
241
|
+
4. **Fetches due thread replies** - polls at most one tracked thread per
|
|
242
|
+
iteration using per-thread exponential backoff to stay within Slack rate
|
|
243
|
+
limits.
|
|
239
244
|
5. **Processes messages** in chronological order:
|
|
240
245
|
- Skips messages already in `processed_ts` (dedup across the overlap window).
|
|
241
246
|
- Skips bot messages and any `ts` in `bot_message_ts`.
|
|
242
|
-
- Reply in a tracked thread
|
|
243
|
-
|
|
244
|
-
|
|
247
|
+
- Reply in a tracked thread whose text contains the trigger phrase → forwards
|
|
248
|
+
a follow-up request to the existing conversation and resets the follow-up
|
|
249
|
+
watch window. Replies without the trigger phrase are marked processed and
|
|
250
|
+
ignored.
|
|
251
|
+
- Contains trigger phrase outside a tracked conversation → 👀 reaction, create
|
|
252
|
+
a new conversation, post link.
|
|
245
253
|
- Thread replies: agent receives full thread history for context.
|
|
246
254
|
- Root messages: agent receives the trigger text only.
|
|
247
255
|
6. **Checks conversation statuses** - for each active conversation where
|
|
248
256
|
`time.time() - last_activity > 15 s`:
|
|
249
257
|
- If status is `idle`, `finished`, `error`, or `stuck` → fetch the agent's
|
|
250
258
|
final response via `/api/conversations/{id}/agent_final_response` and post
|
|
251
|
-
it to the Slack thread. Mark
|
|
259
|
+
it to the Slack thread. Mark the record `watching` for five minutes so
|
|
260
|
+
triggered follow-up replies can continue the same conversation.
|
|
252
261
|
7. **Advances `last_poll`** to `now - 10 s` (overlap window prevents boundary
|
|
253
262
|
races). If a conversation creation failed, pins `last_poll` further back to
|
|
254
263
|
retry on the next iteration.
|
|
@@ -198,10 +198,12 @@ With < 10 channels polled every minute:
|
|
|
198
198
|
| Method | Tier | Calls/min | Headroom |
|
|
199
199
|
|--------|------|-----------|---------|
|
|
200
200
|
| `conversations.history` | Tier 3 | ≤ 10 | Comfortable |
|
|
201
|
-
| `conversations.replies` | Tier 3 | ≤
|
|
201
|
+
| `conversations.replies` | Tier 3 | ≤ 1 due tracked thread per iteration | Throttled with per-thread backoff |
|
|
202
202
|
| `search.messages` | Tier 2 | 1 | Fine |
|
|
203
203
|
| `reactions.add` | Tier 2 | ≤ triggers/min | Fine |
|
|
204
204
|
| `chat.postMessage` | Tier 3 | ≤ triggers + summaries | Fine |
|
|
205
205
|
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
The script surfaces Slack HTTP 429 responses with `Retry-After`. Thread reply
|
|
207
|
+
polling catches that signal, delays the affected thread, and increases its
|
|
208
|
+
per-thread backoff. Other Slack methods still fail fast so the next cron tick can
|
|
209
|
+
retry with the persisted state.
|
|
@@ -52,7 +52,7 @@ variable (field `automation_id`).
|
|
|
52
52
|
|
|
53
53
|
## `conversations` Map
|
|
54
54
|
|
|
55
|
-
Key: `"{channel_id}:{thread_root_ts}"`
|
|
55
|
+
Key: `"{channel_id}:{thread_root_ts}"` - uniquely identifies a Slack thread.
|
|
56
56
|
|
|
57
57
|
Value: **ConversationRecord**
|
|
58
58
|
|
|
@@ -61,14 +61,25 @@ Value: **ConversationRecord**
|
|
|
61
61
|
// Required fields
|
|
62
62
|
"conversation_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
63
63
|
// OpenHands conversation UUID
|
|
64
|
-
"channel_id": "C0123456789",
|
|
64
|
+
"channel_id": "C0123456789", // Slack channel ID
|
|
65
65
|
"thread_ts": "1716576000.000100",
|
|
66
66
|
// Slack thread root timestamp
|
|
67
67
|
// (= msg_ts for top-level trigger messages)
|
|
68
|
-
"status": "active",
|
|
69
|
-
"last_activity": 1716576060.0,
|
|
70
|
-
// script
|
|
71
|
-
//
|
|
68
|
+
"status": "active", // "active" | "watching" | "closed"
|
|
69
|
+
"last_activity": 1716576060.0, // float Unix timestamp of the last time the
|
|
70
|
+
// script created, resumed, or summarized the
|
|
71
|
+
// OpenHands conversation
|
|
72
|
+
|
|
73
|
+
// Follow-up polling fields
|
|
74
|
+
"last_seen_reply_ts": "1716576000.000100",
|
|
75
|
+
// newest Slack reply ts observed for this thread
|
|
76
|
+
"reply_poll_backoff_seconds": 5, // current per-thread conversations.replies backoff
|
|
77
|
+
"next_reply_poll_at": 1716576065.0,// next Unix timestamp when replies may be polled
|
|
78
|
+
"watch_until": 1716576360.0, // follow-up watch expiry for completed conversations
|
|
79
|
+
|
|
80
|
+
// Present after expiry
|
|
81
|
+
"closed_reason": "followup_watch_expired",
|
|
82
|
+
"closed_at": 1716576360.0
|
|
72
83
|
}
|
|
73
84
|
```
|
|
74
85
|
|
|
@@ -76,13 +87,13 @@ Value: **ConversationRecord**
|
|
|
76
87
|
|
|
77
88
|
| Value | Meaning |
|
|
78
89
|
|-------|---------|
|
|
79
|
-
| `active` | Conversation is running or awaiting
|
|
80
|
-
| `
|
|
90
|
+
| `active` | Conversation is running or awaiting completion; triggered replies are forwarded to it |
|
|
91
|
+
| `watching` | A summary has been posted and the thread remains open briefly for triggered follow-ups |
|
|
92
|
+
| `closed` | The follow-up watch expired; future triggered replies create a new conversation |
|
|
81
93
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
pruned.
|
|
94
|
+
`watching` records use `watch_until` to avoid keeping every completed thread hot
|
|
95
|
+
forever. Quiet watched threads use exponential backoff for `conversations.replies`
|
|
96
|
+
polling, and each automation iteration polls at most one due thread.
|
|
86
97
|
|
|
87
98
|
---
|
|
88
99
|
|
|
@@ -109,9 +120,9 @@ exactly `now`, messages near the boundary are re-fetched on the next iteration.
|
|
|
109
120
|
re-fetched messages from being processed twice (e.g., triggering a duplicate
|
|
110
121
|
conversation or forwarding the same reply multiple times).
|
|
111
122
|
|
|
112
|
-
Entries are added when a message is either skipped (not human, already handled
|
|
113
|
-
or
|
|
114
|
-
forwarded).
|
|
123
|
+
Entries are added when a message is either skipped (not human, already handled,
|
|
124
|
+
or an untriggered reply in a watched thread) or successfully processed (trigger
|
|
125
|
+
detected and conversation created, or triggered reply forwarded).
|
|
115
126
|
|
|
116
127
|
---
|
|
117
128
|
|
|
@@ -119,25 +130,33 @@ forwarded).
|
|
|
119
130
|
|
|
120
131
|
```
|
|
121
132
|
[trigger detected]
|
|
122
|
-
|
|
123
|
-
|
|
133
|
+
|
|
|
134
|
+
v
|
|
124
135
|
status = "active"
|
|
125
136
|
last_activity = now
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
└─────────────────────────────────────────────────┘
|
|
134
|
-
│
|
|
137
|
+
follow-up poll fields initialized
|
|
138
|
+
|
|
|
139
|
+
| triggered Slack reply while active
|
|
140
|
+
| -> send_to_conversation()
|
|
141
|
+
| -> last_activity = now
|
|
142
|
+
| -> watch window/backoff reset
|
|
143
|
+
|
|
|
135
144
|
(when time.time() - last_activity > DONE_DEBOUNCE
|
|
136
|
-
AND conversation_status
|
|
137
|
-
|
|
138
|
-
|
|
145
|
+
AND conversation_status in {idle, finished, error, stuck})
|
|
146
|
+
|
|
|
147
|
+
v
|
|
139
148
|
Post summary to Slack thread
|
|
149
|
+
status = "watching"
|
|
150
|
+
watch_until = now + THREAD_FOLLOWUP_WATCH_SECONDS
|
|
151
|
+
|
|
|
152
|
+
| triggered Slack reply before watch_until
|
|
153
|
+
| -> send_to_conversation()
|
|
154
|
+
| -> status = "active"
|
|
155
|
+
|
|
|
156
|
+
| no triggered reply before watch_until
|
|
157
|
+
v
|
|
140
158
|
status = "closed"
|
|
159
|
+
closed_reason = "followup_watch_expired"
|
|
141
160
|
```
|
|
142
161
|
|
|
143
162
|
---
|
|
@@ -158,14 +177,22 @@ forwarded).
|
|
|
158
177
|
"channel_id": "C0123456789",
|
|
159
178
|
"thread_ts": "1716575900.000100",
|
|
160
179
|
"status": "active",
|
|
161
|
-
"last_activity": 1716575902.3
|
|
180
|
+
"last_activity": 1716575902.3,
|
|
181
|
+
"last_seen_reply_ts": "1716575900.000100",
|
|
182
|
+
"reply_poll_backoff_seconds": 5,
|
|
183
|
+
"next_reply_poll_at": 1716575907.3,
|
|
184
|
+
"watch_until": 1716576202.3
|
|
162
185
|
},
|
|
163
186
|
"C9876543210:1716570000.000500": {
|
|
164
187
|
"conversation_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
|
|
165
188
|
"channel_id": "C9876543210",
|
|
166
189
|
"thread_ts": "1716570000.000500",
|
|
167
|
-
"status": "
|
|
168
|
-
"last_activity": 1716572100.0
|
|
190
|
+
"status": "watching",
|
|
191
|
+
"last_activity": 1716572100.0,
|
|
192
|
+
"last_seen_reply_ts": "1716572050.000100",
|
|
193
|
+
"reply_poll_backoff_seconds": 20,
|
|
194
|
+
"next_reply_poll_at": 1716572120.0,
|
|
195
|
+
"watch_until": 1716572400.0
|
|
169
196
|
}
|
|
170
197
|
},
|
|
171
198
|
"bot_message_ts": [
|
|
@@ -9,7 +9,8 @@ trigger phrase is detected it:
|
|
|
9
9
|
3. Posts a reply in the Slack thread with a link to the conversation.
|
|
10
10
|
|
|
11
11
|
On subsequent runs:
|
|
12
|
-
- New replies in a tracked thread are forwarded
|
|
12
|
+
- New replies in a tracked thread are forwarded only when they contain the
|
|
13
|
+
trigger phrase.
|
|
13
14
|
- When the conversation reaches a terminal/idle state the agent's final
|
|
14
15
|
response (or an error notice) is posted back to the Slack thread.
|
|
15
16
|
|
|
@@ -85,6 +86,18 @@ CONTEXT_MESSAGE_LIMIT = 15
|
|
|
85
86
|
# How far back (seconds) to look for context when creating a new conversation.
|
|
86
87
|
CONTEXT_LOOKBACK_SECONDS = 3600 # 1 hour of recent messages for context
|
|
87
88
|
|
|
89
|
+
# Keep completed conversations available for triggered follow-up replies for a
|
|
90
|
+
# short inactivity window. Polling conversations.replies is intentionally throttled:
|
|
91
|
+
# bot-token Slack apps may have a very small per-method quota, so each thread
|
|
92
|
+
# carries its own exponential backoff and each automation run polls at most one
|
|
93
|
+
# due thread. Start hot immediately after each bot reply, then back off
|
|
94
|
+
# on quiet threads.
|
|
95
|
+
THREAD_FOLLOWUP_WATCH_SECONDS = 300
|
|
96
|
+
THREAD_REPLY_INITIAL_BACKOFF_SECONDS = 5
|
|
97
|
+
THREAD_REPLY_MAX_BACKOFF_SECONDS = 300
|
|
98
|
+
THREAD_REPLY_BACKOFF_MULTIPLIER = 2
|
|
99
|
+
MAX_THREAD_REPLY_POLLS_PER_RUN = 1
|
|
100
|
+
|
|
88
101
|
|
|
89
102
|
# ── Stdlib helpers ─────────────────────────────────────────────────────────────
|
|
90
103
|
|
|
@@ -108,6 +121,55 @@ def get_secret(name: str) -> str:
|
|
|
108
121
|
return r.read().decode().strip()
|
|
109
122
|
|
|
110
123
|
|
|
124
|
+
def _is_loopback_openhands_url(url: str) -> bool:
|
|
125
|
+
return url.startswith((
|
|
126
|
+
"http://localhost",
|
|
127
|
+
"https://localhost",
|
|
128
|
+
"http://127.0.0.1",
|
|
129
|
+
"https://127.0.0.1",
|
|
130
|
+
))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _ngrok_public_openhands_url() -> str | None:
|
|
134
|
+
try:
|
|
135
|
+
with urllib.request.urlopen(
|
|
136
|
+
"http://127.0.0.1:4040/api/tunnels", timeout=2
|
|
137
|
+
) as response:
|
|
138
|
+
tunnels = json.loads(response.read()).get("tunnels", [])
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
print(f"Could not discover ngrok public URL: {exc}")
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
for tunnel in tunnels:
|
|
144
|
+
public_url = str(tunnel.get("public_url", "")).rstrip("/")
|
|
145
|
+
addr = str(tunnel.get("config", {}).get("addr", ""))
|
|
146
|
+
if (
|
|
147
|
+
public_url.startswith("https://")
|
|
148
|
+
and ("localhost:8000" in addr or "127.0.0.1:8000" in addr)
|
|
149
|
+
):
|
|
150
|
+
return public_url
|
|
151
|
+
|
|
152
|
+
for tunnel in tunnels:
|
|
153
|
+
public_url = str(tunnel.get("public_url", "")).rstrip("/")
|
|
154
|
+
if public_url.startswith("https://"):
|
|
155
|
+
return public_url
|
|
156
|
+
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve_openhands_url() -> str:
|
|
161
|
+
try:
|
|
162
|
+
configured = get_secret("OPENHANDS_URL").rstrip()
|
|
163
|
+
except Exception:
|
|
164
|
+
configured = ""
|
|
165
|
+
|
|
166
|
+
configured = (configured or DEFAULT_OPENHANDS_URL).rstrip("/")
|
|
167
|
+
if not _is_loopback_openhands_url(configured):
|
|
168
|
+
return configured
|
|
169
|
+
|
|
170
|
+
return _ngrok_public_openhands_url() or configured
|
|
171
|
+
|
|
172
|
+
|
|
111
173
|
def fire_callback(
|
|
112
174
|
status: str = "COMPLETED",
|
|
113
175
|
error: str | None = None,
|
|
@@ -195,8 +257,14 @@ def _slack_call(
|
|
|
195
257
|
}
|
|
196
258
|
data = json.dumps(body).encode() if body is not None else None
|
|
197
259
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
198
|
-
|
|
199
|
-
|
|
260
|
+
try:
|
|
261
|
+
with urllib.request.urlopen(req) as r:
|
|
262
|
+
result = json.loads(r.read())
|
|
263
|
+
except urllib.error.HTTPError as exc:
|
|
264
|
+
if exc.code == 429:
|
|
265
|
+
retry_after = exc.headers.get("Retry-After", "60")
|
|
266
|
+
raise RuntimeError(f"Slack {endpoint}: rate_limited retry_after={retry_after}") from exc
|
|
267
|
+
raise
|
|
200
268
|
if not result.get("ok"):
|
|
201
269
|
raise RuntimeError(f"Slack {endpoint}: {result.get('error', 'unknown_error')}")
|
|
202
270
|
return result
|
|
@@ -273,6 +341,36 @@ def thread_replies(token: str, channel: str, thread_ts: str, oldest: str) -> lis
|
|
|
273
341
|
return [m for m in messages if m.get("ts") != thread_ts]
|
|
274
342
|
|
|
275
343
|
|
|
344
|
+
def _trigger_index(text: str) -> int:
|
|
345
|
+
"""Return the start index of an exact trigger phrase match, or -1."""
|
|
346
|
+
lowered = text.lower()
|
|
347
|
+
trigger = TRIGGER_PHRASE.lower()
|
|
348
|
+
start = 0
|
|
349
|
+
while True:
|
|
350
|
+
idx = lowered.find(trigger, start)
|
|
351
|
+
if idx < 0:
|
|
352
|
+
return -1
|
|
353
|
+
before = lowered[idx - 1] if idx > 0 else ""
|
|
354
|
+
after_idx = idx + len(trigger)
|
|
355
|
+
after = lowered[after_idx] if after_idx < len(lowered) else ""
|
|
356
|
+
before_ok = not before or not (before.isalnum() or before in "_-")
|
|
357
|
+
after_ok = not after or not (after.isalnum() or after in "_-")
|
|
358
|
+
if before_ok and after_ok:
|
|
359
|
+
return idx
|
|
360
|
+
start = idx + 1
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _has_trigger(text: str) -> bool:
|
|
364
|
+
return _trigger_index(text) >= 0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _request_after_trigger(text: str) -> str:
|
|
368
|
+
idx = _trigger_index(text)
|
|
369
|
+
if idx < 0:
|
|
370
|
+
return text
|
|
371
|
+
return text[idx + len(TRIGGER_PHRASE):].strip(" :–—")
|
|
372
|
+
|
|
373
|
+
|
|
276
374
|
def full_thread_history(
|
|
277
375
|
token: str, channel: str, thread_ts: str,
|
|
278
376
|
bot_user_id: str, bot_message_ts: list[str],
|
|
@@ -567,14 +665,99 @@ def _gather_channel_context(
|
|
|
567
665
|
return context_lines
|
|
568
666
|
|
|
569
667
|
|
|
668
|
+
def _expire_inactive_thread_watches(active_convs: dict[str, dict], now: float) -> None:
|
|
669
|
+
for conv_key, rec in active_convs.items():
|
|
670
|
+
if rec.get("status") != "watching":
|
|
671
|
+
continue
|
|
672
|
+
watch_until = float(rec.get("watch_until") or 0)
|
|
673
|
+
if watch_until and now >= watch_until:
|
|
674
|
+
rec["status"] = "closed"
|
|
675
|
+
rec["closed_reason"] = "followup_watch_expired"
|
|
676
|
+
rec["closed_at"] = now
|
|
677
|
+
print(f" Follow-up watch expired for {conv_key}")
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _poll_due_thread_replies(
|
|
681
|
+
slack_token: str,
|
|
682
|
+
active_convs: dict[str, dict],
|
|
683
|
+
bot_user_id: str,
|
|
684
|
+
bot_message_ts: list[str],
|
|
685
|
+
) -> list[tuple[str, dict]]:
|
|
686
|
+
now = time.time()
|
|
687
|
+
_expire_inactive_thread_watches(active_convs, now)
|
|
688
|
+
due: list[tuple[float, str, dict]] = []
|
|
689
|
+
for conv_key, rec in active_convs.items():
|
|
690
|
+
if rec.get("status") not in {"active", "watching"}:
|
|
691
|
+
continue
|
|
692
|
+
next_poll = float(rec.get("next_reply_poll_at") or 0)
|
|
693
|
+
if next_poll <= now:
|
|
694
|
+
due.append((next_poll, conv_key, rec))
|
|
695
|
+
|
|
696
|
+
reply_messages: list[tuple[str, dict]] = []
|
|
697
|
+
for _next_poll, conv_key, rec in sorted(due)[:MAX_THREAD_REPLY_POLLS_PER_RUN]:
|
|
698
|
+
cid = rec["channel_id"]
|
|
699
|
+
thread_ts = rec["thread_ts"]
|
|
700
|
+
oldest = rec.get("last_seen_reply_ts") or thread_ts
|
|
701
|
+
try:
|
|
702
|
+
replies = thread_replies(slack_token, cid, thread_ts, oldest)
|
|
703
|
+
except Exception as exc:
|
|
704
|
+
message = str(exc)
|
|
705
|
+
retry_after = THREAD_REPLY_MAX_BACKOFF_SECONDS
|
|
706
|
+
marker = "retry_after="
|
|
707
|
+
if marker in message:
|
|
708
|
+
raw = message.split(marker, 1)[1].split()[0]
|
|
709
|
+
try:
|
|
710
|
+
retry_after = max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, int(raw))
|
|
711
|
+
except ValueError:
|
|
712
|
+
pass
|
|
713
|
+
rec["next_reply_poll_at"] = now + retry_after
|
|
714
|
+
rec["reply_poll_backoff_seconds"] = min(
|
|
715
|
+
THREAD_REPLY_MAX_BACKOFF_SECONDS,
|
|
716
|
+
max(retry_after, int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)),
|
|
717
|
+
)
|
|
718
|
+
print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
if replies:
|
|
722
|
+
rec["last_seen_reply_ts"] = max(r.get("ts", oldest) for r in replies)
|
|
723
|
+
|
|
724
|
+
human_replies = [r for r in replies if _is_human_message(r, bot_user_id, bot_message_ts)]
|
|
725
|
+
triggered_replies = [
|
|
726
|
+
r for r in human_replies
|
|
727
|
+
if _has_trigger(r.get("text", "") or "")
|
|
728
|
+
]
|
|
729
|
+
if triggered_replies:
|
|
730
|
+
rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
731
|
+
rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
732
|
+
rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
|
|
733
|
+
for r in triggered_replies:
|
|
734
|
+
reply_messages.append((cid, r))
|
|
735
|
+
print(f" {conv_key}: {len(triggered_replies)} triggered follow-up reply/replies")
|
|
736
|
+
else:
|
|
737
|
+
if human_replies:
|
|
738
|
+
print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
|
|
739
|
+
current = int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)
|
|
740
|
+
next_backoff = min(
|
|
741
|
+
THREAD_REPLY_MAX_BACKOFF_SECONDS,
|
|
742
|
+
max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, current * THREAD_REPLY_BACKOFF_MULTIPLIER),
|
|
743
|
+
)
|
|
744
|
+
rec["reply_poll_backoff_seconds"] = next_backoff
|
|
745
|
+
rec["next_reply_poll_at"] = now + next_backoff
|
|
746
|
+
print(f" {conv_key}: no follow-ups; next reply poll in {next_backoff}s")
|
|
747
|
+
|
|
748
|
+
return reply_messages
|
|
749
|
+
|
|
750
|
+
|
|
570
751
|
def _poll_new_messages(
|
|
571
752
|
slack_token: str,
|
|
572
753
|
use_search: bool,
|
|
573
754
|
oldest_by_channel: dict[str, str],
|
|
574
755
|
global_oldest: str,
|
|
575
756
|
active_convs: dict[str, dict],
|
|
757
|
+
bot_user_id: str,
|
|
758
|
+
bot_message_ts: list[str],
|
|
576
759
|
) -> list[tuple[str, dict]]:
|
|
577
|
-
"""Collect and sort new top-level messages and thread replies from Slack."""
|
|
760
|
+
"""Collect and sort new top-level messages and due thread replies from Slack."""
|
|
578
761
|
new_messages: list[tuple[str, dict]] = []
|
|
579
762
|
|
|
580
763
|
if use_search:
|
|
@@ -602,19 +785,9 @@ def _poll_new_messages(
|
|
|
602
785
|
except Exception as exc:
|
|
603
786
|
print(f" Warning: could not fetch history for {cid}: {exc}")
|
|
604
787
|
|
|
605
|
-
reply_messages
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
continue
|
|
609
|
-
cid = rec["channel_id"]
|
|
610
|
-
thread_ts = rec["thread_ts"]
|
|
611
|
-
oldest = oldest_by_channel.get(cid, global_oldest)
|
|
612
|
-
try:
|
|
613
|
-
replies = thread_replies(slack_token, cid, thread_ts, oldest)
|
|
614
|
-
for r in replies:
|
|
615
|
-
reply_messages.append((cid, r))
|
|
616
|
-
except Exception as exc:
|
|
617
|
-
print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
|
|
788
|
+
reply_messages = _poll_due_thread_replies(
|
|
789
|
+
slack_token, active_convs, bot_user_id, bot_message_ts
|
|
790
|
+
)
|
|
618
791
|
|
|
619
792
|
return sorted(
|
|
620
793
|
new_messages + reply_messages,
|
|
@@ -669,10 +842,7 @@ def _process_trigger_message(
|
|
|
669
842
|
print(f" Warning: could not fetch thread history: {exc}")
|
|
670
843
|
|
|
671
844
|
# Extract the user's request: the text that follows the trigger phrase.
|
|
672
|
-
request_part = text
|
|
673
|
-
idx = text.lower().find(TRIGGER_PHRASE.lower())
|
|
674
|
-
if idx >= 0:
|
|
675
|
-
request_part = text[idx + len(TRIGGER_PHRASE):].strip(" :–—")
|
|
845
|
+
request_part = _request_after_trigger(text)
|
|
676
846
|
|
|
677
847
|
initial_prompt = (
|
|
678
848
|
f"You are an AI assistant responding to a Slack message.\n\n"
|
|
@@ -697,12 +867,17 @@ def _process_trigger_message(
|
|
|
697
867
|
conv_id = create_conversation(agent_url, api_key, initial_prompt)
|
|
698
868
|
conv_url = f"{openhands_url}/conversations/{conv_id}"
|
|
699
869
|
|
|
870
|
+
now = time.time()
|
|
700
871
|
active_convs[conv_key] = {
|
|
701
872
|
"conversation_id": conv_id,
|
|
702
873
|
"channel_id": channel_id,
|
|
703
874
|
"thread_ts": thread_root,
|
|
704
875
|
"status": "active",
|
|
705
|
-
"last_activity":
|
|
876
|
+
"last_activity": now,
|
|
877
|
+
"last_seen_reply_ts": msg_ts,
|
|
878
|
+
"reply_poll_backoff_seconds": THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
|
|
879
|
+
"next_reply_poll_at": now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
|
|
880
|
+
"watch_until": now + THREAD_FOLLOWUP_WATCH_SECONDS,
|
|
706
881
|
}
|
|
707
882
|
|
|
708
883
|
link_text = f"🤖 On it! View progress here: {conv_url}"
|
|
@@ -760,8 +935,16 @@ def _check_conversation_completion(
|
|
|
760
935
|
if ts_back:
|
|
761
936
|
bot_message_ts.append(ts_back)
|
|
762
937
|
|
|
763
|
-
|
|
764
|
-
|
|
938
|
+
now = time.time()
|
|
939
|
+
rec["status"] = "watching"
|
|
940
|
+
rec["last_activity"] = now
|
|
941
|
+
rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
|
|
942
|
+
rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
943
|
+
rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
944
|
+
print(
|
|
945
|
+
f" Posted summary for {conv_key}; watching for follow-ups "
|
|
946
|
+
f"until {rec['watch_until']:.0f}"
|
|
947
|
+
)
|
|
765
948
|
|
|
766
949
|
|
|
767
950
|
# ── Main ───────────────────────────────────────────────────────────────────────
|
|
@@ -776,10 +959,7 @@ def main() -> str | None:
|
|
|
776
959
|
|
|
777
960
|
slack_token, token_is_user = _resolve_slack_token()
|
|
778
961
|
|
|
779
|
-
|
|
780
|
-
openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
|
|
781
|
-
except Exception:
|
|
782
|
-
openhands_url = DEFAULT_OPENHANDS_URL
|
|
962
|
+
openhands_url = resolve_openhands_url()
|
|
783
963
|
|
|
784
964
|
# Raises RuntimeError immediately if the token is invalid - no point polling.
|
|
785
965
|
bot_user_id_new, scopes = _slack_auth_test(slack_token)
|
|
@@ -808,7 +988,8 @@ def main() -> str | None:
|
|
|
808
988
|
active_convs: dict[str, dict] = state.get("conversations", {})
|
|
809
989
|
|
|
810
990
|
all_incoming = _poll_new_messages(
|
|
811
|
-
slack_token, use_search, oldest_by_channel, global_oldest,
|
|
991
|
+
slack_token, use_search, oldest_by_channel, global_oldest,
|
|
992
|
+
active_convs, bot_user_id, bot_message_ts,
|
|
812
993
|
)
|
|
813
994
|
|
|
814
995
|
print(f" all_incoming: {len(all_incoming)} message(s), "
|
|
@@ -848,14 +1029,16 @@ def main() -> str | None:
|
|
|
848
1029
|
thread_root: str = thread_ts if thread_ts and thread_ts != msg_ts else msg_ts
|
|
849
1030
|
conv_key = f"{channel_id}:{thread_root}"
|
|
850
1031
|
|
|
851
|
-
has_trigger =
|
|
1032
|
+
has_trigger = _has_trigger(text)
|
|
852
1033
|
is_thread_reply = (
|
|
853
1034
|
thread_ts is not None
|
|
854
1035
|
and thread_ts != msg_ts
|
|
855
1036
|
)
|
|
1037
|
+
tracked_rec = active_convs.get(conv_key)
|
|
856
1038
|
is_reply_in_tracked = (
|
|
857
1039
|
is_thread_reply
|
|
858
|
-
and
|
|
1040
|
+
and tracked_rec is not None
|
|
1041
|
+
and tracked_rec.get("status") in {"active", "watching"}
|
|
859
1042
|
)
|
|
860
1043
|
|
|
861
1044
|
print(f" EVAL: ts={msg_ts} trigger={has_trigger} reply={is_thread_reply} "
|
|
@@ -863,20 +1046,39 @@ def main() -> str | None:
|
|
|
863
1046
|
f"text={text[:60]!r}")
|
|
864
1047
|
|
|
865
1048
|
# ── Case A: reply in a thread that has a tracked conversation ──────────
|
|
866
|
-
# Route to the existing conversation
|
|
867
|
-
#
|
|
868
|
-
#
|
|
1049
|
+
# Route to the existing conversation while it is active or in its
|
|
1050
|
+
# follow-up watch window, but only when the reply includes the trigger
|
|
1051
|
+
# phrase. Once the watch expires, a new trigger in the thread creates a
|
|
1052
|
+
# fresh conversation instead.
|
|
869
1053
|
if is_reply_in_tracked:
|
|
870
1054
|
rec = active_convs[conv_key]
|
|
871
|
-
|
|
1055
|
+
rec["last_seen_reply_ts"] = max(rec.get("last_seen_reply_ts", msg_ts), msg_ts)
|
|
1056
|
+
if not has_trigger:
|
|
1057
|
+
processed_ts.add(msg_ts)
|
|
1058
|
+
print(f" → Case A ignored (tracked reply without trigger): {msg_ts}")
|
|
1059
|
+
continue
|
|
1060
|
+
|
|
1061
|
+
request_part = _request_after_trigger(text)
|
|
1062
|
+
print(f" → Case A: Forwarding triggered reply {msg_ts} → conversation {rec['conversation_id']}")
|
|
872
1063
|
try:
|
|
873
|
-
send_to_conversation(
|
|
874
|
-
|
|
1064
|
+
send_to_conversation(
|
|
1065
|
+
agent_url, api_key, rec["conversation_id"],
|
|
1066
|
+
(
|
|
1067
|
+
f"User <@{msg.get('user', '?')}> replied in Slack thread.\n"
|
|
1068
|
+
f"The reply was activated by the trigger phrase: `{TRIGGER_PHRASE}`\n"
|
|
1069
|
+
f"Full reply: {text}\n"
|
|
1070
|
+
f"User request: {request_part or '(no explicit request — use your best judgement)'}"
|
|
1071
|
+
),
|
|
1072
|
+
)
|
|
1073
|
+
now = time.time()
|
|
875
1074
|
rec["status"] = "active"
|
|
876
|
-
rec["last_activity"] =
|
|
1075
|
+
rec["last_activity"] = now
|
|
1076
|
+
rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
|
|
1077
|
+
rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
1078
|
+
rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
877
1079
|
except Exception as exc:
|
|
878
1080
|
print(f" Warning: failed to forward reply: {exc}")
|
|
879
|
-
if
|
|
1081
|
+
if can_react:
|
|
880
1082
|
add_reaction(slack_token, channel_id, msg_ts)
|
|
881
1083
|
processed_ts.add(msg_ts)
|
|
882
1084
|
continue
|
|
@@ -922,7 +1124,7 @@ def main() -> str | None:
|
|
|
922
1124
|
print(f" last_poll set to {effective_last_poll}")
|
|
923
1125
|
|
|
924
1126
|
for conv_key, rec in list(active_convs.items()):
|
|
925
|
-
if rec.get("status")
|
|
1127
|
+
if rec.get("status") == "active":
|
|
926
1128
|
_check_conversation_completion(
|
|
927
1129
|
conv_key, rec, agent_url, api_key, slack_token, bot_message_ts,
|
|
928
1130
|
)
|