From 6099a298d1041882186f1b1137375976c6113cc4 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 19 May 2026 13:04:23 +0000 Subject: [PATCH 1/5] Add slack-channel-listener skill and slack-reply plugin Introduce a guided skill that creates an OpenHands automation to monitor one or more Slack channels for configurable trigger phrases and start new agent conversations per match, with results posted as threaded replies. Supports two architectures: - Push mode via Slack Events API (lowest latency, requires backend reachable from Slack). - Poll mode via cron + Slack Web API (works behind firewalls / on laptops; uses reactions as persistent state). Scope can be a single channel, a list, all public channels the bot has joined, or every channel the user can see (via search.messages). Reply includes optional thread context, recent channel context, and user-ID resolution; all behind environment-variable flags. The slack-reply plugin contains the reusable Python scripts that the skill packages into a custom-automation tarball. Co-authored-by: openhands --- README.md | 6 +- marketplaces/openhands-extensions.json | 1155 +++++++++-------- plugins/slack-reply/.claude-plugin | 1 + plugins/slack-reply/.codex-plugin | 1 + plugins/slack-reply/.plugin/plugin.json | 13 + plugins/slack-reply/README.md | 29 + plugins/slack-reply/SKILL.md | 74 ++ plugins/slack-reply/scripts/agent_event.py | 166 +++ plugins/slack-reply/scripts/agent_poll.py | 208 +++ plugins/slack-reply/scripts/config.py | 92 ++ plugins/slack-reply/scripts/prompt.py | 88 ++ plugins/slack-reply/scripts/setup.sh | 15 + plugins/slack-reply/scripts/slack_client.py | 152 +++ skills/slack-channel-listener/README.md | 36 + skills/slack-channel-listener/SKILL.md | 138 ++ .../commands/slack-listener.md | 8 + .../references/context-options.md | 60 + .../references/multi-channel.md | 67 + .../references/poll-setup.md | 96 ++ .../references/push-setup.md | 124 ++ .../references/tarball-build.md | 143 ++ 21 files changed, 2108 insertions(+), 564 deletions(-) create mode 120000 plugins/slack-reply/.claude-plugin create mode 120000 plugins/slack-reply/.codex-plugin create mode 100644 plugins/slack-reply/.plugin/plugin.json create mode 100644 plugins/slack-reply/README.md create mode 100644 plugins/slack-reply/SKILL.md create mode 100644 plugins/slack-reply/scripts/agent_event.py create mode 100644 plugins/slack-reply/scripts/agent_poll.py create mode 100644 plugins/slack-reply/scripts/config.py create mode 100644 plugins/slack-reply/scripts/prompt.py create mode 100644 plugins/slack-reply/scripts/setup.sh create mode 100644 plugins/slack-reply/scripts/slack_client.py create mode 100644 skills/slack-channel-listener/README.md create mode 100644 skills/slack-channel-listener/SKILL.md create mode 100644 skills/slack-channel-listener/commands/slack-listener.md create mode 100644 skills/slack-channel-listener/references/context-options.md create mode 100644 skills/slack-channel-listener/references/multi-channel.md create mode 100644 skills/slack-channel-listener/references/poll-setup.md create mode 100644 skills/slack-channel-listener/references/push-setup.md create mode 100644 skills/slack-channel-listener/references/tarball-build.md diff --git a/README.md b/README.md index 7959cad4..480504a3 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 **50 extensions** (39 skills, 11 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) +**46 extensions** (37 skills, 9 plugins) | Name | Type | Description | Commands | |------|------|-------------|----------| @@ -111,6 +111,8 @@ 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-listener | skill | Set up an OpenHands automation that listens to a Slack channel (or many) for a configurable trigger phrase and starts... | `/slack-listener` | +| slack-reply | plugin | Custom-automation building blocks for Slack-triggered OpenHands conversations: prompt assembly from Slack events, Sla... | — | | 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..17e40dec 100644 --- a/marketplaces/openhands-extensions.json +++ b/marketplaces/openhands-extensions.json @@ -1,564 +1,595 @@ { - "name": "openhands-extensions", - "owner": { - "name": "OpenHands", - "email": "contact@all-hands.dev" - }, - "metadata": { - "description": "Official skills and plugins for OpenHands — the open-source AI software engineer.", - "maintainer": "OpenHands", - "homepage": "https://github.com/OpenHands/extensions" - }, - "plugins": [ - { - "name": "agent-creator", - "source": "./skills/agent-creator", - "description": "Create file-based sub-agents as Markdown files — no Python code required. Guides the user through a structured interview and generates a ready-to-deploy .md agent file following the OpenHands SDK specification.", - "category": "development", - "keywords": [ - "agent", - "sub-agent", - "file-based", - "markdown", - "no-code", - "create" - ] - }, - { - "name": "add-skill", - "source": "./skills/add-skill", - "description": "Add (import) an OpenHands skill from a GitHub repository into the current workspace.", - "category": "productivity", - "keywords": [ - "skill", - "import", - "github", - "sparse-checkout" - ] - }, - { - "name": "agent-memory", - "source": "./skills/agent-memory", - "description": "Persist and retrieve repository-specific knowledge using AGENTS.md files. Use when you want to save important information about a codebase (build commands, code style, workflows) for future sessions.", - "category": "productivity", - "keywords": [ - "memory", - "knowledge", - "persistence", - "agents" - ] - }, - { - "name": "agent-sdk-builder", - "source": "./skills/agent-sdk-builder", - "description": "Guided workflow for building custom AI agents using the OpenHands Software Agent SDK. Use when you want to create a new agent through an interactive interview process that gathers requirements and generates implementation plans.", - "category": "development", - "keywords": [ - "agent", - "sdk", - "builder", - "openhands" - ] - }, - { - "name": "code-simplifier", - "source": "./skills/code-simplifier", - "description": "Simplifies and refines code across three dimensions - code reuse, code quality, and efficiency - while preserving all functionality. Analyzes recently modified code and provides actionable improvement suggestions.", - "category": "development", - "keywords": [ - "simplify", - "refine", - "cleanup", - "code-quality", - "reuse", - "efficiency", - "refactor" - ] - }, - { - "name": "openhands-automation", - "source": "./skills/openhands-automation", - "description": "Create and manage OpenHands automations - scheduled tasks that run in sandboxes. Use the prompt preset to create automations from natural language, or manage existing automations.", - "category": "integration", - "keywords": [ - "automation", - "cron", - "scheduled-task", - "sandbox", - "openhands" - ] - }, - { - "name": "openhands-sdk", - "source": "./skills/openhands-sdk", - "description": "Reference skill for the OpenHands Software Agent SDK - build AI agents with custom tools, LLM configuration, conversations, sub-agent delegation, MCP integration, security, and persistence.", - "category": "development", - "keywords": [ - "sdk", - "agent", - "openhands", - "tools", - "llm", - "conversation" - ] - }, - { - "name": "azure-devops", - "source": "./skills/azure-devops", - "description": "Interact with Azure DevOps repositories, pull requests, and APIs using the AZURE_DEVOPS_TOKEN environment variable. Use when working with code hosted on Azure DevOps or managing Azure DevOps resources.", - "category": "integration", - "keywords": [ - "azure", - "devops", - "git", - "pull-request" - ] - }, - { - "name": "bitbucket", - "source": "./skills/bitbucket", - "description": "Interact with Bitbucket repositories and pull requests using the BITBUCKET_TOKEN environment variable. Use when working with code hosted on Bitbucket or managing Bitbucket resources via API.", - "category": "integration", - "keywords": [ - "bitbucket", - "git", - "pull-request" - ] - }, - { - "name": "city-weather", - "source": "./plugins/city-weather", - "description": "Get current weather, time, and precipitation forecast for any city using the free Open-Meteo API. Provides slash command /city-weather:now .", - "category": "utilities", - "keywords": [ - "weather", - "time", - "forecast", - "temperature", - "precipitation", - "sample" - ] - }, - { - "name": "code-review", - "source": "./skills/code-review", - "description": "Rigorous code review focusing on data structures, simplicity, security, pragmatism, and risk/safety evaluation. Provides brutally honest, actionable feedback on pull requests or merge requests, including a risk assessment for every review. Use when reviewing code changes.", - "category": "code-quality", - "keywords": [ - "code-review", - "quality", - "security", - "style", - "risk" - ] - }, - { - "name": "datadog", - "source": "./skills/datadog", - "description": "Query and analyze Datadog logs, metrics, APM traces, and monitors using the Datadog API. Use when debugging production issues, monitoring application performance, or investigating alerts.", - "category": "monitoring", - "keywords": [ - "datadog", - "monitoring", - "logs", - "metrics", - "apm" - ] - }, - { - "name": "deno", - "source": "./skills/deno", - "description": "Common project operations using Deno (tasks, run/test/lint/fmt, and dependency management).", - "category": "development", - "keywords": [ - "deno", - "typescript", - "javascript", - "runtime" - ] - }, - { - "name": "discord", - "source": "./skills/discord", - "description": "Build and automate Discord integrations (bots, webhooks, slash commands, and REST API workflows). Use when the user mentions Discord, a Discord server/guild, channels, webhooks, bot tokens, slash commands/application commands, discord.js, or discord.py.", - "category": "integration", - "keywords": [ - "discord", - "bot", - "webhook", - "automation" - ] - }, - { - "name": "docker", - "source": "./skills/docker", - "description": "Run Docker commands within a container environment, including starting the Docker daemon and managing containers. Use when building, running, or managing Docker containers and images.", - "category": "infrastructure", - "keywords": [ - "docker", - "container", - "images" - ] - }, - { - "name": "flarglebargle", - "source": "./skills/flarglebargle", - "description": "A test skill that responds to the magic word 'flarglebargle' with a compliment. Use for testing skill activation and trigger functionality.", - "category": "testing", - "keywords": [ - "test", - "demo" - ] - }, - { - "name": "frontend-design", - "source": "./skills/frontend-design", - "description": "Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications. Generates creative, polished code and UI design that avoids generic AI aesthetics.", - "category": "design", - "keywords": [ - "frontend", - "design", - "ui", - "web", - "react", - "html", - "css" - ] - }, - { - "name": "github", - "source": "./skills/github", - "description": "Interact with GitHub repositories, pull requests, issues, and workflows using the GITHUB_TOKEN environment variable and GitHub CLI. Use when working with code hosted on GitHub or managing GitHub resources.", - "category": "integration", - "keywords": [ - "github", - "git", - "pull-request", - "issues", - "workflows" - ] - }, - { - "name": "github-pr-review", - "source": "./skills/github-pr-review", - "description": "Post structured PR reviews to GitHub with inline comments/suggestions in a single API call.", - "category": "code-quality", - "keywords": [ - "github", - "pull-request", - "review", - "code-review" - ] - }, - { - "name": "gitlab", - "source": "./skills/gitlab", - "description": "Interact with GitLab repositories, merge requests, and APIs using the GITLAB_TOKEN environment variable. Use when working with code hosted on GitLab or managing GitLab resources.", - "category": "integration", - "keywords": [ - "gitlab", - "git", - "merge-request" - ] - }, - { - "name": "jupyter", - "source": "./skills/jupyter", - "description": "Read, modify, execute, and convert Jupyter notebooks programmatically. Use when working with .ipynb files for data science workflows, including editing cells, clearing outputs, or converting to other formats.", - "category": "data-science", - "keywords": [ - "jupyter", - "notebook", - "ipynb", - "data-science" - ] - }, - { - "name": "kubernetes", - "source": "./skills/kubernetes", - "description": "Set up and manage local Kubernetes clusters using KIND (Kubernetes IN Docker). Use when testing Kubernetes applications locally or developing cloud-native workloads.", - "category": "infrastructure", - "keywords": [ - "kubernetes", - "k8s", - "kind", - "cloud-native" - ] - }, - { - "name": "learn-from-code-review", - "source": "./skills/learn-from-code-review", - "description": "Distill code review feedback from GitHub PRs into reusable skills and guidelines. Use when users ask to learn from code reviews, extract review patterns, or generate coding standards from historical PR feedback.", - "category": "code-quality", - "keywords": [ - "code-review", - "learning", - "skills", - "guidelines", - "pr-feedback" - ] - }, - { - "name": "linear", - "source": "./skills/linear", - "description": "Interact with Linear project management - query issues, update status, create tickets using the Linear GraphQL API.", - "category": "integration", - "keywords": [ - "linear", - "ticket", - "issue", - "project-management", - "graphql" - ] - }, - { - "name": "magic-test", - "source": "./plugins/magic-test", - "description": "A simple test plugin for verifying plugin loading. Triggers on magic words (alakazam, abracadabra) and returns a specific phrase to confirm plugins are working.", - "category": "testing", - "keywords": [ - "test", - "magic", - "plugin", - "verification", - "sample" - ] - }, - { - "name": "notion", - "source": "./skills/notion", - "description": "Create, search, and update Notion pages/databases using the Notion API. Use for documenting work, generating runbooks, and automating knowledge base updates.", - "category": "productivity", - "keywords": [ - "notion", - "documentation", - "knowledge-base" - ] - }, - { - "name": "npm", - "source": "./skills/npm", - "description": "Handle npm package installation in non-interactive environments by piping confirmations. Use when installing Node.js packages that require user confirmation prompts.", - "category": "development", - "keywords": [ - "npm", - "nodejs", - "packages" - ] - }, - { - "name": "onboarding", - "source": "./plugins/onboarding", - "description": "Assess repository agent-readiness across five pillars, propose high-impact fixes, and generate repo-specific AGENTS.md files.", - "category": "productivity", - "keywords": [ - "onboarding", - "readiness", - "agents-md", - "agent-readiness", - "assessment" - ] - }, - { - "name": "openhands", - "source": "./plugins/openhands", - "description": "Unified OpenHands plugin — bundles Cloud CLI, REST API (openhands-api), and Automations (openhands-automation) into a single plugin.", - "category": "openhands", - "keywords": [ - "openhands", - "cloud", - "api", - "automation", - "cli" - ] - }, - { - "name": "openhands-api", - "source": "./skills/openhands-api", - "description": "Use the OpenHands Cloud REST API (V1) to create and manage app conversations, including multi-conversation delegation workflows, and to access sandbox agent-server endpoints. Includes minimal Python and TypeScript clients under scripts/.", - "category": "development", - "keywords": [ - "openhands", - "api", - "cloud", - "automation", - "delegation", - "agent-server", - "sandbox", - "conversations" - ] - }, - { - "name": "pdflatex", - "source": "./skills/pdflatex", - "description": "Install and use pdflatex to compile LaTeX documents into PDFs on Linux. Use when generating academic papers, research publications, or any documents written in LaTeX.", - "category": "documentation", - "keywords": [ - "latex", - "pdf", - "academic", - "documents" - ] - }, - { - "name": "pr-review", - "source": "./plugins/pr-review", - "description": "Automated PR code review — analyzes diffs and posts inline review comments via the GitHub API.", - "category": "code-quality", - "keywords": [ - "pr-review", - "code-review", - "github", - "automation" - ] - }, - { - "name": "qa-changes", - "source": "./plugins/qa-changes", - "description": "Validate pull request changes by actually running the code — setting up the environment, exercising changed behavior, and posting a structured QA report.", - "category": "quality-assurance", - "keywords": [ - "qa", - "testing", - "pull-request", - "validation", - "automation" - ] - }, - { - "name": "prd", - "source": "./skills/prd", - "description": "Generate a Product Requirements Document (PRD) for a new feature through an interactive clarifying-question workflow. Use when planning a feature, starting a new project, or when asked to create a PRD.", - "category": "productivity", - "keywords": [ - "prd", - "requirements", - "planning", - "product", - "specification" - ] - }, - { - "name": "release-notes", - "source": "./plugins/release-notes", - "description": "Generate consistent, well-structured release notes from git history. Produces categorized changelog with breaking changes, features, fixes, and contributor attribution.", - "category": "productivity", - "keywords": [ - "release-notes", - "changelog", - "git", - "semver" - ] - }, - { - "name": "security", - "source": "./skills/security", - "description": "Security best practices for secure coding, authentication, authorization, and data protection. Use when developing features that handle sensitive data, user authentication, or require security review.", - "category": "security", - "keywords": [ - "security", - "authentication", - "authorization", - "encryption" - ] - }, - { - "name": "skill-creator", - "source": "./skills/skill-creator", - "description": "Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.", - "category": "development", - "keywords": [ - "skill", - "plugin", - "create" - ] - }, - { - "name": "ssh", - "source": "./skills/ssh", - "description": "Establish and manage SSH connections to remote machines, including key generation, configuration, and file transfers. Use when connecting to remote servers, executing remote commands, or transferring files via SCP.", - "category": "infrastructure", - "keywords": [ - "ssh", - "remote", - "scp", - "server" - ] - }, - { - "name": "swift-linux", - "source": "./skills/swift-linux", - "description": "Install and configure Swift programming language on Debian Linux for server-side development. Use when building Swift applications on Linux or setting up a Swift development environment.", - "category": "development", - "keywords": [ - "swift", - "linux", - "server-side" - ] - }, - { - "name": "theme-factory", - "source": "./skills/theme-factory", - "description": "Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.", - "category": "design", - "keywords": [ - "theme", - "styling", - "design", - "slides", - "documents" - ] - }, - { - "name": "uv", - "source": "./skills/uv", - "description": "Common project, dependency, and environment operations using uv.", - "category": "development", - "keywords": [ - "uv", - "python", - "dependencies", - "venv" - ] - }, - { - "name": "vercel", - "source": "./skills/vercel", - "description": "Deploy and manage applications on Vercel, including preview deployments and deployment protection.", - "category": "integration", - "keywords": [ - "vercel", - "deployment", - "preview", - "hosting" - ] - }, - { - "name": "vulnerability-remediation", - "source": "./plugins/vulnerability-remediation", - "description": "Automated security vulnerability scanning and AI-powered remediation. Scans repositories, skips when no issues found, and creates PRs with fixes.", - "category": "security", - "keywords": [ - "security", - "vulnerability", - "remediation", - "scanning", - "automation" - ] - }, - { - "name": "iterate", - "source": "./skills/iterate", - "description": "Iterate on a GitHub pull request — drive it through CI, code review, and QA until merge-ready. Monitors state, fixes failures, addresses review feedback, retries flaky checks, and pushes fixes in one continuous loop.", - "category": "productivity", - "keywords": [ - "github", - "ci", - "review", - "qa", - "pull-request", - "iterate" - ] - } - ] + "name": "openhands-extensions", + "owner": { + "name": "OpenHands", + "email": "contact@all-hands.dev" + }, + "metadata": { + "description": "Official skills and plugins for OpenHands \u2014 the open-source AI software engineer.", + "maintainer": "OpenHands", + "homepage": "https://github.com/OpenHands/extensions" + }, + "plugins": [ + { + "name": "agent-creator", + "source": "./skills/agent-creator", + "description": "Create file-based sub-agents as Markdown files \u2014 no Python code required. Guides the user through a structured interview and generates a ready-to-deploy .md agent file following the OpenHands SDK specification.", + "category": "development", + "keywords": [ + "agent", + "sub-agent", + "file-based", + "markdown", + "no-code", + "create" + ] + }, + { + "name": "add-skill", + "source": "./skills/add-skill", + "description": "Add (import) an OpenHands skill from a GitHub repository into the current workspace.", + "category": "productivity", + "keywords": [ + "skill", + "import", + "github", + "sparse-checkout" + ] + }, + { + "name": "agent-memory", + "source": "./skills/agent-memory", + "description": "Persist and retrieve repository-specific knowledge using AGENTS.md files. Use when you want to save important information about a codebase (build commands, code style, workflows) for future sessions.", + "category": "productivity", + "keywords": [ + "memory", + "knowledge", + "persistence", + "agents" + ] + }, + { + "name": "agent-sdk-builder", + "source": "./skills/agent-sdk-builder", + "description": "Guided workflow for building custom AI agents using the OpenHands Software Agent SDK. Use when you want to create a new agent through an interactive interview process that gathers requirements and generates implementation plans.", + "category": "development", + "keywords": [ + "agent", + "sdk", + "builder", + "openhands" + ] + }, + { + "name": "code-simplifier", + "source": "./skills/code-simplifier", + "description": "Simplifies and refines code across three dimensions - code reuse, code quality, and efficiency - while preserving all functionality. Analyzes recently modified code and provides actionable improvement suggestions.", + "category": "development", + "keywords": [ + "simplify", + "refine", + "cleanup", + "code-quality", + "reuse", + "efficiency", + "refactor" + ] + }, + { + "name": "openhands-automation", + "source": "./skills/openhands-automation", + "description": "Create and manage OpenHands automations - scheduled tasks that run in sandboxes. Use the prompt preset to create automations from natural language, or manage existing automations.", + "category": "integration", + "keywords": [ + "automation", + "cron", + "scheduled-task", + "sandbox", + "openhands" + ] + }, + { + "name": "openhands-sdk", + "source": "./skills/openhands-sdk", + "description": "Reference skill for the OpenHands Software Agent SDK - build AI agents with custom tools, LLM configuration, conversations, sub-agent delegation, MCP integration, security, and persistence.", + "category": "development", + "keywords": [ + "sdk", + "agent", + "openhands", + "tools", + "llm", + "conversation" + ] + }, + { + "name": "azure-devops", + "source": "./skills/azure-devops", + "description": "Interact with Azure DevOps repositories, pull requests, and APIs using the AZURE_DEVOPS_TOKEN environment variable. Use when working with code hosted on Azure DevOps or managing Azure DevOps resources.", + "category": "integration", + "keywords": [ + "azure", + "devops", + "git", + "pull-request" + ] + }, + { + "name": "bitbucket", + "source": "./skills/bitbucket", + "description": "Interact with Bitbucket repositories and pull requests using the BITBUCKET_TOKEN environment variable. Use when working with code hosted on Bitbucket or managing Bitbucket resources via API.", + "category": "integration", + "keywords": [ + "bitbucket", + "git", + "pull-request" + ] + }, + { + "name": "city-weather", + "source": "./plugins/city-weather", + "description": "Get current weather, time, and precipitation forecast for any city using the free Open-Meteo API. Provides slash command /city-weather:now .", + "category": "utilities", + "keywords": [ + "weather", + "time", + "forecast", + "temperature", + "precipitation", + "sample" + ] + }, + { + "name": "code-review", + "source": "./skills/code-review", + "description": "Rigorous code review focusing on data structures, simplicity, security, pragmatism, and risk/safety evaluation. Provides brutally honest, actionable feedback on pull requests or merge requests, including a risk assessment for every review. Use when reviewing code changes.", + "category": "code-quality", + "keywords": [ + "code-review", + "quality", + "security", + "style", + "risk" + ] + }, + { + "name": "datadog", + "source": "./skills/datadog", + "description": "Query and analyze Datadog logs, metrics, APM traces, and monitors using the Datadog API. Use when debugging production issues, monitoring application performance, or investigating alerts.", + "category": "monitoring", + "keywords": [ + "datadog", + "monitoring", + "logs", + "metrics", + "apm" + ] + }, + { + "name": "deno", + "source": "./skills/deno", + "description": "Common project operations using Deno (tasks, run/test/lint/fmt, and dependency management).", + "category": "development", + "keywords": [ + "deno", + "typescript", + "javascript", + "runtime" + ] + }, + { + "name": "discord", + "source": "./skills/discord", + "description": "Build and automate Discord integrations (bots, webhooks, slash commands, and REST API workflows). Use when the user mentions Discord, a Discord server/guild, channels, webhooks, bot tokens, slash commands/application commands, discord.js, or discord.py.", + "category": "integration", + "keywords": [ + "discord", + "bot", + "webhook", + "automation" + ] + }, + { + "name": "docker", + "source": "./skills/docker", + "description": "Run Docker commands within a container environment, including starting the Docker daemon and managing containers. Use when building, running, or managing Docker containers and images.", + "category": "infrastructure", + "keywords": [ + "docker", + "container", + "images" + ] + }, + { + "name": "flarglebargle", + "source": "./skills/flarglebargle", + "description": "A test skill that responds to the magic word 'flarglebargle' with a compliment. Use for testing skill activation and trigger functionality.", + "category": "testing", + "keywords": [ + "test", + "demo" + ] + }, + { + "name": "frontend-design", + "source": "./skills/frontend-design", + "description": "Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications. Generates creative, polished code and UI design that avoids generic AI aesthetics.", + "category": "design", + "keywords": [ + "frontend", + "design", + "ui", + "web", + "react", + "html", + "css" + ] + }, + { + "name": "github", + "source": "./skills/github", + "description": "Interact with GitHub repositories, pull requests, issues, and workflows using the GITHUB_TOKEN environment variable and GitHub CLI. Use when working with code hosted on GitHub or managing GitHub resources.", + "category": "integration", + "keywords": [ + "github", + "git", + "pull-request", + "issues", + "workflows" + ] + }, + { + "name": "github-pr-review", + "source": "./skills/github-pr-review", + "description": "Post structured PR reviews to GitHub with inline comments/suggestions in a single API call.", + "category": "code-quality", + "keywords": [ + "github", + "pull-request", + "review", + "code-review" + ] + }, + { + "name": "gitlab", + "source": "./skills/gitlab", + "description": "Interact with GitLab repositories, merge requests, and APIs using the GITLAB_TOKEN environment variable. Use when working with code hosted on GitLab or managing GitLab resources.", + "category": "integration", + "keywords": [ + "gitlab", + "git", + "merge-request" + ] + }, + { + "name": "jupyter", + "source": "./skills/jupyter", + "description": "Read, modify, execute, and convert Jupyter notebooks programmatically. Use when working with .ipynb files for data science workflows, including editing cells, clearing outputs, or converting to other formats.", + "category": "data-science", + "keywords": [ + "jupyter", + "notebook", + "ipynb", + "data-science" + ] + }, + { + "name": "kubernetes", + "source": "./skills/kubernetes", + "description": "Set up and manage local Kubernetes clusters using KIND (Kubernetes IN Docker). Use when testing Kubernetes applications locally or developing cloud-native workloads.", + "category": "infrastructure", + "keywords": [ + "kubernetes", + "k8s", + "kind", + "cloud-native" + ] + }, + { + "name": "learn-from-code-review", + "source": "./skills/learn-from-code-review", + "description": "Distill code review feedback from GitHub PRs into reusable skills and guidelines. Use when users ask to learn from code reviews, extract review patterns, or generate coding standards from historical PR feedback.", + "category": "code-quality", + "keywords": [ + "code-review", + "learning", + "skills", + "guidelines", + "pr-feedback" + ] + }, + { + "name": "linear", + "source": "./skills/linear", + "description": "Interact with Linear project management - query issues, update status, create tickets using the Linear GraphQL API.", + "category": "integration", + "keywords": [ + "linear", + "ticket", + "issue", + "project-management", + "graphql" + ] + }, + { + "name": "magic-test", + "source": "./plugins/magic-test", + "description": "A simple test plugin for verifying plugin loading. Triggers on magic words (alakazam, abracadabra) and returns a specific phrase to confirm plugins are working.", + "category": "testing", + "keywords": [ + "test", + "magic", + "plugin", + "verification", + "sample" + ] + }, + { + "name": "notion", + "source": "./skills/notion", + "description": "Create, search, and update Notion pages/databases using the Notion API. Use for documenting work, generating runbooks, and automating knowledge base updates.", + "category": "productivity", + "keywords": [ + "notion", + "documentation", + "knowledge-base" + ] + }, + { + "name": "npm", + "source": "./skills/npm", + "description": "Handle npm package installation in non-interactive environments by piping confirmations. Use when installing Node.js packages that require user confirmation prompts.", + "category": "development", + "keywords": [ + "npm", + "nodejs", + "packages" + ] + }, + { + "name": "onboarding", + "source": "./plugins/onboarding", + "description": "Assess repository agent-readiness across five pillars, propose high-impact fixes, and generate repo-specific AGENTS.md files.", + "category": "productivity", + "keywords": [ + "onboarding", + "readiness", + "agents-md", + "agent-readiness", + "assessment" + ] + }, + { + "name": "openhands", + "source": "./plugins/openhands", + "description": "Unified OpenHands plugin \u2014 bundles Cloud CLI, REST API (openhands-api), and Automations (openhands-automation) into a single plugin.", + "category": "openhands", + "keywords": [ + "openhands", + "cloud", + "api", + "automation", + "cli" + ] + }, + { + "name": "openhands-api", + "source": "./skills/openhands-api", + "description": "Use the OpenHands Cloud REST API (V1) to create and manage app conversations, including multi-conversation delegation workflows, and to access sandbox agent-server endpoints. Includes minimal Python and TypeScript clients under scripts/.", + "category": "development", + "keywords": [ + "openhands", + "api", + "cloud", + "automation", + "delegation", + "agent-server", + "sandbox", + "conversations" + ] + }, + { + "name": "pdflatex", + "source": "./skills/pdflatex", + "description": "Install and use pdflatex to compile LaTeX documents into PDFs on Linux. Use when generating academic papers, research publications, or any documents written in LaTeX.", + "category": "documentation", + "keywords": [ + "latex", + "pdf", + "academic", + "documents" + ] + }, + { + "name": "pr-review", + "source": "./plugins/pr-review", + "description": "Automated PR code review \u2014 analyzes diffs and posts inline review comments via the GitHub API.", + "category": "code-quality", + "keywords": [ + "pr-review", + "code-review", + "github", + "automation" + ] + }, + { + "name": "qa-changes", + "source": "./plugins/qa-changes", + "description": "Validate pull request changes by actually running the code \u2014 setting up the environment, exercising changed behavior, and posting a structured QA report.", + "category": "quality-assurance", + "keywords": [ + "qa", + "testing", + "pull-request", + "validation", + "automation" + ] + }, + { + "name": "prd", + "source": "./skills/prd", + "description": "Generate a Product Requirements Document (PRD) for a new feature through an interactive clarifying-question workflow. Use when planning a feature, starting a new project, or when asked to create a PRD.", + "category": "productivity", + "keywords": [ + "prd", + "requirements", + "planning", + "product", + "specification" + ] + }, + { + "name": "release-notes", + "source": "./plugins/release-notes", + "description": "Generate consistent, well-structured release notes from git history. Produces categorized changelog with breaking changes, features, fixes, and contributor attribution.", + "category": "productivity", + "keywords": [ + "release-notes", + "changelog", + "git", + "semver" + ] + }, + { + "name": "security", + "source": "./skills/security", + "description": "Security best practices for secure coding, authentication, authorization, and data protection. Use when developing features that handle sensitive data, user authentication, or require security review.", + "category": "security", + "keywords": [ + "security", + "authentication", + "authorization", + "encryption" + ] + }, + { + "name": "skill-creator", + "source": "./skills/skill-creator", + "description": "Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.", + "category": "development", + "keywords": [ + "skill", + "plugin", + "create" + ] + }, + { + "name": "ssh", + "source": "./skills/ssh", + "description": "Establish and manage SSH connections to remote machines, including key generation, configuration, and file transfers. Use when connecting to remote servers, executing remote commands, or transferring files via SCP.", + "category": "infrastructure", + "keywords": [ + "ssh", + "remote", + "scp", + "server" + ] + }, + { + "name": "swift-linux", + "source": "./skills/swift-linux", + "description": "Install and configure Swift programming language on Debian Linux for server-side development. Use when building Swift applications on Linux or setting up a Swift development environment.", + "category": "development", + "keywords": [ + "swift", + "linux", + "server-side" + ] + }, + { + "name": "theme-factory", + "source": "./skills/theme-factory", + "description": "Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.", + "category": "design", + "keywords": [ + "theme", + "styling", + "design", + "slides", + "documents" + ] + }, + { + "name": "uv", + "source": "./skills/uv", + "description": "Common project, dependency, and environment operations using uv.", + "category": "development", + "keywords": [ + "uv", + "python", + "dependencies", + "venv" + ] + }, + { + "name": "vercel", + "source": "./skills/vercel", + "description": "Deploy and manage applications on Vercel, including preview deployments and deployment protection.", + "category": "integration", + "keywords": [ + "vercel", + "deployment", + "preview", + "hosting" + ] + }, + { + "name": "vulnerability-remediation", + "source": "./plugins/vulnerability-remediation", + "description": "Automated security vulnerability scanning and AI-powered remediation. Scans repositories, skips when no issues found, and creates PRs with fixes.", + "category": "security", + "keywords": [ + "security", + "vulnerability", + "remediation", + "scanning", + "automation" + ] + }, + { + "name": "iterate", + "source": "./skills/iterate", + "description": "Iterate on a GitHub pull request \u2014 drive it through CI, code review, and QA until merge-ready. Monitors state, fixes failures, addresses review feedback, retries flaky checks, and pushes fixes in one continuous loop.", + "category": "productivity", + "keywords": [ + "github", + "ci", + "review", + "qa", + "pull-request", + "iterate" + ] + }, + { + "name": "slack-channel-listener", + "source": "./skills/slack-channel-listener", + "description": "Set up an OpenHands automation that listens to a Slack channel (or many) for a configurable trigger phrase and starts a new agent conversation per match, replying threaded to the triggering message. Supports both Slack Events API (push) and cron polling for laptops / firewalled environments.", + "category": "integration", + "keywords": [ + "slack", + "automation", + "webhook", + "events-api", + "poll", + "thread", + "mention", + "listener" + ] + }, + { + "name": "slack-reply", + "source": "./plugins/slack-reply", + "description": "Custom-automation building blocks for Slack-triggered OpenHands conversations: prompt assembly from Slack events, Slack API helpers, and an on-finish threaded reply. Consumed by the slack-channel-listener skill.", + "category": "integration", + "keywords": [ + "slack", + "automation", + "reply", + "thread", + "webhook", + "poll", + "plugin" + ] + } + ] } diff --git a/plugins/slack-reply/.claude-plugin b/plugins/slack-reply/.claude-plugin new file mode 120000 index 00000000..665797f0 --- /dev/null +++ b/plugins/slack-reply/.claude-plugin @@ -0,0 +1 @@ +.plugin \ No newline at end of file diff --git a/plugins/slack-reply/.codex-plugin b/plugins/slack-reply/.codex-plugin new file mode 120000 index 00000000..665797f0 --- /dev/null +++ b/plugins/slack-reply/.codex-plugin @@ -0,0 +1 @@ +.plugin \ No newline at end of file diff --git a/plugins/slack-reply/.plugin/plugin.json b/plugins/slack-reply/.plugin/plugin.json new file mode 100644 index 00000000..baf36dea --- /dev/null +++ b/plugins/slack-reply/.plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "slack-reply", + "version": "0.1.0", + "description": "Custom-automation building blocks for Slack-triggered OpenHands conversations: prompt assembly from Slack events, Slack API helpers, and an on-finish threaded reply.", + "author": { + "name": "OpenHands", + "email": "contact@all-hands.dev" + }, + "homepage": "https://github.com/OpenHands/extensions", + "repository": "https://github.com/OpenHands/extensions", + "license": "MIT", + "keywords": ["slack", "automation", "reply", "thread", "webhook", "poll"] +} diff --git a/plugins/slack-reply/README.md b/plugins/slack-reply/README.md new file mode 100644 index 00000000..43189a1f --- /dev/null +++ b/plugins/slack-reply/README.md @@ -0,0 +1,29 @@ +# slack-reply + +Reusable Python scripts for Slack-triggered OpenHands automations. + +This plugin is consumed by the [`slack-channel-listener`](../../skills/slack-channel-listener) skill, +which copies the contents of `scripts/` into a [custom-automation](../../skills/openhands-automation/references/custom-automation.md) +tarball and uploads it. The scripts themselves are also usable directly if +you want to assemble your own automation by hand. + +## Usage paths + +| Trigger | Entrypoint | Comment | +|---|---|---| +| Slack Events API webhook | `agent_event.py` | One automation run per matching message. Near real-time. Requires the automation backend to be reachable from Slack. | +| Cron poll | `agent_poll.py` | One automation run per cron tick, handles any matching messages since the last marker. Works behind firewalls; uses message reactions as state. | + +See `SKILL.md` for the full environment-variable contract. + +## Local sanity check + +```bash +# Inside the unpacked plugin directory +python -m py_compile scripts/*.py +``` + +There's no runtime test harness in this repo because the scripts depend on +`openhands-sdk` and a live Slack token; tests live alongside the consuming +skill (`skills/slack-channel-listener`) and are exercised via automation +dry-runs in CI. diff --git a/plugins/slack-reply/SKILL.md b/plugins/slack-reply/SKILL.md new file mode 100644 index 00000000..abdb0421 --- /dev/null +++ b/plugins/slack-reply/SKILL.md @@ -0,0 +1,74 @@ +--- +name: slack-reply +description: >- + Building blocks for Slack-triggered OpenHands automations. Provides the + agent entrypoint scripts, Slack API helpers, prompt assembly from a Slack + event payload, and a post-run threaded reply back to the triggering + message. Intended to be packaged into a custom-automation tarball by the + `slack-channel-listener` skill, not loaded directly into a Conversation. +triggers: + - slack reply + - slack thread reply + - slack-reply plugin +--- + +# slack-reply + +A reusable bundle of Python scripts that turn an incoming Slack message into an +OpenHands `Conversation` run and post the agent's final reply back as a +threaded message. + +This plugin is **not** a runtime `Plugin` you load via `PluginSource(...)` - +it is a code template consumed by the `slack-channel-listener` skill, which +copies these scripts into a custom-automation tarball and uploads it via the +OpenHands Automations API. + +## Contents + +| File | Purpose | +|---|---| +| `scripts/setup.sh` | Installs `uv`, the OpenHands SDK, and `slack_sdk` in the automation sandbox. | +| `scripts/slack_client.py` | Thin wrappers over the Slack Web API: history, replies, postMessage, reactions, permalinks, user resolution. | +| `scripts/prompt.py` | Turns a Slack event payload (or polled message) plus optional thread/channel context into the initial agent prompt. | +| `scripts/agent_event.py` | Entrypoint for **push-mode** automations. Reads the Slack event from `AUTOMATION_EVENT_PAYLOAD`, runs the agent, posts the result back. | +| `scripts/agent_poll.py` | Entrypoint for **poll-mode** automations. Scans matching messages since the last marker, runs the agent for each, posts the result back. | +| `scripts/config.py` | Loads automation-level config (trigger phrases, channel scope, context options, reply behaviour) from environment variables. | + +## Configuration (environment variables read at runtime) + +All variables are baked into the automation by `slack-channel-listener` when +it creates the automation; users should not edit them by hand. + +| Variable | Required | Meaning | +|---|---|---| +| `SLACK_BOT_TOKEN` | yes (push + poll) | Bot token with `chat:write`, `reactions:read`, `reactions:write`, and the appropriate `channels:history` / `groups:history` / `search:read` scopes. | +| `SLACK_TRIGGER_PHRASES` | yes | Comma-separated list of phrases that activate the agent (case-insensitive substring match, e.g. `@thems-fightin-words,/triage`). | +| `SLACK_CHANNEL_SCOPE` | yes | One of `single` (then `SLACK_CHANNEL_ID` must be set), `list` (`SLACK_CHANNEL_IDS` comma-separated), `all-public`, `all-accessible`. | +| `SLACK_CHANNEL_ID` | conditional | Single channel ID when scope = `single`. | +| `SLACK_CHANNEL_IDS` | conditional | Comma-separated channel IDs when scope = `list`. | +| `SLACK_INCLUDE_THREAD_CONTEXT` | no (default `true`) | If the trigger is inside a thread, include the parent + prior replies in the prompt. | +| `SLACK_INCLUDE_CHANNEL_RECENT` | no (default `0`) | Include this many recent channel messages before the trigger as context. | +| `SLACK_RESOLVE_USER_IDS` | no (default `true`) | Replace `<@U…>` mentions with `@DisplayName` in the assembled prompt. | +| `SLACK_REPLY_MODE` | no (default `thread`) | `none` (don't post back), `thread` (threaded reply under triggering message), `thread+reaction` (also adds a `👀` on start and `✅` on finish). | +| `SLACK_ACK_REACTION` | no (default `eyes`) | Reaction emoji posted when work starts (used by `thread+reaction`). | +| `SLACK_DONE_REACTION` | no (default `white_check_mark`) | Reaction emoji posted on success. | +| `SLACK_FAIL_REACTION` | no (default `warning`) | Reaction emoji posted on failure. | +| `SLACK_POLL_LOOKBACK_MINUTES` | no (default `15`) | Poll mode only: how far back to search when no `ack` reaction is found. | + +## How "post on idle" works + +The script awaits `conversation.run()` (which blocks until the agent +terminates - finished, awaiting user input, or errored) and then reads the +last assistant message from `conversation.state.events`. There is no +in-Conversation hook system needed for this; the post-run boundary in the +script is the idle boundary. + +For error paths (exception during `run()`, or the agent leaving in +`AgentExecutionStatus.ERROR`), the script posts a short failure summary with +the `SLACK_FAIL_REACTION`. This guarantees the user gets a reply on Slack +even when the agent fails. + +## See also + +- `skills/slack-channel-listener` - the guided flow that uses this plugin. +- `skills/openhands-automation` - the underlying automation API documentation. diff --git a/plugins/slack-reply/scripts/agent_event.py b/plugins/slack-reply/scripts/agent_event.py new file mode 100644 index 00000000..e9fa87a1 --- /dev/null +++ b/plugins/slack-reply/scripts/agent_event.py @@ -0,0 +1,166 @@ +"""Push-mode entrypoint: one run per Slack Events API webhook delivery. + +Reads the Slack event from `AUTOMATION_EVENT_PAYLOAD`, validates that the +message matches the configured trigger phrases and channel scope, runs the +OpenHands agent, and posts the result back to Slack as a threaded reply. +""" +from __future__ import annotations + +import json +import logging +import os +import sys +import traceback + +from config import Config +from prompt import Trigger, build_prompt +from slack_client import SlackClient + +log = logging.getLogger("slack_reply.event") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + +def extract_trigger(event_payload: dict) -> Trigger | None: + """Pull the Slack `event` object out of the webhook envelope and normalise it.""" + # The webhook payload from the automation dispatcher wraps the original + # provider payload under "payload" or "body" depending on version. We + # try both, then fall back to assuming the top-level dict is the + # Slack event_callback envelope itself. + body = event_payload.get("payload") or event_payload.get("body") or event_payload + if isinstance(body, str): + try: + body = json.loads(body) + except json.JSONDecodeError: + return None + + inner = body.get("event") if isinstance(body, dict) else None + if not isinstance(inner, dict): + return None + if inner.get("type") != "message": + return None + # Ignore bots talking to themselves and message edits/deletes. + if inner.get("subtype") in {"bot_message", "message_changed", "message_deleted"}: + return None + if inner.get("bot_id"): + return None + + return Trigger( + channel=inner.get("channel", ""), + channel_name=None, # not present in `message` events; resolve on demand if needed + ts=inner.get("ts", ""), + thread_ts=inner.get("thread_ts"), + user=inner.get("user"), + text=inner.get("text", ""), + ) + + +def main() -> int: + cfg = Config.from_env() + raw = os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}") + try: + event_payload = json.loads(raw) + except json.JSONDecodeError: + log.error("AUTOMATION_EVENT_PAYLOAD is not valid JSON: %r", raw[:200]) + return 1 + + trigger = extract_trigger(event_payload) + if trigger is None: + log.info("event did not produce a usable trigger; skipping") + return 0 + + if not cfg.channel_in_scope(trigger.channel): + log.info("channel %s not in scope; skipping", trigger.channel) + return 0 + + if not cfg.matches_phrase(trigger.text): + log.info("message did not match any trigger phrase; skipping") + return 0 + + slack = SlackClient(cfg.bot_token) + + if cfg.reply_mode == "thread+reaction": + slack.add_reaction(trigger.channel, trigger.ts, cfg.ack_reaction) + + try: + _run_agent_and_reply(cfg, slack, trigger) + except Exception: # noqa: BLE001 + log.exception("agent run failed") + if cfg.reply_mode != "none": + slack.post_thread_reply( + trigger.channel, + trigger.reply_thread_ts, + ":warning: I hit an error while working on this. " + "Check the automation run logs for details.\n" + f"```{traceback.format_exc(limit=2)}```", + ) + if cfg.reply_mode == "thread+reaction": + slack.add_reaction(trigger.channel, trigger.ts, cfg.fail_reaction) + return 1 + + if cfg.reply_mode == "thread+reaction": + slack.add_reaction(trigger.channel, trigger.ts, cfg.done_reaction) + return 0 + + +def _run_agent_and_reply(cfg: Config, slack: SlackClient, trigger: Trigger) -> None: + # Imported lazily so the dry-run / unit-test path doesn't require the SDK. + from openhands.sdk import Conversation + from openhands.tools.preset.default import get_default_agent + from openhands.workspace import OpenHandsCloudWorkspace + + api_key = os.environ["OPENHANDS_API_KEY"] + api_url = os.environ["OPENHANDS_CLOUD_API_URL"] + prompt = build_prompt(trigger, slack, cfg) + log.info("assembled prompt (%d chars)", len(prompt)) + + with OpenHandsCloudWorkspace( + local_agent_server_mode=True, + cloud_api_url=api_url, + cloud_api_key=api_key, + ) as workspace: + llm = workspace.get_llm() + secrets = workspace.get_secrets() + agent = get_default_agent(llm=llm, cli_mode=True) + conversation = Conversation(agent=agent, workspace=workspace) + if secrets: + conversation.update_secrets(secrets) + conversation.send_message(prompt) + conversation.run() + + final_text = _final_assistant_text(conversation) or "(agent produced no text output)" + if cfg.reply_mode != "none": + slack.post_thread_reply(trigger.channel, trigger.reply_thread_ts, final_text) + conversation.close() + + +def _final_assistant_text(conversation) -> str | None: + """Return the text of the last assistant message in the conversation.""" + events = getattr(getattr(conversation, "state", None), "events", None) or [] + for ev in reversed(list(events)): + # Be permissive across SDK versions: look for an assistant role and any text content. + role = getattr(ev, "source", None) or getattr(ev, "role", None) + if role and str(role).lower() not in {"assistant", "agent"}: + continue + text = _coerce_text(ev) + if text: + return text + return None + + +def _coerce_text(ev) -> str | None: + # Try common fields used across SDK event shapes. + for attr in ("text", "content", "message"): + v = getattr(ev, attr, None) + if isinstance(v, str) and v.strip(): + return v.strip() + if isinstance(v, list): + joined = "\n".join( + part.get("text", "") if isinstance(part, dict) else str(part) for part in v + ).strip() + if joined: + return joined + return None + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/slack-reply/scripts/agent_poll.py b/plugins/slack-reply/scripts/agent_poll.py new file mode 100644 index 00000000..77cbbff5 --- /dev/null +++ b/plugins/slack-reply/scripts/agent_poll.py @@ -0,0 +1,208 @@ +"""Poll-mode entrypoint: cron-triggered scan for new matching messages. + +Designed for environments where the Slack Events API cannot reach the +automation backend (developer laptops, corporate firewalls). Uses message +reactions as persistent state: a message is "claimed" by adding the +`ack_reaction` before processing and "completed" by adding the +`done_reaction` (or `fail_reaction` on error). Already-claimed messages are +skipped on subsequent runs, so this is safe to run more frequently than the +agent itself takes to finish. +""" +from __future__ import annotations + +import logging +import os +import sys +import time +import traceback +from datetime import datetime, timedelta, timezone + +from agent_event import _final_assistant_text # reuse the extraction logic +from config import Config +from prompt import Trigger, build_prompt +from slack_client import SlackClient + +log = logging.getLogger("slack_reply.poll") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + +def main() -> int: + cfg = Config.from_env() + slack = SlackClient(cfg.bot_token) + self_id = (slack.auth_test() or {}).get("user_id", "") + if not self_id: + log.error("auth.test did not return a user_id; cannot use reaction-based state") + return 1 + + oldest = _oldest_ts(cfg) + candidates = _collect_candidates(cfg, slack, oldest) + log.info("found %d candidate message(s) older=%s", len(candidates), oldest) + + exit_code = 0 + for trigger in candidates: + if slack.has_reaction_from_self(trigger.channel, trigger.ts, cfg.ack_reaction, self_id): + log.info("skipping %s/%s - already acknowledged", trigger.channel, trigger.ts) + continue + if slack.has_reaction_from_self(trigger.channel, trigger.ts, cfg.done_reaction, self_id): + log.info("skipping %s/%s - already completed", trigger.channel, trigger.ts) + continue + + # Claim the message before doing any work. If another runner also + # tries to claim it, Slack returns `already_reacted` which we treat + # as a no-op - the loser will still see the reaction on its next + # `has_reaction_from_self` check. + slack.add_reaction(trigger.channel, trigger.ts, cfg.ack_reaction) + + try: + _run_agent_and_reply(cfg, slack, trigger) + slack.add_reaction(trigger.channel, trigger.ts, cfg.done_reaction) + except Exception: # noqa: BLE001 + log.exception("agent run failed for %s/%s", trigger.channel, trigger.ts) + slack.add_reaction(trigger.channel, trigger.ts, cfg.fail_reaction) + if cfg.reply_mode != "none": + slack.post_thread_reply( + trigger.channel, + trigger.reply_thread_ts, + ":warning: I hit an error while working on this. " + "Check the automation run logs for details.\n" + f"```{traceback.format_exc(limit=2)}```", + ) + exit_code = 1 + + return exit_code + + +def _oldest_ts(cfg: Config) -> str: + cutoff = datetime.now(tz=timezone.utc) - timedelta(minutes=cfg.poll_lookback_minutes) + return f"{cutoff.timestamp():.6f}" + + +def _collect_candidates(cfg: Config, slack: SlackClient, oldest: str) -> list[Trigger]: + if cfg.scope == "all-accessible": + return _candidates_from_search(cfg, slack) + channels = _scoped_channels(cfg) + out: list[Trigger] = [] + for channel in channels: + msgs = slack.conversations_history(channel, oldest=oldest, limit=50) + for m in msgs: + text = m.get("text", "") + if not cfg.matches_phrase(text): + continue + if m.get("subtype") in {"bot_message", "message_changed", "message_deleted"}: + continue + if m.get("bot_id"): + continue + out.append( + Trigger( + channel=channel, + channel_name=None, + ts=m.get("ts", ""), + thread_ts=m.get("thread_ts"), + user=m.get("user"), + text=text, + ) + ) + return out + + +def _scoped_channels(cfg: Config) -> list[str]: + if cfg.scope == "single": + return [cfg.channel_id] if cfg.channel_id else [] + if cfg.scope == "list": + return list(cfg.channel_ids) + if cfg.scope == "all-public": + # Walk the workspace's public channels via the bot token. Note: the + # bot must be a member of each channel to read history; `channels:read` + # is sufficient for listing, `channels:history` for reading. + try: + from slack_sdk import WebClient + client = WebClient(token=cfg.bot_token) + ids: list[str] = [] + cursor = None + while True: + resp = client.conversations_list( + types="public_channel", limit=200, cursor=cursor + ) + for ch in resp.get("channels", []): + if ch.get("is_member"): + ids.append(ch["id"]) + cursor = (resp.get("response_metadata") or {}).get("next_cursor") + if not cursor: + break + time.sleep(0.2) # gentle on Tier 2 rate limit + return ids + except Exception: # noqa: BLE001 + log.exception("failed to enumerate all-public channels") + return [] + return [] + + +def _candidates_from_search(cfg: Config, slack: SlackClient) -> list[Trigger]: + cutoff = datetime.now(tz=timezone.utc) - timedelta(minutes=cfg.poll_lookback_minutes) + after = cutoff.strftime("%Y-%m-%d") + out: list[Trigger] = [] + for phrase in cfg.trigger_phrases: + q = f'"{phrase}" after:{after}' + for hit in slack.search_messages(q, count=50): + ts = hit.get("ts") or "" + if not ts: + continue + if float(ts) < cutoff.timestamp(): + continue + channel = (hit.get("channel") or {}).get("id") or "" + if not channel: + continue + out.append( + Trigger( + channel=channel, + channel_name=(hit.get("channel") or {}).get("name"), + ts=ts, + thread_ts=hit.get("thread_ts"), + user=hit.get("user"), + text=hit.get("text", ""), + ) + ) + # De-duplicate by (channel, ts) - the same message may match multiple phrases. + seen: set[tuple[str, str]] = set() + deduped: list[Trigger] = [] + for t in out: + key = (t.channel, t.ts) + if key in seen: + continue + seen.add(key) + deduped.append(t) + return deduped + + +def _run_agent_and_reply(cfg: Config, slack: SlackClient, trigger: Trigger) -> None: + from openhands.sdk import Conversation + from openhands.tools.preset.default import get_default_agent + from openhands.workspace import OpenHandsCloudWorkspace + + api_key = os.environ["OPENHANDS_API_KEY"] + api_url = os.environ["OPENHANDS_CLOUD_API_URL"] + prompt = build_prompt(trigger, slack, cfg) + log.info("assembled prompt for %s/%s (%d chars)", trigger.channel, trigger.ts, len(prompt)) + + with OpenHandsCloudWorkspace( + local_agent_server_mode=True, + cloud_api_url=api_url, + cloud_api_key=api_key, + ) as workspace: + llm = workspace.get_llm() + secrets = workspace.get_secrets() + agent = get_default_agent(llm=llm, cli_mode=True) + conversation = Conversation(agent=agent, workspace=workspace) + if secrets: + conversation.update_secrets(secrets) + conversation.send_message(prompt) + conversation.run() + + final_text = _final_assistant_text(conversation) or "(agent produced no text output)" + if cfg.reply_mode != "none": + slack.post_thread_reply(trigger.channel, trigger.reply_thread_ts, final_text) + conversation.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/slack-reply/scripts/config.py b/plugins/slack-reply/scripts/config.py new file mode 100644 index 00000000..56b1d631 --- /dev/null +++ b/plugins/slack-reply/scripts/config.py @@ -0,0 +1,92 @@ +"""Configuration loaded from environment variables for the slack-reply scripts. + +The `slack-channel-listener` skill bakes these into the automation when it is +created. They are read fresh on every run so a `PATCH` to the automation env +can change behaviour without rebuilding the tarball. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +def _csv(name: str) -> list[str]: + raw = os.environ.get(name, "").strip() + if not raw: + return [] + return [item.strip() for item in raw.split(",") if item.strip()] + + +def _bool(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _int(name: str, default: int) -> int: + raw = os.environ.get(name) + if raw is None or raw.strip() == "": + return default + try: + return int(raw) + except ValueError: + return default + + +@dataclass(frozen=True) +class Config: + bot_token: str + trigger_phrases: list[str] + scope: str # single | list | all-public | all-accessible + channel_id: str | None + channel_ids: list[str] = field(default_factory=list) + include_thread_context: bool = True + include_channel_recent: int = 0 + resolve_user_ids: bool = True + reply_mode: str = "thread" # none | thread | thread+reaction + ack_reaction: str = "eyes" + done_reaction: str = "white_check_mark" + fail_reaction: str = "warning" + poll_lookback_minutes: int = 15 + + @classmethod + def from_env(cls) -> "Config": + scope = os.environ.get("SLACK_CHANNEL_SCOPE", "single").strip().lower() + if scope not in {"single", "list", "all-public", "all-accessible"}: + raise ValueError( + f"SLACK_CHANNEL_SCOPE must be single|list|all-public|all-accessible, got: {scope!r}" + ) + + phrases = _csv("SLACK_TRIGGER_PHRASES") + if not phrases: + raise ValueError("SLACK_TRIGGER_PHRASES is required (comma-separated list)") + + return cls( + bot_token=os.environ["SLACK_BOT_TOKEN"], + trigger_phrases=phrases, + scope=scope, + channel_id=(os.environ.get("SLACK_CHANNEL_ID") or None), + channel_ids=_csv("SLACK_CHANNEL_IDS"), + include_thread_context=_bool("SLACK_INCLUDE_THREAD_CONTEXT", True), + include_channel_recent=_int("SLACK_INCLUDE_CHANNEL_RECENT", 0), + resolve_user_ids=_bool("SLACK_RESOLVE_USER_IDS", True), + reply_mode=os.environ.get("SLACK_REPLY_MODE", "thread").strip().lower(), + ack_reaction=os.environ.get("SLACK_ACK_REACTION", "eyes"), + done_reaction=os.environ.get("SLACK_DONE_REACTION", "white_check_mark"), + fail_reaction=os.environ.get("SLACK_FAIL_REACTION", "warning"), + poll_lookback_minutes=_int("SLACK_POLL_LOOKBACK_MINUTES", 15), + ) + + def matches_phrase(self, text: str) -> bool: + lower = text.lower() + return any(p.lower() in lower for p in self.trigger_phrases) + + def channel_in_scope(self, channel_id: str) -> bool: + if self.scope == "single": + return channel_id == self.channel_id + if self.scope == "list": + return channel_id in self.channel_ids + # all-public / all-accessible are scoped at the Slack-app / token level + # rather than at the filter level, so we accept everything that reaches us. + return True diff --git a/plugins/slack-reply/scripts/prompt.py b/plugins/slack-reply/scripts/prompt.py new file mode 100644 index 00000000..63be2e1e --- /dev/null +++ b/plugins/slack-reply/scripts/prompt.py @@ -0,0 +1,88 @@ +"""Build the agent prompt from a Slack message + optional surrounding context.""" +from __future__ import annotations + +from dataclasses import dataclass + +from config import Config +from slack_client import SlackClient, iter_message_chunks + + +@dataclass(frozen=True) +class Trigger: + """The Slack message that caused this automation run.""" + channel: str + channel_name: str | None + ts: str + thread_ts: str | None # parent thread ts, or None if not in a thread + user: str | None + text: str + + @property + def reply_thread_ts(self) -> str: + """Always reply in a thread - if the trigger is not yet a thread, start one at its ts.""" + return self.thread_ts or self.ts + + +def build_prompt(trigger: Trigger, slack: SlackClient, cfg: Config) -> str: + """Compose the initial agent message from the trigger and configured context.""" + pieces: list[str] = [] + + triggered_by = slack.display_name(trigger.user) if trigger.user else "an unknown user" + channel_label = f"#{trigger.channel_name}" if trigger.channel_name else trigger.channel + permalink = slack.get_permalink(trigger.channel, trigger.ts) + + header = [ + f"You were triggered by a Slack message in {channel_label}.", + f"Triggering user: @{triggered_by}", + ] + if permalink: + header.append(f"Permalink: {permalink}") + pieces.append("\n".join(header)) + + if cfg.include_thread_context and trigger.thread_ts and trigger.thread_ts != trigger.ts: + thread_msgs = slack.conversations_replies(trigger.channel, trigger.thread_ts, limit=50) + # Exclude the trigger message itself from "prior" context (it appears at the end). + prior = [m for m in thread_msgs if m.get("ts") != trigger.ts] + if prior: + rendered = _render_messages(prior, slack, cfg) + pieces.append(f"Thread context ({len(prior)} prior messages):\n{rendered}") + + if cfg.include_channel_recent > 0: + recent = slack.conversations_history( + trigger.channel, latest=trigger.ts, limit=cfg.include_channel_recent + ) + # `latest` includes the boundary - drop the trigger message if present. + recent = [m for m in recent if m.get("ts") != trigger.ts] + if recent: + rendered = _render_messages(recent, slack, cfg) + pieces.append( + f"Recent channel context ({len(recent)} messages before the trigger):\n{rendered}" + ) + + trigger_text = ( + slack.resolve_mentions(trigger.text) if cfg.resolve_user_ids else trigger.text + ) + pieces.append(f"Triggering message:\n @{triggered_by}: {trigger_text}") + + pieces.append( + "Treat the triggering message (with the trigger phrase stripped) as the user's " + "request and respond to it. When you finish, return your final answer as your " + "last assistant message - the automation will post it back to Slack as a " + "threaded reply automatically." + ) + + return "\n\n".join(pieces) + + +def _render_messages(messages: list[dict], slack: SlackClient, cfg: Config) -> str: + lines: list[str] = [] + for ts, user_id, text in iter_message_chunks(messages): + if cfg.resolve_user_ids and user_id and user_id.startswith(("U", "W")): + speaker = f"@{slack.display_name(user_id)}" + elif user_id: + speaker = f"<{user_id}>" + else: + speaker = "" + body = slack.resolve_mentions(text) if cfg.resolve_user_ids else text + lines.append(f" {speaker}: {body}") + return "\n".join(lines) diff --git a/plugins/slack-reply/scripts/setup.sh b/plugins/slack-reply/scripts/setup.sh new file mode 100644 index 00000000..08dde638 --- /dev/null +++ b/plugins/slack-reply/scripts/setup.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Setup script for Slack-triggered OpenHands automations. +# Installs uv, the OpenHands SDK, and the Slack SDK in the automation sandbox. +set -euo pipefail + +if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +fi + +uv pip install -q --system \ + openhands-sdk \ + openhands-workspace \ + openhands-tools \ + "slack_sdk>=3.27" diff --git a/plugins/slack-reply/scripts/slack_client.py b/plugins/slack-reply/scripts/slack_client.py new file mode 100644 index 00000000..8d111bbf --- /dev/null +++ b/plugins/slack-reply/scripts/slack_client.py @@ -0,0 +1,152 @@ +"""Thin Slack Web API wrapper used by the slack-reply scripts. + +We use the official `slack_sdk` to handle retries and rate limits. All +methods are best-effort: callers should treat failures as recoverable and +fall back to logging rather than crashing the automation run. +""" +from __future__ import annotations + +import logging +import re +from typing import Iterable + +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +log = logging.getLogger(__name__) + +_USER_MENTION_RE = re.compile(r"<@([UW][A-Z0-9]+)>") + + +class SlackClient: + def __init__(self, token: str): + self._client = WebClient(token=token) + self._user_cache: dict[str, str] = {} + + # ---------------- posting ---------------- + + def post_thread_reply(self, channel: str, thread_ts: str, text: str) -> dict | None: + try: + return self._client.chat_postMessage( + channel=channel, + thread_ts=thread_ts, + text=text, + reply_broadcast=False, + unfurl_links=False, + unfurl_media=False, + ).data + except SlackApiError as e: + log.warning("chat.postMessage failed: %s", e.response.get("error")) + return None + + def add_reaction(self, channel: str, ts: str, name: str) -> None: + try: + self._client.reactions_add(channel=channel, timestamp=ts, name=name) + except SlackApiError as e: + # already_reacted is a no-op for our purposes + err = e.response.get("error") + if err and err != "already_reacted": + log.warning("reactions.add(%s) failed: %s", name, err) + + # ---------------- reading ---------------- + + def get_permalink(self, channel: str, ts: str) -> str | None: + try: + return self._client.chat_getPermalink(channel=channel, message_ts=ts).get("permalink") + except SlackApiError: + return None + + def conversations_history( + self, channel: str, *, latest: str | None = None, oldest: str | None = None, limit: int = 20 + ) -> list[dict]: + try: + resp = self._client.conversations_history( + channel=channel, latest=latest, oldest=oldest, limit=limit + ) + return list(resp.get("messages", [])) + except SlackApiError as e: + log.warning("conversations.history failed: %s", e.response.get("error")) + return [] + + def conversations_replies(self, channel: str, thread_ts: str, *, limit: int = 50) -> list[dict]: + try: + resp = self._client.conversations_replies( + channel=channel, ts=thread_ts, limit=limit + ) + return list(resp.get("messages", [])) + except SlackApiError as e: + log.warning("conversations.replies failed: %s", e.response.get("error")) + return [] + + def reactions_on_message(self, channel: str, ts: str) -> list[dict]: + try: + resp = self._client.reactions_get(channel=channel, timestamp=ts) + return list((resp.get("message") or {}).get("reactions", [])) + except SlackApiError as e: + err = e.response.get("error") + # `no_reaction` is the expected "nothing here" response + if err and err != "no_reaction": + log.warning("reactions.get failed: %s", err) + return [] + + def has_reaction_from_self(self, channel: str, ts: str, name: str, self_user_id: str) -> bool: + for r in self.reactions_on_message(channel, ts): + if r.get("name") == name and self_user_id in (r.get("users") or []): + return True + return False + + # ---------------- search (multi-channel poll mode) ---------------- + + def search_messages(self, query: str, *, count: int = 50) -> list[dict]: + """Workspace-wide message search. Requires a user token with `search:read`.""" + try: + resp = self._client.search_messages(query=query, count=count, sort="timestamp", sort_dir="desc") + return list(((resp.get("messages") or {}).get("matches")) or []) + except SlackApiError as e: + log.warning("search.messages failed: %s", e.response.get("error")) + return [] + + # ---------------- identity / user resolution ---------------- + + def auth_test(self) -> dict: + try: + return self._client.auth_test().data + except SlackApiError as e: + log.warning("auth.test failed: %s", e.response.get("error")) + return {} + + def display_name(self, user_id: str) -> str: + if user_id in self._user_cache: + return self._user_cache[user_id] + try: + resp = self._client.users_info(user=user_id) + user = resp.get("user") or {} + profile = user.get("profile") or {} + name = ( + profile.get("display_name_normalized") + or profile.get("real_name_normalized") + or user.get("name") + or user_id + ) + except SlackApiError: + name = user_id + self._user_cache[user_id] = name + return name + + def resolve_mentions(self, text: str) -> str: + """Replace `<@U123>` mentions with `@DisplayName` for human readability.""" + def _sub(m: re.Match) -> str: + return f"@{self.display_name(m.group(1))}" + + return _USER_MENTION_RE.sub(_sub, text) + + +def iter_message_chunks(messages: Iterable[dict]) -> Iterable[tuple[str, str, str]]: + """Yield `(ts, user_id, text)` for renderable messages, oldest first.""" + seen: list[tuple[str, str, str]] = [] + for m in messages: + if m.get("subtype") in {"channel_join", "channel_leave"}: + continue + seen.append((m.get("ts", ""), m.get("user") or m.get("bot_id") or "?", m.get("text", ""))) + seen.sort(key=lambda row: row[0]) + yield from seen diff --git a/skills/slack-channel-listener/README.md b/skills/slack-channel-listener/README.md new file mode 100644 index 00000000..7489a0b7 --- /dev/null +++ b/skills/slack-channel-listener/README.md @@ -0,0 +1,36 @@ +# slack-channel-listener + +Set up an OpenHands automation that watches a Slack channel (or many) for a +configurable trigger phrase, runs an agent conversation in response, and +posts the agent's final answer back as a threaded reply. + +## Two trigger styles + +| Style | When to use | Setup cost | +|---|---|---| +| **Push** (Slack Events API webhook) | Backend is publicly reachable from Slack (OpenHands Cloud, or self-hosted with HTTPS ingress). Lowest latency. | Slack app with Event Subscriptions + signing secret. | +| **Poll** (cron + `conversations.history` or `search.messages`) | Laptop, corporate firewall, or any host without inbound HTTPS. Higher latency = cron interval. | Just a bot/user token; no Slack-app changes. | + +The skill auto-detects which path to recommend based on the user's runtime. + +## What gets created + +- A **custom-automation** tarball uploaded to the OpenHands Automations API. +- One automation per channel scope, with these environment variables baked in: + - `SLACK_BOT_TOKEN`, optional `SLACK_USER_TOKEN` + - `SLACK_TRIGGER_PHRASES` (comma-separated) + - `SLACK_CHANNEL_SCOPE` and one of `SLACK_CHANNEL_ID` / `SLACK_CHANNEL_IDS` + - `SLACK_REPLY_MODE`, context flags, reaction names +- For push mode: a custom webhook source registered on the automation + backend (the skill prints the webhook URL the user must paste into Slack's + *Event Subscriptions* page). + +See `SKILL.md` for the guided flow and the files under `references/` for +deep-dives on each path. + +## Status + +Draft / first iteration. Multi-channel `all-public` and `all-accessible` +modes work end-to-end but have only been exercised against a small Slack +workspace; expect refinements as the skill gets more usage. See the open PR +on `OpenHands/extensions` for known follow-ups. diff --git a/skills/slack-channel-listener/SKILL.md b/skills/slack-channel-listener/SKILL.md new file mode 100644 index 00000000..306735fc --- /dev/null +++ b/skills/slack-channel-listener/SKILL.md @@ -0,0 +1,138 @@ +--- +name: slack-channel-listener +description: >- + Set up an OpenHands automation that listens to a Slack channel (or many) + for a configurable trigger phrase and starts a new agent conversation + whenever someone posts it, replying threaded to the triggering message. + Supports both Slack Events API (push) for publicly-reachable backends and + cron polling for laptops and firewalled environments. +triggers: + - slack channel listener + - slack listener automation + - listen to slack channel + - slack trigger phrase + - slack mention automation + - /slack-listener +--- + +# slack-channel-listener + +Create an OpenHands automation that watches one or more Slack channels for a +configurable trigger phrase (for example `@thems-fightin-words`, `/triage`, +`hey openhands`) and starts a new agent conversation per match. The agent's +final answer is posted back as a threaded reply to the triggering message. + +## Decision flow + +Walk the user through these decisions in order. Do not silently pick - ask +each question explicitly and confirm before creating anything. + +### Step 1 - confirm goal and trigger phrases + +Ask: + +1. What channel(s) should be monitored? (one ID, several IDs, "all public + channels the bot can see", or "every channel my user account can see") +2. What trigger phrase(s) should activate the agent? (one or more strings, + case-insensitive substring match) +3. Where will this automation run? (OpenHands Cloud, self-hosted with public + ingress, or a laptop / firewalled host) + +Surface that trigger phrases match anywhere in the message text - if they +need exact `@mention` semantics, recommend a leading `@`. + +### Step 2 - pick push vs poll + +Use this table: + +| User's runtime | Recommended | Why | +|---|---|---| +| OpenHands Cloud | Push (Events API) | Backend is publicly reachable; lowest latency. | +| Self-hosted with public HTTPS ingress | Push | Same. Confirm the ingress can receive POST from Slack. | +| Laptop / firewalled host / unsure | Poll | Outbound HTTPS only; works everywhere. | + +If the user is unsure whether their backend is reachable, prefer **poll** - +no Slack-app changes are needed. + +For multi-channel scope `all-accessible`, **poll is the only mode that +makes sense**; push requires the bot to be invited to each channel. Explain +this and recommend `search.messages` with a user token (requires +`search:read`). + +### Step 3 - reply behaviour + +Default: `thread` (post the agent's final assistant message as a threaded +reply). Offer `thread+reaction` (also leaves a `👀` reaction on start and a +`✅` on success / `⚠️` on failure) and `none` (silent). + +### Step 4 - context options + +Defaults are sensible; offer to change only if the user asks. See +`references/context-options.md` for the full list. Common toggles: + +- `SLACK_INCLUDE_THREAD_CONTEXT=true` (default) - pull prior thread replies + when the trigger is inside a thread. +- `SLACK_INCLUDE_CHANNEL_RECENT=0` (default) - set to N to include the N + most recent channel messages as background. + +### Step 5 - gather credentials + +You will need: + +- A Slack **bot token** (`xoxb-...`) with scopes: + - `chat:write` - to post the reply. + - `reactions:read`, `reactions:write` - to mark progress (and, in poll + mode, for the reaction-based state machine). + - `channels:history` - to read public channel messages. + - `groups:history` - if monitoring private channels. + - `users:read` - if `resolve_user_ids` is enabled (default on). +- For push mode: a Slack **signing secret** and a Slack app with Event + Subscriptions enabled. Walkthrough: `references/push-setup.md`. +- For poll mode with `scope=all-accessible`: a Slack **user token** + (`xoxp-...`) with `search:read`. Walkthrough: `references/poll-setup.md`. +- An OpenHands API key (`OPENHANDS_API_KEY` / `OPENHANDS_CLOUD_API_KEY`) for + the automation API itself. + +**Never echo secrets back to the user verbatim** - confirm by length or +prefix instead. Store them as automation environment variables, not in the +tarball. + +### Step 6 - assemble and upload + +Build a custom-automation tarball that contains the contents of +`plugins/slack-reply/scripts/`. The entrypoint is: + +- Push mode: `python agent_event.py`, setup `setup.sh`, trigger `event`. +- Poll mode: `python agent_poll.py`, setup `setup.sh`, trigger `cron`. + +See `references/tarball-build.md` for the full step-by-step (fetch the +plugin from this repo, tar it up, upload, then create the automation). + +### Step 7 - verify + +- Push mode: ask the user to confirm Slack URL verification succeeded + (Slack's *Event Subscriptions* page shows "Verified" with a green tick). + Have them post a test message containing the trigger phrase and wait for + the threaded reply. +- Poll mode: manually dispatch a run via the Automations API and check + that messages older than `SLACK_POLL_LOOKBACK_MINUTES` aren't acted on. + +## Non-goals (out of scope for this skill) + +- Interactive Slack components (buttons, modals, slash commands) - the agent + responds with plain text only. +- Streaming partial agent output to Slack - the reply is posted once when + the agent goes idle. Update the prompt if you want progress updates. +- Per-user permission enforcement - any user posting the trigger phrase in + an allowed channel will spawn a conversation. If you need allow/deny + lists, add a filter to the automation trigger (push) or extend + `Config.matches_phrase` (poll). + +## See also + +- `plugins/slack-reply` - the Python building blocks that this skill + assembles into a tarball. +- `skills/openhands-automation` - the underlying automation API. +- `references/push-setup.md`, `references/poll-setup.md`, + `references/multi-channel.md`, `references/context-options.md`, + `references/tarball-build.md` - in this directory. diff --git a/skills/slack-channel-listener/commands/slack-listener.md b/skills/slack-channel-listener/commands/slack-listener.md new file mode 100644 index 00000000..98446a75 --- /dev/null +++ b/skills/slack-channel-listener/commands/slack-listener.md @@ -0,0 +1,8 @@ +--- +# auto-generated by sync_extensions.py +description: Set up an OpenHands automation that listens to a Slack channel (or many) for a configurable trigger phrase and starts a new agent conversation whenever someone posts it, replying threaded to the triggering message. Supports both Slack Events API (push) for publicly-reachable backends and cron polling for laptops and firewalled environments. +--- + +Read and follow the complete instructions in the SKILL.md file located in this skill's directory. + +$ARGUMENTS diff --git a/skills/slack-channel-listener/references/context-options.md b/skills/slack-channel-listener/references/context-options.md new file mode 100644 index 00000000..8ed7b4ca --- /dev/null +++ b/skills/slack-channel-listener/references/context-options.md @@ -0,0 +1,60 @@ +# Context options - what the agent sees + +By default the agent receives a structured preamble like: + +``` +You were triggered by a Slack message in #tim-test-openhands-5. +Triggering user: @tim +Permalink: https://example.slack.com/archives/C.../p... + +Thread context (3 prior messages): + @alice: ... + @bob: ... + @tim: ... + +Triggering message: + @tim: @thems-fightin-words please summarise the design discussion above. + +Treat the triggering message (with the trigger phrase stripped) as the +user's request and respond to it. When you finish, return your final +answer as your last assistant message - the automation will post it back +to Slack as a threaded reply automatically. +``` + +Each piece is gated by a configuration flag. + +## Flags + +| Variable | Default | Effect | +|---|---|---| +| `SLACK_INCLUDE_THREAD_CONTEXT` | `true` | If the triggering message is inside a thread, fetch the parent + prior replies (up to 50) and include them. Adds one `conversations.replies` call. | +| `SLACK_INCLUDE_CHANNEL_RECENT` | `0` | Number of channel messages before the trigger to include. `0` disables. Adds one `conversations.history` call. | +| `SLACK_RESOLVE_USER_IDS` | `true` | Replace `<@U12345>` with `@DisplayName`. Adds one `users.info` call per unique user (cached for the run). | + +## Things you might want next (not yet implemented) + +These would be additive and live behind their own flags: + +| Idea | Sketch | +|---|---| +| `SLACK_INCLUDE_FILES` | Pass `files` metadata from the message (name, mimetype, `url_private`) into the prompt; agent can fetch with the bot token. | +| `SLACK_INCLUDE_REACTIONS` | Pass current reactions on the trigger and thread messages - sometimes a useful priority / sentiment signal. | +| `SLACK_INCLUDE_CHANNEL_TOPIC` | Pull the channel topic/purpose from `conversations.info` so the agent understands the channel's role. | +| `SLACK_STRIP_TRIGGER` | Remove the trigger phrase from the triggering message before passing it to the agent. (Today the agent is told to ignore it.) | + +PRs welcome - keep each behind a flag with a safe default. + +## Cost considerations + +Every extra API call before the agent runs adds latency to the user's +threaded reply. Rough numbers: + +- `chat.getPermalink`: ~50 ms. +- `users.info`: ~100 ms each, mostly Slack roundtrip. +- `conversations.replies` (thread context): ~200 ms. +- `conversations.history` (channel context): ~200 ms. + +These are all under a second in aggregate for a typical message, but if +you crank `SLACK_INCLUDE_CHANNEL_RECENT` very high or `resolve_user_ids` +across a long thread, you'll feel it. Profile before adding more +enrichment. diff --git a/skills/slack-channel-listener/references/multi-channel.md b/skills/slack-channel-listener/references/multi-channel.md new file mode 100644 index 00000000..86612541 --- /dev/null +++ b/skills/slack-channel-listener/references/multi-channel.md @@ -0,0 +1,67 @@ +# Multi-channel scope + +The skill supports four scope modes via `SLACK_CHANNEL_SCOPE`: + +| Scope | Means | Push viable? | Poll viable? | +|---|---|---|---| +| `single` | Exactly one channel. `SLACK_CHANNEL_ID` is the channel ID. | Yes - filter on `event.channel == 'C...'`. | Yes. | +| `list` | A specific set of channels. `SLACK_CHANNEL_IDS` is comma-separated. | Yes - filter on `contains(['C1','C2'], event.channel)`. | Yes. | +| `all-public` | Every public channel the **bot** is a member of. | Yes (bot must be invited to each channel for `message.channels` to fire there). | Yes (lists channels with `conversations.list` + `is_member`). | +| `all-accessible` | Every channel the **user token** can see (DMs included). | Not really - apps cannot self-join channels en masse; use poll. | Yes - uses `search.messages`. | + +## How to grow the scope safely + +- Start with `single` for the first dogfooding pass. The blast radius is + contained and you'll catch surprises (deleted messages, edits, + re-deliveries) on one channel before fanning out. +- Move to `list` once you know exactly which channels you want. +- `all-public` only makes sense for a workspace-wide assistant. Be aware + the bot will only fire in channels it's been invited to; add a small + helper to `conversations.join` every public channel if you want it + everywhere, or rely on humans inviting it as needed. +- `all-accessible` is the right choice for a "personal assistant" pattern + - "anything I can see, the agent can react to" - but it requires a user + token, which is more sensitive than a bot token. Treat it accordingly. + +## Push: inviting the bot in bulk + +For `all-public` push, you can script the invites: + +```bash +# Requires the bot token to have `channels:join` scope. +slack_token="$SLACK_BOT_TOKEN" +cursor="" +while :; do + resp=$(curl -s -H "Authorization: Bearer $slack_token" \ + "https://slack.com/api/conversations.list?types=public_channel&limit=200&cursor=$cursor") + echo "$resp" | jq -r '.channels[] | select(.is_member==false) | .id' | \ + while read -r ch; do + curl -s -X POST -H "Authorization: Bearer $slack_token" \ + -d "channel=$ch" https://slack.com/api/conversations.join >/dev/null + done + cursor=$(echo "$resp" | jq -r '.response_metadata.next_cursor // ""') + [ -z "$cursor" ] && break +done +``` + +New channels created after this runs won't auto-join. Subscribe to the +`channel_created` event in the Slack app and add a tiny automation that +calls `conversations.join` on each one if you need that. + +## Poll: paging through results + +- `conversations.history` pages per channel - the script keeps `limit=50` + per channel per cron tick, which is plenty unless your channel volume + exceeds 50 matches in `SLACK_POLL_LOOKBACK_MINUTES` (in which case you + have bigger problems). +- `search.messages` is paginated; the script asks for `count=50` per + trigger phrase per cron tick. Same logic. + +## Cross-cutting: rate limits + +The Slack Web API enforces per-method tiers. Most read methods are Tier 3 +(~50 req/min). The poll script does roughly one `conversations.history` +per channel per cron tick and one `users.info` per unique mentioned user +(cached for the run). For typical scopes the load is negligible; for +hundreds of channels in `all-public`, consider a longer cron interval or +move to push. diff --git a/skills/slack-channel-listener/references/poll-setup.md b/skills/slack-channel-listener/references/poll-setup.md new file mode 100644 index 00000000..025bfdbc --- /dev/null +++ b/skills/slack-channel-listener/references/poll-setup.md @@ -0,0 +1,96 @@ +# Poll mode setup (cron + Slack Web API) + +Use this path when the OpenHands automation backend is **not** reachable +from the public internet - laptops, hosts behind a corporate firewall, or +any deployment where you don't want to expose an inbound endpoint. The +automation runs on a schedule and polls Slack via outbound HTTPS. + +## How it stays correct across runs + +The script uses **message reactions as persistent state**. For each matching +message it finds: + +1. Check whether the bot has already added the `ack_reaction` (default + `eyes` 👀). If yes → skip; another run has claimed (or completed) it. +2. Add `ack_reaction` to claim the message. +3. Run the agent. On success → add `done_reaction` (default `white_check_mark` + ✅). On failure → add `fail_reaction` (default `warning` ⚠️) and post a + short error reply to the thread. + +This makes the poller idempotent and concurrent-safe: re-running the cron +job at any frequency cannot double-process a message, and the state is +visible to humans in Slack itself - no external store needed. + +`SLACK_POLL_LOOKBACK_MINUTES` (default 15) bounds how far back the poller +looks; set it to comfortably exceed your cron interval so a delayed run +doesn't miss messages. + +## Required scopes + +| Scope | When you need it | +|---|---| +| `chat:write` | Always (post threaded replies). | +| `reactions:read`, `reactions:write` | Always (state machine). | +| `channels:history` | Scopes `single` / `list` / `all-public` for public channels. | +| `groups:history` | Private channels. | +| `users:read` | If `SLACK_RESOLVE_USER_IDS=true` (default). | +| `search:read` | **Only** for scope `all-accessible`. Must be on a **user token** (`xoxp-...`), not a bot token. | + +## Create the automation + +```bash +curl -X POST "${OPENHANDS_HOST}/api/automation/v1" \ + -H "Authorization: Bearer ${OPENHANDS_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"Slack poll listener (#${CHANNEL_NAME})\", + \"trigger\": { + \"type\": \"cron\", + \"schedule\": \"*/2 * * * *\", + \"timezone\": \"UTC\" + }, + \"tarball_path\": \"${TARBALL_PATH}\", + \"entrypoint\": \"python agent_poll.py\", + \"setup_script_path\": \"setup.sh\", + \"timeout\": 600, + \"env\": { + \"SLACK_BOT_TOKEN\": \"xoxb-...\", + \"SLACK_TRIGGER_PHRASES\": \"@thems-fightin-words\", + \"SLACK_CHANNEL_SCOPE\": \"single\", + \"SLACK_CHANNEL_ID\": \"${CHANNEL_ID}\", + \"SLACK_REPLY_MODE\": \"thread+reaction\", + \"SLACK_POLL_LOOKBACK_MINUTES\": \"15\" + } + }" +``` + +For `scope=all-accessible`, set `SLACK_BOT_TOKEN` to a user token (the +script uses `search.messages` which only works on user tokens) and ensure +`search:read` is granted. + +## Cron cadence recommendations + +| Trigger volume | Recommended cadence | +|---|---| +| Rare (a few times per day) | `*/15 * * * *` | +| Active channel (chat-bot-like) | `*/2 * * * *` | +| Need < 1 min latency | Use push mode instead - polling that fast burns sandbox time even when there's nothing to do. | + +The script exits in well under a second if there are no matches, so a +busy cron schedule mostly just costs a few cron dispatches per day, not +LLM tokens. The LLM runs only when an actual match is processed. + +## Catching up after downtime + +If the automation was disabled or the host was offline for longer than +`SLACK_POLL_LOOKBACK_MINUTES`, messages from that gap will not be picked +up. To force a backfill, temporarily increase +`SLACK_POLL_LOOKBACK_MINUTES` (PATCH the automation), dispatch a manual +run, then revert. + +## Disambiguating from push mode + +If you have both push and poll automations on the same channel, you will +double-process messages. Pick one. The poll-mode reactions will not +prevent push-mode runs because push doesn't check reactions before +spinning up. diff --git a/skills/slack-channel-listener/references/push-setup.md b/skills/slack-channel-listener/references/push-setup.md new file mode 100644 index 00000000..a16d69ae --- /dev/null +++ b/skills/slack-channel-listener/references/push-setup.md @@ -0,0 +1,124 @@ +# Push mode setup (Slack Events API) + +Use this path when the OpenHands automation backend is reachable from the +public internet (OpenHands Cloud, or a self-hosted deployment with HTTPS +ingress). Slack will POST events to a webhook URL on the automation +backend; the automation runs once per matching message. + +## 1. Create / configure the Slack app + +1. Go to . Pick an existing app, or click + **Create New App** → **From scratch**. +2. Under **OAuth & Permissions** → **Bot Token Scopes**, add: + - `chat:write` + - `reactions:read` + - `reactions:write` + - `channels:history` (public channels) + - `groups:history` (private channels - optional) + - `users:read` (only if `SLACK_RESOLVE_USER_IDS=true`, which is the default) +3. (Re)install the app to your workspace and copy the **Bot User OAuth + Token** (`xoxb-...`). +4. Under **Basic Information** → **App Credentials**, copy the **Signing + Secret**. + +## 2. Register the custom webhook on the automation backend + +Slack's signature header is `X-Slack-Signature` and its event type lives at +the top of the payload. Register a webhook source with those settings: + +```bash +curl -X POST "${OPENHANDS_HOST}/api/automation/v1/webhooks" \ + -H "Authorization: Bearer ${OPENHANDS_API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Slack Events", + "source": "slack", + "event_key_expr": "event.type", + "signature_header": "X-Slack-Signature", + "webhook_secret": "" + }' +``` + +The response contains a `webhook_url` like +`https://app.all-hands.dev/v1/events//slack`. Save it. + +> If the user does not yet have a signing secret, **do not** ask the +> backend to generate one - Slack signs requests with its own secret, so it +> must match the value Slack will use. Have the user grab the secret from +> the Slack app's *Basic Information* page first. + +## 3. Wire Slack to the webhook + +1. In the Slack app, open **Event Subscriptions** and toggle **Enable + Events** on. +2. Paste the `webhook_url` from step 2 into **Request URL**. Slack will + immediately POST a `url_verification` payload; the automation backend's + default Slack handler echoes back the `challenge` and the field flips to + "Verified ✓" within a few seconds. If it stays red: + - Confirm the URL is publicly reachable (try a `curl -I` from outside + your network). + - Confirm the signing secret in step 2 matches the Slack app's secret. +3. Under **Subscribe to bot events**, add: + - `message.channels` (public channels) + - `message.groups` (private channels - optional) +4. **Save Changes** and reinstall the app if Slack prompts you to. +5. **Invite the bot** to each channel you want to monitor: + `/invite @YourBotName` in the channel. + +## 4. Create the automation + +Build the tarball from `plugins/slack-reply/scripts/` and upload it (see +`tarball-build.md`). Then create the automation with an event trigger: + +```bash +curl -X POST "${OPENHANDS_HOST}/api/automation/v1" \ + -H "Authorization: Bearer ${OPENHANDS_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"Slack listener (#${CHANNEL_NAME})\", + \"trigger\": { + \"type\": \"event\", + \"source\": \"slack\", + \"on\": \"message\", + \"filter\": \"event.channel == '${CHANNEL_ID}' && (icontains(event.text, '${PHRASE1}') || icontains(event.text, '${PHRASE2}')) && !event.bot_id\" + }, + \"tarball_path\": \"${TARBALL_PATH}\", + \"entrypoint\": \"python agent_event.py\", + \"setup_script_path\": \"setup.sh\", + \"timeout\": 600, + \"env\": { + \"SLACK_BOT_TOKEN\": \"xoxb-...\", + \"SLACK_TRIGGER_PHRASES\": \"${PHRASE1},${PHRASE2}\", + \"SLACK_CHANNEL_SCOPE\": \"single\", + \"SLACK_CHANNEL_ID\": \"${CHANNEL_ID}\", + \"SLACK_REPLY_MODE\": \"thread+reaction\" + } + }" +``` + +### Filter expressions, concretely + +The `filter` is a JMESPath against the Slack event payload. Useful patterns: + +| Goal | Expression | +|---|---| +| Single channel + single phrase | `event.channel == 'C123' && icontains(event.text, '@thems-fightin-words')` | +| Single channel + any of several phrases | `event.channel == 'C123' && (icontains(event.text, '/triage') \|\| icontains(event.text, 'hey openhands'))` | +| Several specific channels | `contains(['C123','C456'], event.channel) && icontains(event.text, '@bot')` | +| Any channel + suppress bots and edits | `!event.bot_id && event.subtype != 'message_changed' && event.subtype != 'bot_message' && icontains(event.text, '@bot')` | + +The script also re-checks the phrase / scope in Python before calling the +agent, so a slightly looser filter is fine - the filter is the cheap first +pass, the script is the source of truth. + +## Limitations + +- One Slack app per webhook source name. If you already use the `slack` + source for another integration, pick a different name (e.g. `slack-team-a`). +- Slack retries delivery on a 3s timeout. The automation backend ack must + happen quickly; the agent run itself happens asynchronously after the ack, + so this is usually fine. +- Slack's URL verification challenge is handled by the backend, but if you + see it flowing through to the automation script (unusual), the script + will return early because `event.type` is `url_verification`, not + `message`. diff --git a/skills/slack-channel-listener/references/tarball-build.md b/skills/slack-channel-listener/references/tarball-build.md new file mode 100644 index 00000000..9852c7d6 --- /dev/null +++ b/skills/slack-channel-listener/references/tarball-build.md @@ -0,0 +1,143 @@ +# Building and uploading the automation tarball + +This walks through assembling a custom-automation tarball from the +`slack-reply` plugin scripts and uploading it to the OpenHands Automations +API. + +## 1. Fetch the plugin scripts + +Two options. + +### Option A - sparse checkout from this repo + +```bash +work=$(mktemp -d) +cd "$work" +git clone --depth=1 --filter=blob:none --sparse \ + https://github.com/OpenHands/extensions.git +cd extensions +git sparse-checkout set plugins/slack-reply +``` + +The scripts live at `plugins/slack-reply/scripts/`. + +### Option B - download the tarball directly + +GitHub serves a tar of any directory via its codeload endpoint: + +```bash +work=$(mktemp -d) +cd "$work" +curl -L \ + "https://github.com/OpenHands/extensions/archive/refs/heads/main.tar.gz" \ + | tar -xz --strip=1 extensions-main/plugins/slack-reply +``` + +## 2. Assemble the upload tarball + +The Automations API tarball must contain the entrypoint at the root, with +the `setup.sh` script alongside it. Flatten: + +```bash +stage=$(mktemp -d) +cp plugins/slack-reply/scripts/*.py "$stage/" +cp plugins/slack-reply/scripts/setup.sh "$stage/" +chmod +x "$stage/setup.sh" + +# Sanity check +( cd "$stage" && python3 -m py_compile ./*.py ) + +# Tar it up +tar -czf slack-listener.tar.gz -C "$stage" . +ls -lh slack-listener.tar.gz # must be < 1 MB +``` + +## 3. Upload + +```bash +upload=$(curl -sS -X POST \ + "${OPENHANDS_HOST}/api/automation/v1/uploads?name=slack-listener" \ + -H "Authorization: Bearer ${OPENHANDS_API_KEY}" \ + -H "Content-Type: application/gzip" \ + --data-binary @slack-listener.tar.gz) + +tarball_path=$(echo "$upload" | jq -r '.tarball_path') +echo "$tarball_path" +``` + +## 4. Create the automation + +Choose the entrypoint / trigger based on the path: + +### Push mode + +```bash +curl -sS -X POST "${OPENHANDS_HOST}/api/automation/v1" \ + -H "Authorization: Bearer ${OPENHANDS_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"Slack listener (#${CHANNEL_NAME})\", + \"trigger\": { + \"type\": \"event\", + \"source\": \"slack\", + \"on\": \"message\", + \"filter\": \"event.channel == '${CHANNEL_ID}' && icontains(event.text, '${PHRASE}') && !event.bot_id\" + }, + \"tarball_path\": \"${tarball_path}\", + \"entrypoint\": \"python agent_event.py\", + \"setup_script_path\": \"setup.sh\", + \"timeout\": 600, + \"env\": { ... } + }" +``` + +### Poll mode + +```bash +curl -sS -X POST "${OPENHANDS_HOST}/api/automation/v1" \ + -H "Authorization: Bearer ${OPENHANDS_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"Slack poll listener (#${CHANNEL_NAME})\", + \"trigger\": {\"type\": \"cron\", \"schedule\": \"*/2 * * * *\"}, + \"tarball_path\": \"${tarball_path}\", + \"entrypoint\": \"python agent_poll.py\", + \"setup_script_path\": \"setup.sh\", + \"timeout\": 600, + \"env\": { ... } + }" +``` + +Full env-variable contract: see `plugins/slack-reply/SKILL.md`. + +## 5. Update the env later + +The tarball is immutable per upload, but `env` is patchable: + +```bash +curl -X PATCH "${OPENHANDS_HOST}/api/automation/v1/${automation_id}" \ + -H "Authorization: Bearer ${OPENHANDS_API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"env": {"SLACK_TRIGGER_PHRASES": "@thems-fightin-words,/triage"}}' +``` + +This means you can change trigger phrases, scope, or reply mode without +re-uploading the tarball. Re-upload only when you change code in +`plugins/slack-reply/scripts/`. + +## 6. Verify + +```bash +# Manually dispatch a run (works for both push and poll automations). +curl -X POST "${OPENHANDS_HOST}/api/automation/v1/${automation_id}/dispatch" \ + -H "Authorization: Bearer ${OPENHANDS_API_KEY}" + +# Check run status. +curl "${OPENHANDS_HOST}/api/automation/v1/${automation_id}/runs?limit=5" \ + -H "Authorization: Bearer ${OPENHANDS_API_KEY}" +``` + +For push mode, a manual dispatch passes an empty event payload, so the +script logs `event did not produce a usable trigger; skipping` and exits +cleanly - that's the expected "everything wired up" smoke signal. Real +verification is posting an actual matching message in Slack. From 5cbc38919491ef8bd8d9165a91090eb16f5fc3f2 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 19 May 2026 13:18:51 +0000 Subject: [PATCH 2/5] Persist poll-mode state in SQLite; default cron to every minute Replace the reaction-as-state design with a small SQLite database under $SLACK_STATE_DIR (default /automation/storage/state). claim() does an atomic INSERT OR IGNORE, mark_done()/mark_failed() update the row. Reactions stay available as opt-in visual UX via reply_mode=thread+reaction but no longer drive control flow, so users without reactions:read/write scopes can use plain thread mode. Also bump the recommended cron cadence from */2 * * * * to * * * * *. The state store makes high-frequency polling safe (each message gets exactly one run regardless of how often the cron fires), and the script exits in well under a second when there's nothing to do, so the cost is just a few extra sandbox dispatches per day. Co-authored-by: openhands --- plugins/slack-reply/SKILL.md | 8 +- plugins/slack-reply/scripts/agent_poll.py | 88 +++++----- plugins/slack-reply/scripts/state.py | 160 ++++++++++++++++++ .../references/poll-setup.md | 71 +++++--- .../references/tarball-build.md | 2 +- 5 files changed, 263 insertions(+), 66 deletions(-) create mode 100644 plugins/slack-reply/scripts/state.py diff --git a/plugins/slack-reply/SKILL.md b/plugins/slack-reply/SKILL.md index abdb0421..bb9d035a 100644 --- a/plugins/slack-reply/SKILL.md +++ b/plugins/slack-reply/SKILL.md @@ -31,8 +31,9 @@ OpenHands Automations API. | `scripts/slack_client.py` | Thin wrappers over the Slack Web API: history, replies, postMessage, reactions, permalinks, user resolution. | | `scripts/prompt.py` | Turns a Slack event payload (or polled message) plus optional thread/channel context into the initial agent prompt. | | `scripts/agent_event.py` | Entrypoint for **push-mode** automations. Reads the Slack event from `AUTOMATION_EVENT_PAYLOAD`, runs the agent, posts the result back. | -| `scripts/agent_poll.py` | Entrypoint for **poll-mode** automations. Scans matching messages since the last marker, runs the agent for each, posts the result back. | -| `scripts/config.py` | Loads automation-level config (trigger phrases, channel scope, context options, reply behaviour) from environment variables. | +| `scripts/agent_poll.py` | Entrypoint for **poll-mode** automations. Scans matching messages since the configured lookback, claims each via the state store, runs the agent, posts the result back. | +| `scripts/state.py` | SQLite-backed `processed_messages` store at `$SLACK_STATE_DIR/slack-listener.sqlite3`. Used by poll-mode for idempotent claim/done/failed bookkeeping across cron runs. | +| `scripts/config.py` | Loads automation-level config (trigger phrases, channel scope, context options, reply behaviour, state directory) from environment variables. | ## Configuration (environment variables read at runtime) @@ -53,7 +54,8 @@ it creates the automation; users should not edit them by hand. | `SLACK_ACK_REACTION` | no (default `eyes`) | Reaction emoji posted when work starts (used by `thread+reaction`). | | `SLACK_DONE_REACTION` | no (default `white_check_mark`) | Reaction emoji posted on success. | | `SLACK_FAIL_REACTION` | no (default `warning`) | Reaction emoji posted on failure. | -| `SLACK_POLL_LOOKBACK_MINUTES` | no (default `15`) | Poll mode only: how far back to search when no `ack` reaction is found. | +| `SLACK_POLL_LOOKBACK_MINUTES` | no (default `15`) | Poll mode only: how far back the cron run scans for matching messages on each tick. Set comfortably above your cron interval. | +| `SLACK_STATE_DIR` | no (default `/automation/storage/state`) | Poll mode only: directory holding `slack-listener.sqlite3`. Must survive across automation runs. If the path isn't writable, the script falls back to a tempdir and logs a warning (state will not persist). | ## How "post on idle" works diff --git a/plugins/slack-reply/scripts/agent_poll.py b/plugins/slack-reply/scripts/agent_poll.py index 77cbbff5..0af9e884 100644 --- a/plugins/slack-reply/scripts/agent_poll.py +++ b/plugins/slack-reply/scripts/agent_poll.py @@ -1,12 +1,21 @@ """Poll-mode entrypoint: cron-triggered scan for new matching messages. Designed for environments where the Slack Events API cannot reach the -automation backend (developer laptops, corporate firewalls). Uses message -reactions as persistent state: a message is "claimed" by adding the -`ack_reaction` before processing and "completed" by adding the -`done_reaction` (or `fail_reaction` on error). Already-claimed messages are -skipped on subsequent runs, so this is safe to run more frequently than the -agent itself takes to finish. +automation backend (developer laptops, corporate firewalls). The script +uses a small SQLite database at `$SLACK_STATE_DIR/slack-listener.sqlite3` +to remember which messages it has already processed, so it can run as +frequently as every minute without double-firing. + +Lifecycle per matching message: + +1. Read the message from `conversations.history` / `search.messages`. +2. `state.claim(channel, ts)` - atomic INSERT OR IGNORE. If another + concurrent runner won the race, skip. +3. (Optional) leave a `👀` reaction so humans can see something is in + flight - controlled by `SLACK_REPLY_MODE=thread+reaction`. +4. Run the agent, post the threaded reply. +5. `state.mark_done(...)` on success, `state.mark_failed(...)` on + exception. (Optional) leave a `✅` / `⚠️` reaction. """ from __future__ import annotations @@ -21,6 +30,7 @@ from config import Config from prompt import Trigger, build_prompt from slack_client import SlackClient +from state import Store log = logging.getLogger("slack_reply.poll") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") @@ -29,45 +39,45 @@ def main() -> int: cfg = Config.from_env() slack = SlackClient(cfg.bot_token) - self_id = (slack.auth_test() or {}).get("user_id", "") - if not self_id: - log.error("auth.test did not return a user_id; cannot use reaction-based state") - return 1 oldest = _oldest_ts(cfg) candidates = _collect_candidates(cfg, slack, oldest) - log.info("found %d candidate message(s) older=%s", len(candidates), oldest) + log.info("found %d candidate message(s) since ts=%s", len(candidates), oldest) exit_code = 0 - for trigger in candidates: - if slack.has_reaction_from_self(trigger.channel, trigger.ts, cfg.ack_reaction, self_id): - log.info("skipping %s/%s - already acknowledged", trigger.channel, trigger.ts) - continue - if slack.has_reaction_from_self(trigger.channel, trigger.ts, cfg.done_reaction, self_id): - log.info("skipping %s/%s - already completed", trigger.channel, trigger.ts) - continue - - # Claim the message before doing any work. If another runner also - # tries to claim it, Slack returns `already_reacted` which we treat - # as a no-op - the loser will still see the reaction on its next - # `has_reaction_from_self` check. - slack.add_reaction(trigger.channel, trigger.ts, cfg.ack_reaction) - - try: - _run_agent_and_reply(cfg, slack, trigger) - slack.add_reaction(trigger.channel, trigger.ts, cfg.done_reaction) - except Exception: # noqa: BLE001 - log.exception("agent run failed for %s/%s", trigger.channel, trigger.ts) - slack.add_reaction(trigger.channel, trigger.ts, cfg.fail_reaction) - if cfg.reply_mode != "none": - slack.post_thread_reply( - trigger.channel, - trigger.reply_thread_ts, - ":warning: I hit an error while working on this. " - "Check the automation run logs for details.\n" - f"```{traceback.format_exc(limit=2)}```", + with Store() as store: + log.info("state dir: %s", store.path.parent) + for trigger in candidates: + if not store.claim(trigger.channel, trigger.ts): + existing = store.status_of(trigger.channel, trigger.ts) + log.info( + "skipping %s/%s - already %s", + trigger.channel, trigger.ts, existing or "claimed", ) - exit_code = 1 + continue + + if cfg.reply_mode == "thread+reaction": + slack.add_reaction(trigger.channel, trigger.ts, cfg.ack_reaction) + + try: + _run_agent_and_reply(cfg, slack, trigger) + store.mark_done(trigger.channel, trigger.ts) + if cfg.reply_mode == "thread+reaction": + slack.add_reaction(trigger.channel, trigger.ts, cfg.done_reaction) + except Exception as e: # noqa: BLE001 + log.exception("agent run failed for %s/%s", trigger.channel, trigger.ts) + store.mark_failed(trigger.channel, trigger.ts, repr(e)) + if cfg.reply_mode == "thread+reaction": + slack.add_reaction(trigger.channel, trigger.ts, cfg.fail_reaction) + if cfg.reply_mode != "none": + slack.post_thread_reply( + trigger.channel, + trigger.reply_thread_ts, + ":warning: I hit an error while working on this. " + "Check the automation run logs for details.\n" + f"```{traceback.format_exc(limit=2)}```", + ) + exit_code = 1 return exit_code diff --git a/plugins/slack-reply/scripts/state.py b/plugins/slack-reply/scripts/state.py new file mode 100644 index 00000000..9f8ae068 --- /dev/null +++ b/plugins/slack-reply/scripts/state.py @@ -0,0 +1,160 @@ +"""Persistent state for the Slack-reply scripts. + +Tracks "have I already processed this Slack message?" in a small SQLite +database so the poll-mode entrypoint is idempotent across cron runs. + +State lives in `$SLACK_STATE_DIR` (default `/automation/storage/state`), +which is expected to be a directory backed by a volume that survives +across automation runs. If the directory isn't writable, we fall back to +a tempdir and emit a warning - useful for local dogfooding when the +persistent mount may not be present yet. + +Schema: + + CREATE TABLE processed_messages ( + channel_id TEXT NOT NULL, + ts TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('claimed', 'done', 'failed')), + claimed_at TEXT NOT NULL, + finished_at TEXT, + error TEXT, + PRIMARY KEY (channel_id, ts) + ); + +A row in state `claimed` means another concurrent runner has already taken +this message. Concurrent runners race on `INSERT OR IGNORE`; the loser +sees `False` from `claim()` and moves on. +""" +from __future__ import annotations + +import logging +import os +import sqlite3 +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +log = logging.getLogger(__name__) + +DEFAULT_STATE_DIR = "/automation/storage/state" +DB_FILENAME = "slack-listener.sqlite3" + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS processed_messages ( + channel_id TEXT NOT NULL, + ts TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('claimed', 'done', 'failed')), + claimed_at TEXT NOT NULL, + finished_at TEXT, + error TEXT, + PRIMARY KEY (channel_id, ts) +); +""" + + +def _now_iso() -> str: + return datetime.now(tz=timezone.utc).isoformat(timespec="seconds") + + +def _resolve_state_dir() -> Path: + """Pick the state directory, falling back gracefully if it's not writable.""" + preferred = Path(os.environ.get("SLACK_STATE_DIR", DEFAULT_STATE_DIR)) + try: + preferred.mkdir(parents=True, exist_ok=True) + # Probe writability with a tiny file. + probe = preferred / ".write-probe" + probe.write_text("ok") + probe.unlink(missing_ok=True) + return preferred + except OSError as e: + fallback = Path(tempfile.gettempdir()) / "slack-listener-state" + fallback.mkdir(parents=True, exist_ok=True) + log.warning( + "state dir %s is not writable (%s); falling back to %s. " + "State will be lost when the sandbox restarts.", + preferred, + e, + fallback, + ) + return fallback + + +class Store: + """Thin wrapper around a single SQLite file. + + Designed for short-lived poll-mode runs: each `Store` instance opens a + connection, does its work, then closes. There's no long-lived + connection pool because cron runs are short. + """ + + def __init__(self, path: Path | None = None): + self._path = path or (_resolve_state_dir() / DB_FILENAME) + self._conn = sqlite3.connect(self._path) + # WAL gives us safe concurrent reads while a writer holds the + # exclusive write lock; we expect at most a handful of concurrent + # cron runs, so this is plenty. + self._conn.execute("PRAGMA journal_mode=WAL;") + self._conn.execute("PRAGMA synchronous=NORMAL;") + self._conn.executescript(_SCHEMA) + self._conn.commit() + + @property + def path(self) -> Path: + return self._path + + def close(self) -> None: + try: + self._conn.close() + except sqlite3.Error: + pass + + # ---------------- claim/finish lifecycle ---------------- + + def claim(self, channel_id: str, ts: str) -> bool: + """Atomically reserve a message for processing. + + Returns True if this caller successfully claimed it (and should now + process it), False if another caller already did or it's already + finished. + """ + now = _now_iso() + cur = self._conn.execute( + "INSERT OR IGNORE INTO processed_messages " + "(channel_id, ts, status, claimed_at) VALUES (?, ?, 'claimed', ?)", + (channel_id, ts, now), + ) + self._conn.commit() + return cur.rowcount > 0 + + def mark_done(self, channel_id: str, ts: str) -> None: + self._conn.execute( + "UPDATE processed_messages " + "SET status='done', finished_at=?, error=NULL " + "WHERE channel_id=? AND ts=?", + (_now_iso(), channel_id, ts), + ) + self._conn.commit() + + def mark_failed(self, channel_id: str, ts: str, error: str) -> None: + self._conn.execute( + "UPDATE processed_messages " + "SET status='failed', finished_at=?, error=? " + "WHERE channel_id=? AND ts=?", + (_now_iso(), error[:2000], channel_id, ts), + ) + self._conn.commit() + + # ---------------- diagnostic helpers ---------------- + + def status_of(self, channel_id: str, ts: str) -> str | None: + row = self._conn.execute( + "SELECT status FROM processed_messages WHERE channel_id=? AND ts=?", + (channel_id, ts), + ).fetchone() + return row[0] if row else None + + def __enter__(self) -> "Store": + return self + + def __exit__(self, *exc) -> None: + self.close() diff --git a/skills/slack-channel-listener/references/poll-setup.md b/skills/slack-channel-listener/references/poll-setup.md index 025bfdbc..399587fb 100644 --- a/skills/slack-channel-listener/references/poll-setup.md +++ b/skills/slack-channel-listener/references/poll-setup.md @@ -7,32 +7,47 @@ automation runs on a schedule and polls Slack via outbound HTTPS. ## How it stays correct across runs -The script uses **message reactions as persistent state**. For each matching -message it finds: - -1. Check whether the bot has already added the `ack_reaction` (default - `eyes` 👀). If yes → skip; another run has claimed (or completed) it. -2. Add `ack_reaction` to claim the message. -3. Run the agent. On success → add `done_reaction` (default `white_check_mark` - ✅). On failure → add `fail_reaction` (default `warning` ⚠️) and post a - short error reply to the thread. - -This makes the poller idempotent and concurrent-safe: re-running the cron -job at any frequency cannot double-process a message, and the state is -visible to humans in Slack itself - no external store needed. +The script uses a **small SQLite database** as the source of truth for +"have I already processed this message?". For each matching message it +finds: + +1. `state.claim(channel, ts)` does an `INSERT OR IGNORE` into + `processed_messages`. If a row already exists, this call returns + `False` and the message is skipped - either another concurrent runner + has it in flight or a previous run completed it. +2. Run the agent. +3. On success, `state.mark_done(...)` updates the row's `status` to + `done` and stamps `finished_at`. On exception, + `state.mark_failed(channel, ts, error)` records the error. + +The database lives at `$SLACK_STATE_DIR/slack-listener.sqlite3` +(default `$SLACK_STATE_DIR = /automation/storage/state`). This directory +must survive across automation runs - point it at whatever persistent +mount your runtime provides. If the path is missing or not writable the +script falls back to a tempdir and logs a warning; this is acceptable +for first-run dogfooding, but state will be lost between sandbox +restarts in that mode (the script will re-process recent messages and +double-reply). + +Reactions are still available as **opt-in visual UX** via +`SLACK_REPLY_MODE=thread+reaction` - they leave a 👀 on start and a ✅ +on success - but they no longer drive the control flow, so users +without `reactions:read` / `reactions:write` scopes can stick with +plain `thread` mode. `SLACK_POLL_LOOKBACK_MINUTES` (default 15) bounds how far back the poller looks; set it to comfortably exceed your cron interval so a delayed run -doesn't miss messages. +doesn't miss messages. Messages older than the lookback that have never +been seen will not be picked up unless you bump the lookback temporarily. ## Required scopes | Scope | When you need it | |---|---| | `chat:write` | Always (post threaded replies). | -| `reactions:read`, `reactions:write` | Always (state machine). | | `channels:history` | Scopes `single` / `list` / `all-public` for public channels. | | `groups:history` | Private channels. | +| `reactions:write` | Only if `SLACK_REPLY_MODE=thread+reaction`. | | `users:read` | If `SLACK_RESOLVE_USER_IDS=true` (default). | | `search:read` | **Only** for scope `all-accessible`. Must be on a **user token** (`xoxp-...`), not a bot token. | @@ -46,7 +61,7 @@ curl -X POST "${OPENHANDS_HOST}/api/automation/v1" \ \"name\": \"Slack poll listener (#${CHANNEL_NAME})\", \"trigger\": { \"type\": \"cron\", - \"schedule\": \"*/2 * * * *\", + \"schedule\": \"* * * * *\", \"timezone\": \"UTC\" }, \"tarball_path\": \"${TARBALL_PATH}\", @@ -58,8 +73,9 @@ curl -X POST "${OPENHANDS_HOST}/api/automation/v1" \ \"SLACK_TRIGGER_PHRASES\": \"@thems-fightin-words\", \"SLACK_CHANNEL_SCOPE\": \"single\", \"SLACK_CHANNEL_ID\": \"${CHANNEL_ID}\", - \"SLACK_REPLY_MODE\": \"thread+reaction\", - \"SLACK_POLL_LOOKBACK_MINUTES\": \"15\" + \"SLACK_REPLY_MODE\": \"thread\", + \"SLACK_POLL_LOOKBACK_MINUTES\": \"15\", + \"SLACK_STATE_DIR\": \"/automation/storage/state\" } }" ``` @@ -72,13 +88,15 @@ script uses `search.messages` which only works on user tokens) and ensure | Trigger volume | Recommended cadence | |---|---| -| Rare (a few times per day) | `*/15 * * * *` | -| Active channel (chat-bot-like) | `*/2 * * * *` | -| Need < 1 min latency | Use push mode instead - polling that fast burns sandbox time even when there's nothing to do. | +| Default (chat-like, sub-minute feel desired) | `* * * * *` | +| Rare (a few times per day, willing to wait) | `*/5 * * * *` or `*/15 * * * *` | +| Need sub-second latency | Use push mode instead - polling that fast burns sandbox time even when there's nothing to do. | -The script exits in well under a second if there are no matches, so a +The script exits in well under a second when there are no matches, so a busy cron schedule mostly just costs a few cron dispatches per day, not -LLM tokens. The LLM runs only when an actual match is processed. +LLM tokens. The LLM only runs when an actual match is processed, and the +SQLite store guarantees each message gets exactly one run regardless of +how often the cron fires. ## Catching up after downtime @@ -88,6 +106,13 @@ up. To force a backfill, temporarily increase `SLACK_POLL_LOOKBACK_MINUTES` (PATCH the automation), dispatch a manual run, then revert. +## Inspecting the state database + +The SQLite file is plain on disk; you can `sqlite3 +$SLACK_STATE_DIR/slack-listener.sqlite3 'SELECT * FROM processed_messages +ORDER BY claimed_at DESC LIMIT 20'` to see recent processing history, +including any failures and their stored error messages. + ## Disambiguating from push mode If you have both push and poll automations on the same channel, you will diff --git a/skills/slack-channel-listener/references/tarball-build.md b/skills/slack-channel-listener/references/tarball-build.md index 9852c7d6..425ed8b6 100644 --- a/skills/slack-channel-listener/references/tarball-build.md +++ b/skills/slack-channel-listener/references/tarball-build.md @@ -99,7 +99,7 @@ curl -sS -X POST "${OPENHANDS_HOST}/api/automation/v1" \ -H "Content-Type: application/json" \ -d "{ \"name\": \"Slack poll listener (#${CHANNEL_NAME})\", - \"trigger\": {\"type\": \"cron\", \"schedule\": \"*/2 * * * *\"}, + \"trigger\": {\"type\": \"cron\", \"schedule\": \"* * * * *\"}, \"tarball_path\": \"${tarball_path}\", \"entrypoint\": \"python agent_poll.py\", \"setup_script_path\": \"setup.sh\", From 886a4af0bdc5e21f7ad901be8cbd25d46837b0f7 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 19 May 2026 17:34:00 +0000 Subject: [PATCH 3/5] Make state backend pluggable: prefer platform KV store, fall back to SQLite Refactor scripts/state.py around a Store protocol with two implementations: - KVApiStore: persists state in the per-automation KV store from OpenHands/automation#69. Storage layout is a single JSON document keyed 'slack_listener_state' holding {:: {status, claimed_at, finished_at, error}}. Mutations are read-modify-write with ?if_version=N, with jittered exponential backoff on 409 (lock timeout or version mismatch). - SQLiteStore: existing on-disk implementation, unchanged in behavior, kept as a fallback for environments without the KV store. Defaults to /automation/storage/state but the path is overridable via SLACK_STATE_DIR. A get_store() factory picks KVApiStore when both AUTOMATION_KV_TOKEN and an API URL are present, otherwise falls back to SQLiteStore. Both backends now expose prune_older_than(cutoff_iso) so agent_poll.py can keep state within the KV API's 64 KB cap and stop the SQLite file from growing without bound; the cron entrypoint prunes at 2x lookback on every run. The skill now sets enable_kv_store: true on the automation create calls and the docs explain the two-backend selection logic. Reactions are now strictly opt-in visual UX (reply_mode=thread+reaction); plain thread mode no longer requires reactions:read/write scopes. Tested both backends end-to-end (lifecycle + factory + KV 409 retry) with an in-process stub of the KV API. Co-authored-by: openhands --- plugins/slack-reply/SKILL.md | 6 +- plugins/slack-reply/scripts/agent_poll.py | 20 +- plugins/slack-reply/scripts/state.py | 400 ++++++++++++++---- skills/slack-channel-listener/SKILL.md | 6 +- .../references/poll-setup.md | 84 ++-- .../references/tarball-build.md | 1 + 6 files changed, 405 insertions(+), 112 deletions(-) diff --git a/plugins/slack-reply/SKILL.md b/plugins/slack-reply/SKILL.md index bb9d035a..65a779a0 100644 --- a/plugins/slack-reply/SKILL.md +++ b/plugins/slack-reply/SKILL.md @@ -32,7 +32,7 @@ OpenHands Automations API. | `scripts/prompt.py` | Turns a Slack event payload (or polled message) plus optional thread/channel context into the initial agent prompt. | | `scripts/agent_event.py` | Entrypoint for **push-mode** automations. Reads the Slack event from `AUTOMATION_EVENT_PAYLOAD`, runs the agent, posts the result back. | | `scripts/agent_poll.py` | Entrypoint for **poll-mode** automations. Scans matching messages since the configured lookback, claims each via the state store, runs the agent, posts the result back. | -| `scripts/state.py` | SQLite-backed `processed_messages` store at `$SLACK_STATE_DIR/slack-listener.sqlite3`. Used by poll-mode for idempotent claim/done/failed bookkeeping across cron runs. | +| `scripts/state.py` | Pluggable state store with two backends: `KVApiStore` (preferred, uses the platform automation KV store; activated when `AUTOMATION_KV_TOKEN` is set per [OpenHands/automation#69](https://github.com/OpenHands/automation/pull/69)) and `SQLiteStore` (fallback, writes to `$SLACK_STATE_DIR/slack-listener.sqlite3`). Used by poll-mode for idempotent claim/done/failed bookkeeping across cron runs. | | `scripts/config.py` | Loads automation-level config (trigger phrases, channel scope, context options, reply behaviour, state directory) from environment variables. | ## Configuration (environment variables read at runtime) @@ -55,7 +55,9 @@ it creates the automation; users should not edit them by hand. | `SLACK_DONE_REACTION` | no (default `white_check_mark`) | Reaction emoji posted on success. | | `SLACK_FAIL_REACTION` | no (default `warning`) | Reaction emoji posted on failure. | | `SLACK_POLL_LOOKBACK_MINUTES` | no (default `15`) | Poll mode only: how far back the cron run scans for matching messages on each tick. Set comfortably above your cron interval. | -| `SLACK_STATE_DIR` | no (default `/automation/storage/state`) | Poll mode only: directory holding `slack-listener.sqlite3`. Must survive across automation runs. If the path isn't writable, the script falls back to a tempdir and logs a warning (state will not persist). | +| `SLACK_STATE_DIR` | no (default `/automation/storage/state`) | Poll mode only, SQLite backend only: directory holding `slack-listener.sqlite3`. Must survive across automation runs. If the path isn't writable, the script falls back to a tempdir and logs a warning (state will not persist). Ignored when the platform KV store is available. | +| `AUTOMATION_KV_TOKEN` | injected by the dispatcher | Poll mode only, KV backend selector: when present (i.e. the automation was created with `enable_kv_store: true` on a runtime that ships [OpenHands/automation#69](https://github.com/OpenHands/automation/pull/69)) state is persisted via the platform KV store and `SLACK_STATE_DIR` is ignored. | +| `AUTOMATION_API_URL` / `OPENHANDS_CLOUD_API_URL` | injected by the dispatcher | Base URL the KV backend talks to. Resolved in that order; if neither is set the KV backend cannot start. | ## How "post on idle" works diff --git a/plugins/slack-reply/scripts/agent_poll.py b/plugins/slack-reply/scripts/agent_poll.py index 0af9e884..e283b352 100644 --- a/plugins/slack-reply/scripts/agent_poll.py +++ b/plugins/slack-reply/scripts/agent_poll.py @@ -30,7 +30,7 @@ from config import Config from prompt import Trigger, build_prompt from slack_client import SlackClient -from state import Store +from state import get_store log = logging.getLogger("slack_reply.poll") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") @@ -45,8 +45,22 @@ def main() -> int: log.info("found %d candidate message(s) since ts=%s", len(candidates), oldest) exit_code = 0 - with Store() as store: - log.info("state dir: %s", store.path.parent) + with get_store() as store: + # Drop entries older than twice the lookback window. Bounds the + # KVApiStore document size (which is capped at 64 KB) and keeps + # SQLite tidy. Anything older than 2x lookback can't possibly be + # rediscovered by a future poll so we lose nothing. + prune_cutoff = ( + datetime.now(tz=timezone.utc) + - timedelta(minutes=cfg.poll_lookback_minutes * 2) + ).isoformat(timespec="seconds") + try: + removed = store.prune_older_than(prune_cutoff) + if removed: + log.info("pruned %d state entries older than %s", removed, prune_cutoff) + except Exception: # noqa: BLE001 + log.warning("state prune failed; continuing", exc_info=True) + for trigger in candidates: if not store.claim(trigger.channel, trigger.ts): existing = store.status_of(trigger.channel, trigger.ts) diff --git a/plugins/slack-reply/scripts/state.py b/plugins/slack-reply/scripts/state.py index 9f8ae068..29640089 100644 --- a/plugins/slack-reply/scripts/state.py +++ b/plugins/slack-reply/scripts/state.py @@ -1,67 +1,316 @@ -"""Persistent state for the Slack-reply scripts. - -Tracks "have I already processed this Slack message?" in a small SQLite -database so the poll-mode entrypoint is idempotent across cron runs. - -State lives in `$SLACK_STATE_DIR` (default `/automation/storage/state`), -which is expected to be a directory backed by a volume that survives -across automation runs. If the directory isn't writable, we fall back to -a tempdir and emit a warning - useful for local dogfooding when the -persistent mount may not be present yet. - -Schema: - - CREATE TABLE processed_messages ( - channel_id TEXT NOT NULL, - ts TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('claimed', 'done', 'failed')), - claimed_at TEXT NOT NULL, - finished_at TEXT, - error TEXT, - PRIMARY KEY (channel_id, ts) - ); - -A row in state `claimed` means another concurrent runner has already taken -this message. Concurrent runners race on `INSERT OR IGNORE`; the loser -sees `False` from `claim()` and moves on. +"""Persistent state for the Slack-reply poll-mode entrypoint. + +Tracks "have I already processed this Slack message?" across cron runs +so the same message is never handled twice. + +Two backends are supported and selected automatically by `get_store()`: + +1. **KVApiStore** - preferred. Uses the platform-provided automation KV + store (see https://github.com/OpenHands/automation/pull/69). Activated + when `AUTOMATION_KV_TOKEN` and an API URL are present in the + environment - i.e., the automation was created with + `enable_kv_store: true` and the runtime supports it. No mounted + filesystem state; data lives in the platform, encrypted at rest, and + round-trips over HTTPS. + +2. **SQLiteStore** - fallback. Writes `slack-listener.sqlite3` under + `$SLACK_STATE_DIR` (default `/automation/storage/state`). If the + directory isn't writable, falls back further to a tempdir with a + warning (acceptable for dogfooding; state will not survive sandbox + restarts in that mode). + +The `Store` protocol is small on purpose - the agent_poll script only +needs `claim`, `mark_done`, `mark_failed`, `status_of`, and `prune`. """ from __future__ import annotations +import abc +import json import logging import os +import random import sqlite3 import tempfile +import time from datetime import datetime, timezone from pathlib import Path +from typing import Iterator +from urllib import error as urlerror +from urllib import request as urlrequest log = logging.getLogger(__name__) DEFAULT_STATE_DIR = "/automation/storage/state" DB_FILENAME = "slack-listener.sqlite3" -_SCHEMA = """ -CREATE TABLE IF NOT EXISTS processed_messages ( - channel_id TEXT NOT NULL, - ts TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('claimed', 'done', 'failed')), - claimed_at TEXT NOT NULL, - finished_at TEXT, - error TEXT, - PRIMARY KEY (channel_id, ts) -); -""" +# All state for the slack-channel-listener lives under this single KV key. +# The single-document model is per-design of the KV API; using one key +# means every claim/mark serializes through the same row lock, which is +# what we want for "exactly once per Slack ts" semantics. +KV_KEY = "slack_listener_state" + +# Cap retries on optimistic-concurrency conflicts. Cron runs are +# single-instance by default, so contention is rare; this is a guardrail +# against pathological cases (e.g., manual dispatch overlapping a cron). +KV_MAX_RETRIES = 6 def _now_iso() -> str: return datetime.now(tz=timezone.utc).isoformat(timespec="seconds") +def _key(channel_id: str, ts: str) -> str: + return f"{channel_id}:{ts}" + + +# --------------------------------------------------------------------------- # +# Public protocol +# --------------------------------------------------------------------------- # + + +class Store(abc.ABC): + """Backend-agnostic interface for the poll-mode state store.""" + + @abc.abstractmethod + def claim(self, channel_id: str, ts: str) -> bool: + """Atomically reserve a message. True if this caller won the race.""" + + @abc.abstractmethod + def mark_done(self, channel_id: str, ts: str) -> None: ... + + @abc.abstractmethod + def mark_failed(self, channel_id: str, ts: str, error: str) -> None: ... + + @abc.abstractmethod + def status_of(self, channel_id: str, ts: str) -> str | None: ... + + @abc.abstractmethod + def prune_older_than(self, cutoff_iso: str) -> int: + """Delete entries claimed before `cutoff_iso`. Returns count removed.""" + + @abc.abstractmethod + def close(self) -> None: ... + + def __enter__(self) -> "Store": + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + +# --------------------------------------------------------------------------- # +# Factory +# --------------------------------------------------------------------------- # + + +def get_store() -> Store: + """Pick the best available backend. + + Prefers the platform KV store when its env vars are present; falls back + to SQLite otherwise. Emits one INFO line per call so it's obvious in run + logs which backend was selected. + """ + if os.environ.get("AUTOMATION_KV_TOKEN") and _resolve_api_url(): + try: + store = KVApiStore() + log.info("state backend: KVApiStore (%s)", store.base_url) + return store + except Exception: # noqa: BLE001 + log.warning("KVApiStore init failed; falling back to SQLite", exc_info=True) + store = SQLiteStore() + log.info("state backend: SQLiteStore (%s)", store.path) + return store + + +def _resolve_api_url() -> str | None: + # `AUTOMATION_API_URL` is the canonical name per the KV client guide; + # fall back to `OPENHANDS_CLOUD_API_URL` which is set today by the + # automation backend for the SDK boilerplate. + return os.environ.get("AUTOMATION_API_URL") or os.environ.get("OPENHANDS_CLOUD_API_URL") + + +# --------------------------------------------------------------------------- # +# KV API backend +# --------------------------------------------------------------------------- # + + +class KVApiStore(Store): + """State stored in the platform-provided automation KV store. + + Storage layout - a single JSON document at key `slack_listener_state`: + + { + ":": { + "status": "claimed" | "done" | "failed", + "claimed_at": "", + "finished_at": "" | null, + "error": "" | null + }, + ... + } + + Every mutator is a read-modify-write with `if_version` for optimistic + concurrency, retrying on 409 with jittered exponential backoff per the + KV client guide. + """ + + def __init__(self): + token = os.environ.get("AUTOMATION_KV_TOKEN") + api_url = _resolve_api_url() + if not token or not api_url: + raise RuntimeError( + "KVApiStore requires AUTOMATION_KV_TOKEN and AUTOMATION_API_URL" + ) + self._token = token + self.base_url = api_url.rstrip("/") + "/v1/kv" + + # ---- HTTP helpers ---- + + def _request( + self, method: str, path: str, body: object | None = None + ) -> tuple[int, dict, dict]: + url = f"{self.base_url}/{path.lstrip('/')}" + data = None if body is None else json.dumps(body).encode() + req = urlrequest.Request(url, data=data, method=method) + req.add_header("Authorization", f"Bearer {self._token}") + if data is not None: + req.add_header("Content-Type", "application/json") + try: + resp = urlrequest.urlopen(req, timeout=10) + payload = resp.read() + return resp.status, dict(resp.headers), json.loads(payload) if payload else {} + except urlerror.HTTPError as e: + payload = e.read() + try: + parsed = json.loads(payload) if payload else {} + except json.JSONDecodeError: + parsed = {"detail": payload.decode(errors="replace")} + return e.code, dict(e.headers or {}), parsed + + def _read_state(self) -> tuple[dict, int | None]: + status, _, body = self._request("GET", f"{KV_KEY}?meta=true") + if status == 200: + return body.get("value") or {}, body.get("version") + if status == 404: + return {}, None + raise RuntimeError(f"KV GET failed: {status} {body}") + + def _write_state(self, state: dict, if_version: int | None) -> int: + path = KV_KEY + if if_version is not None: + path = f"{KV_KEY}?if_version={if_version}" + elif not state: + path = f"{KV_KEY}?nx=true" + status, _, _ = self._request("PUT", path, body=state) + return status + + # ---- RMW loop ---- + + def _txn(self, mutate) -> bool: + """Run a transactional read-modify-write. + + `mutate(state)` may return False to abort the write (returned to + the caller as False). Returning a truthy value commits the new + state and returns True. Retries on 409 up to KV_MAX_RETRIES. + """ + for attempt in range(KV_MAX_RETRIES): + state, version = self._read_state() + result = mutate(state) + if result is False: + return False + status = self._write_state(state, if_version=version) + if status in (200, 201): + return True + if status == 409: + # Jittered exponential backoff. Per the client guide the + # server suggests a baseline via Retry-After: 1 second. + delay = (0.1 * (2**attempt)) + random.uniform(0, 0.1) + time.sleep(delay) + continue + if status == 413: + # State too large. Best-effort: drop entries older than 24h + # and retry once. + log.warning("KV state at 64KB cap; pruning aggressively") + cutoff = datetime.now(tz=timezone.utc).replace(microsecond=0) + cutoff = cutoff.replace(hour=max(0, cutoff.hour - 24)) + self.prune_older_than(cutoff.isoformat()) + continue + raise RuntimeError(f"KV PUT failed: {status}") + raise RuntimeError(f"KV write contention after {KV_MAX_RETRIES} retries") + + # ---- Store interface ---- + + def claim(self, channel_id: str, ts: str) -> bool: + k = _key(channel_id, ts) + + def mutate(state: dict) -> bool: + if k in state: + return False # someone else already has it (or it's done) + state[k] = {"status": "claimed", "claimed_at": _now_iso(), + "finished_at": None, "error": None} + return True + + return self._txn(mutate) + + def mark_done(self, channel_id: str, ts: str) -> None: + k = _key(channel_id, ts) + + def mutate(state: dict) -> bool: + entry = state.get(k) or {} + entry.update(status="done", finished_at=_now_iso(), error=None) + state[k] = entry + return True + + self._txn(mutate) + + def mark_failed(self, channel_id: str, ts: str, error: str) -> None: + k = _key(channel_id, ts) + + def mutate(state: dict) -> bool: + entry = state.get(k) or {} + entry.update(status="failed", finished_at=_now_iso(), + error=error[:2000]) + state[k] = entry + return True + + self._txn(mutate) + + def status_of(self, channel_id: str, ts: str) -> str | None: + state, _ = self._read_state() + entry = state.get(_key(channel_id, ts)) + return entry.get("status") if entry else None + + def prune_older_than(self, cutoff_iso: str) -> int: + removed = 0 + + def mutate(state: dict) -> bool: + nonlocal removed + to_drop = [ + k for k, v in state.items() + if (v.get("finished_at") or v.get("claimed_at") or "") < cutoff_iso + ] + for k in to_drop: + del state[k] + removed = len(to_drop) + return bool(to_drop) or True # always commit (no-op write is fine) + + self._txn(mutate) + return removed + + def close(self) -> None: + # No persistent connection; nothing to release. + return None + + +# --------------------------------------------------------------------------- # +# SQLite backend +# --------------------------------------------------------------------------- # + + def _resolve_state_dir() -> Path: - """Pick the state directory, falling back gracefully if it's not writable.""" preferred = Path(os.environ.get("SLACK_STATE_DIR", DEFAULT_STATE_DIR)) try: preferred.mkdir(parents=True, exist_ok=True) - # Probe writability with a tiny file. probe = preferred / ".write-probe" probe.write_text("ok") probe.unlink(missing_ok=True) @@ -72,56 +321,40 @@ def _resolve_state_dir() -> Path: log.warning( "state dir %s is not writable (%s); falling back to %s. " "State will be lost when the sandbox restarts.", - preferred, - e, - fallback, + preferred, e, fallback, ) return fallback -class Store: - """Thin wrapper around a single SQLite file. +_SQLITE_SCHEMA = """ +CREATE TABLE IF NOT EXISTS processed_messages ( + channel_id TEXT NOT NULL, + ts TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('claimed', 'done', 'failed')), + claimed_at TEXT NOT NULL, + finished_at TEXT, + error TEXT, + PRIMARY KEY (channel_id, ts) +); +""" + - Designed for short-lived poll-mode runs: each `Store` instance opens a - connection, does its work, then closes. There's no long-lived - connection pool because cron runs are short. - """ +class SQLiteStore(Store): + """Fallback backend for environments without the platform KV store.""" def __init__(self, path: Path | None = None): - self._path = path or (_resolve_state_dir() / DB_FILENAME) - self._conn = sqlite3.connect(self._path) - # WAL gives us safe concurrent reads while a writer holds the - # exclusive write lock; we expect at most a handful of concurrent - # cron runs, so this is plenty. + self.path = path or (_resolve_state_dir() / DB_FILENAME) + self._conn = sqlite3.connect(self.path) self._conn.execute("PRAGMA journal_mode=WAL;") self._conn.execute("PRAGMA synchronous=NORMAL;") - self._conn.executescript(_SCHEMA) + self._conn.executescript(_SQLITE_SCHEMA) self._conn.commit() - @property - def path(self) -> Path: - return self._path - - def close(self) -> None: - try: - self._conn.close() - except sqlite3.Error: - pass - - # ---------------- claim/finish lifecycle ---------------- - def claim(self, channel_id: str, ts: str) -> bool: - """Atomically reserve a message for processing. - - Returns True if this caller successfully claimed it (and should now - process it), False if another caller already did or it's already - finished. - """ - now = _now_iso() cur = self._conn.execute( "INSERT OR IGNORE INTO processed_messages " "(channel_id, ts, status, claimed_at) VALUES (?, ?, 'claimed', ?)", - (channel_id, ts, now), + (channel_id, ts, _now_iso()), ) self._conn.commit() return cur.rowcount > 0 @@ -144,8 +377,6 @@ def mark_failed(self, channel_id: str, ts: str, error: str) -> None: ) self._conn.commit() - # ---------------- diagnostic helpers ---------------- - def status_of(self, channel_id: str, ts: str) -> str | None: row = self._conn.execute( "SELECT status FROM processed_messages WHERE channel_id=? AND ts=?", @@ -153,8 +384,17 @@ def status_of(self, channel_id: str, ts: str) -> str | None: ).fetchone() return row[0] if row else None - def __enter__(self) -> "Store": - return self + def prune_older_than(self, cutoff_iso: str) -> int: + cur = self._conn.execute( + "DELETE FROM processed_messages " + "WHERE COALESCE(finished_at, claimed_at) < ?", + (cutoff_iso,), + ) + self._conn.commit() + return cur.rowcount - def __exit__(self, *exc) -> None: - self.close() + def close(self) -> None: + try: + self._conn.close() + except sqlite3.Error: + pass diff --git a/skills/slack-channel-listener/SKILL.md b/skills/slack-channel-listener/SKILL.md index 306735fc..c09e7f4c 100644 --- a/skills/slack-channel-listener/SKILL.md +++ b/skills/slack-channel-listener/SKILL.md @@ -81,11 +81,13 @@ You will need: - A Slack **bot token** (`xoxb-...`) with scopes: - `chat:write` - to post the reply. - - `reactions:read`, `reactions:write` - to mark progress (and, in poll - mode, for the reaction-based state machine). - `channels:history` - to read public channel messages. - `groups:history` - if monitoring private channels. - `users:read` - if `resolve_user_ids` is enabled (default on). + - `reactions:write` - **only** if `reply_mode=thread+reaction` (which + decorates the triggering message with 👀 / ✅ for visual feedback). + Not required for the default `thread` mode - state is persisted via + the platform KV store (or SQLite fallback), not reactions. - For push mode: a Slack **signing secret** and a Slack app with Event Subscriptions enabled. Walkthrough: `references/push-setup.md`. - For poll mode with `scope=all-accessible`: a Slack **user token** diff --git a/skills/slack-channel-listener/references/poll-setup.md b/skills/slack-channel-listener/references/poll-setup.md index 399587fb..a817049f 100644 --- a/skills/slack-channel-listener/references/poll-setup.md +++ b/skills/slack-channel-listener/references/poll-setup.md @@ -7,27 +7,43 @@ automation runs on a schedule and polls Slack via outbound HTTPS. ## How it stays correct across runs -The script uses a **small SQLite database** as the source of truth for -"have I already processed this message?". For each matching message it -finds: - -1. `state.claim(channel, ts)` does an `INSERT OR IGNORE` into - `processed_messages`. If a row already exists, this call returns - `False` and the message is skipped - either another concurrent runner - has it in flight or a previous run completed it. +The script uses a small state store - `state.Store` in +`plugins/slack-reply` - to remember which `(channel, ts)` pairs it has +already handled. Two backends are bundled and the script picks one +automatically: + +1. **Platform KV store** (preferred). When `AUTOMATION_KV_TOKEN` is + injected by the dispatcher (which happens iff the automation was + created with `enable_kv_store: true` and the runtime ships + [OpenHands/automation#69](https://github.com/OpenHands/automation/pull/69)), + state lives in the platform's per-automation KV store. One JSON + document holds the whole set; mutations use optimistic concurrency + (`?if_version=N`) with jittered exponential backoff on 409. No mounted + filesystem state, encrypted at rest, free on Cloud and self-hosted. +2. **SQLite fallback**. When the KV token isn't present, state is + persisted in `$SLACK_STATE_DIR/slack-listener.sqlite3` (default + `$SLACK_STATE_DIR = /automation/storage/state`). The directory must + survive across automation runs. If the path is missing or not writable + the script falls back further to a tempdir and logs a warning - state + will be lost between sandbox restarts in that mode and the script will + re-process recent messages. + +Either way, the script's claim/run/finish flow is the same: + +1. `store.claim(channel, ts)` atomically records the message as + `claimed`. If another concurrent runner already claimed it, or a + previous run completed it, `claim()` returns `False` and the message + is skipped. 2. Run the agent. -3. On success, `state.mark_done(...)` updates the row's `status` to - `done` and stamps `finished_at`. On exception, - `state.mark_failed(channel, ts, error)` records the error. - -The database lives at `$SLACK_STATE_DIR/slack-listener.sqlite3` -(default `$SLACK_STATE_DIR = /automation/storage/state`). This directory -must survive across automation runs - point it at whatever persistent -mount your runtime provides. If the path is missing or not writable the -script falls back to a tempdir and logs a warning; this is acceptable -for first-run dogfooding, but state will be lost between sandbox -restarts in that mode (the script will re-process recent messages and -double-reply). +3. On success, `store.mark_done(...)`. On exception, `store.mark_failed( + channel, ts, error)`. + +At the start of each run the script also calls +`store.prune_older_than(...)` to drop entries older than twice the +lookback window. This bounds the KV document size (the platform caps +state at 64 KB per automation) and keeps the SQLite file from growing +without bound. Anything older than `2 x SLACK_POLL_LOOKBACK_MINUTES` +cannot be rediscovered by a future poll, so pruning loses nothing. Reactions are still available as **opt-in visual UX** via `SLACK_REPLY_MODE=thread+reaction` - they leave a 👀 on start and a ✅ @@ -67,6 +83,7 @@ curl -X POST "${OPENHANDS_HOST}/api/automation/v1" \ \"tarball_path\": \"${TARBALL_PATH}\", \"entrypoint\": \"python agent_poll.py\", \"setup_script_path\": \"setup.sh\", + \"enable_kv_store\": true, \"timeout\": 600, \"env\": { \"SLACK_BOT_TOKEN\": \"xoxb-...\", @@ -106,12 +123,29 @@ up. To force a backfill, temporarily increase `SLACK_POLL_LOOKBACK_MINUTES` (PATCH the automation), dispatch a manual run, then revert. -## Inspecting the state database +## Inspecting state + +**KV backend.** Use the platform KV API directly: + +```bash +curl -H "Authorization: Bearer $AUTOMATION_KV_TOKEN" \ + "$AUTOMATION_API_URL/v1/kv/slack_listener_state?meta=true" | jq . +``` + +You'll get the entire `{:: {status, claimed_at, finished_at, +error}}` document plus the current `$version`. + +**SQLite backend.** The file is plain on disk: + +```bash +sqlite3 $SLACK_STATE_DIR/slack-listener.sqlite3 \ + 'SELECT * FROM processed_messages ORDER BY claimed_at DESC LIMIT 20' +``` -The SQLite file is plain on disk; you can `sqlite3 -$SLACK_STATE_DIR/slack-listener.sqlite3 'SELECT * FROM processed_messages -ORDER BY claimed_at DESC LIMIT 20'` to see recent processing history, -including any failures and their stored error messages. +In either case the script logs which backend it selected at the start of +each run (`state backend: KVApiStore (...)` or `state backend: +SQLiteStore (...)`) so it's unambiguous which view is authoritative for a +given automation. ## Disambiguating from push mode diff --git a/skills/slack-channel-listener/references/tarball-build.md b/skills/slack-channel-listener/references/tarball-build.md index 425ed8b6..02db5212 100644 --- a/skills/slack-channel-listener/references/tarball-build.md +++ b/skills/slack-channel-listener/references/tarball-build.md @@ -103,6 +103,7 @@ curl -sS -X POST "${OPENHANDS_HOST}/api/automation/v1" \ \"tarball_path\": \"${tarball_path}\", \"entrypoint\": \"python agent_poll.py\", \"setup_script_path\": \"setup.sh\", + \"enable_kv_store\": true, \"timeout\": 600, \"env\": { ... } }" From d24bf3a7a5bd4662d1559bf527208eb0396e2c63 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 19 May 2026 18:41:06 +0000 Subject: [PATCH 4/5] Install into a venv instead of system Python setup.sh was using 'uv pip install --system', which writes to the interpreter's site-packages (/usr/local/lib/pythonX.Y/...). That directory is root-owned in the automation sandbox; the run process runs as an unprivileged user, so the install died on EACCES halfway through (annotated_doc was the package that surfaced it during dogfood on PR #245). Worse, the dispatcher didn't propagate the setup failure into run.error_detail, so runs stayed in RUNNING forever until the 10-minute timeout. Fix is a dedicated venv at $HOME/.venvs/slack-listener, plus a companion run.sh entrypoint wrapper that exec's the venv's python on the requested script (agent_event.py or agent_poll.py). The venv path is overridable via $SLACK_LISTENER_VENV for testing. Smoke-tested locally as an unprivileged user (matching the failing sandbox's constraint): venv creation, package install, and import of openhands.sdk / openhands.workspace / openhands.tools / slack_sdk all succeed end-to-end. SDK v1.22.1 confirmed working in the venv. Documentation updated: - plugins/slack-reply/SKILL.md describes setup.sh + run.sh roles. - skills/slack-channel-listener/SKILL.md, references/{tarball-build, poll-setup,push-setup}.md now stage run.sh into the tarball and set entrypoint to 'bash run.sh agent_{event,poll}.py'. Co-authored-by: openhands --- plugins/slack-reply/SKILL.md | 3 ++- plugins/slack-reply/scripts/run.sh | 13 +++++++++++++ plugins/slack-reply/scripts/setup.sh | 13 +++++++++++-- skills/slack-channel-listener/SKILL.md | 4 ++-- .../references/poll-setup.md | 2 +- .../references/push-setup.md | 2 +- .../references/tarball-build.md | 15 ++++++++++++--- 7 files changed, 42 insertions(+), 10 deletions(-) create mode 100755 plugins/slack-reply/scripts/run.sh mode change 100644 => 100755 plugins/slack-reply/scripts/setup.sh diff --git a/plugins/slack-reply/SKILL.md b/plugins/slack-reply/SKILL.md index 65a779a0..f48a02f5 100644 --- a/plugins/slack-reply/SKILL.md +++ b/plugins/slack-reply/SKILL.md @@ -27,7 +27,8 @@ OpenHands Automations API. | File | Purpose | |---|---| -| `scripts/setup.sh` | Installs `uv`, the OpenHands SDK, and `slack_sdk` in the automation sandbox. | +| `scripts/setup.sh` | Installs `uv`, creates a venv at `$HOME/.venvs/slack-listener`, and installs the OpenHands SDK + `slack_sdk` into that venv. Avoids the system Python because the automation sandbox runs as an unprivileged user and `/usr/local/lib/pythonX.Y/site-packages` is root-owned. | +| `scripts/run.sh` | Entrypoint wrapper. Invoked as `bash run.sh agent_event.py` (push) or `bash run.sh agent_poll.py` (poll); execs the venv's python on the named script. | | `scripts/slack_client.py` | Thin wrappers over the Slack Web API: history, replies, postMessage, reactions, permalinks, user resolution. | | `scripts/prompt.py` | Turns a Slack event payload (or polled message) plus optional thread/channel context into the initial agent prompt. | | `scripts/agent_event.py` | Entrypoint for **push-mode** automations. Reads the Slack event from `AUTOMATION_EVENT_PAYLOAD`, runs the agent, posts the result back. | diff --git a/plugins/slack-reply/scripts/run.sh b/plugins/slack-reply/scripts/run.sh new file mode 100755 index 00000000..69ddba12 --- /dev/null +++ b/plugins/slack-reply/scripts/run.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Entrypoint wrapper for Slack-triggered OpenHands automations. +# +# Used as the automation's `entrypoint`: `bash run.sh agent_event.py` +# (push mode) or `bash run.sh agent_poll.py` (poll mode). Delegates to +# the venv created by setup.sh so the system Python is never touched. +set -euo pipefail + +VENV="${SLACK_LISTENER_VENV:-$HOME/.venvs/slack-listener}" +SCRIPT="${1:-agent_poll.py}" + +cd "$(dirname "$0")" +exec "$VENV/bin/python" "$SCRIPT" diff --git a/plugins/slack-reply/scripts/setup.sh b/plugins/slack-reply/scripts/setup.sh old mode 100644 new mode 100755 index 08dde638..8fd58054 --- a/plugins/slack-reply/scripts/setup.sh +++ b/plugins/slack-reply/scripts/setup.sh @@ -1,6 +1,11 @@ #!/bin/bash # Setup script for Slack-triggered OpenHands automations. -# Installs uv, the OpenHands SDK, and the Slack SDK in the automation sandbox. +# +# Installs uv, then provisions a dedicated virtualenv at +# $HOME/.venvs/slack-listener and installs the OpenHands SDK + slack_sdk +# into it. The companion `run.sh` wrapper invokes that venv's python so +# we never need write access to the system site-packages (the automation +# sandbox runs as an unprivileged user; /usr/local/lib is root-owned). set -euo pipefail if ! command -v uv >/dev/null 2>&1; then @@ -8,7 +13,11 @@ if ! command -v uv >/dev/null 2>&1; then export PATH="$HOME/.local/bin:$PATH" fi -uv pip install -q --system \ +VENV="${SLACK_LISTENER_VENV:-$HOME/.venvs/slack-listener}" +mkdir -p "$(dirname "$VENV")" +uv venv "$VENV" + +uv pip install --python "$VENV/bin/python" -q \ openhands-sdk \ openhands-workspace \ openhands-tools \ diff --git a/skills/slack-channel-listener/SKILL.md b/skills/slack-channel-listener/SKILL.md index c09e7f4c..c106cd8e 100644 --- a/skills/slack-channel-listener/SKILL.md +++ b/skills/slack-channel-listener/SKILL.md @@ -104,8 +104,8 @@ tarball. Build a custom-automation tarball that contains the contents of `plugins/slack-reply/scripts/`. The entrypoint is: -- Push mode: `python agent_event.py`, setup `setup.sh`, trigger `event`. -- Poll mode: `python agent_poll.py`, setup `setup.sh`, trigger `cron`. +- Push mode: `bash run.sh agent_event.py`, setup `setup.sh`, trigger `event`. +- Poll mode: `bash run.sh agent_poll.py`, setup `setup.sh`, trigger `cron`. See `references/tarball-build.md` for the full step-by-step (fetch the plugin from this repo, tar it up, upload, then create the automation). diff --git a/skills/slack-channel-listener/references/poll-setup.md b/skills/slack-channel-listener/references/poll-setup.md index a817049f..449222e2 100644 --- a/skills/slack-channel-listener/references/poll-setup.md +++ b/skills/slack-channel-listener/references/poll-setup.md @@ -81,7 +81,7 @@ curl -X POST "${OPENHANDS_HOST}/api/automation/v1" \ \"timezone\": \"UTC\" }, \"tarball_path\": \"${TARBALL_PATH}\", - \"entrypoint\": \"python agent_poll.py\", + \"entrypoint\": \"bash run.sh agent_poll.py\", \"setup_script_path\": \"setup.sh\", \"enable_kv_store\": true, \"timeout\": 600, diff --git a/skills/slack-channel-listener/references/push-setup.md b/skills/slack-channel-listener/references/push-setup.md index a16d69ae..a3e862ff 100644 --- a/skills/slack-channel-listener/references/push-setup.md +++ b/skills/slack-channel-listener/references/push-setup.md @@ -83,7 +83,7 @@ curl -X POST "${OPENHANDS_HOST}/api/automation/v1" \ \"filter\": \"event.channel == '${CHANNEL_ID}' && (icontains(event.text, '${PHRASE1}') || icontains(event.text, '${PHRASE2}')) && !event.bot_id\" }, \"tarball_path\": \"${TARBALL_PATH}\", - \"entrypoint\": \"python agent_event.py\", + \"entrypoint\": \"bash run.sh agent_event.py\", \"setup_script_path\": \"setup.sh\", \"timeout\": 600, \"env\": { diff --git a/skills/slack-channel-listener/references/tarball-build.md b/skills/slack-channel-listener/references/tarball-build.md index 02db5212..52663bf0 100644 --- a/skills/slack-channel-listener/references/tarball-build.md +++ b/skills/slack-channel-listener/references/tarball-build.md @@ -42,7 +42,8 @@ the `setup.sh` script alongside it. Flatten: stage=$(mktemp -d) cp plugins/slack-reply/scripts/*.py "$stage/" cp plugins/slack-reply/scripts/setup.sh "$stage/" -chmod +x "$stage/setup.sh" +cp plugins/slack-reply/scripts/run.sh "$stage/" +chmod +x "$stage/setup.sh" "$stage/run.sh" # Sanity check ( cd "$stage" && python3 -m py_compile ./*.py ) @@ -52,6 +53,14 @@ tar -czf slack-listener.tar.gz -C "$stage" . ls -lh slack-listener.tar.gz # must be < 1 MB ``` +`setup.sh` creates a dedicated virtualenv at `$HOME/.venvs/slack-listener` +and installs `openhands-sdk`, `openhands-workspace`, `openhands-tools`, +and `slack_sdk` into it. The companion `run.sh` invokes that venv's +python on the right entrypoint script. The system Python is never +written to - this is required because the automation sandbox runs as an +unprivileged user and `/usr/local/lib/pythonX.Y/site-packages` is +root-owned. + ## 3. Upload ```bash @@ -84,7 +93,7 @@ curl -sS -X POST "${OPENHANDS_HOST}/api/automation/v1" \ \"filter\": \"event.channel == '${CHANNEL_ID}' && icontains(event.text, '${PHRASE}') && !event.bot_id\" }, \"tarball_path\": \"${tarball_path}\", - \"entrypoint\": \"python agent_event.py\", + \"entrypoint\": \"bash run.sh agent_event.py\", \"setup_script_path\": \"setup.sh\", \"timeout\": 600, \"env\": { ... } @@ -101,7 +110,7 @@ curl -sS -X POST "${OPENHANDS_HOST}/api/automation/v1" \ \"name\": \"Slack poll listener (#${CHANNEL_NAME})\", \"trigger\": {\"type\": \"cron\", \"schedule\": \"* * * * *\"}, \"tarball_path\": \"${tarball_path}\", - \"entrypoint\": \"python agent_poll.py\", + \"entrypoint\": \"bash run.sh agent_poll.py\", \"setup_script_path\": \"setup.sh\", \"enable_kv_store\": true, \"timeout\": 600, From 3054826b33b6acff3d30c0c35197f1d00fe83864 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 19 May 2026 19:05:01 +0000 Subject: [PATCH 5/5] Make setup.sh idempotent (venv may already exist on cron re-runs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automation sandbox is reused across runs (cron ticks share state). 'uv venv' aborts with exit code 2 if the target directory already exists, which made the first cron tick succeed but every subsequent one fail with: error: Failed to create virtual environment Caused by: A virtual environment already exists at `/home/openhands/.venvs/slack-listener`. Use `--clear` to replace it Guard the venv creation with '[ ! -x $VENV/bin/python ]' so it's a no-op on re-runs. 'uv pip install' itself is already idempotent and will still surface dependency upgrades when versions change. Smoke-tested two back-to-back runs locally — first creates the venv and installs, second is a no-op exiting 0. Co-authored-by: openhands --- plugins/slack-reply/scripts/setup.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/slack-reply/scripts/setup.sh b/plugins/slack-reply/scripts/setup.sh index 8fd58054..7b86da76 100755 --- a/plugins/slack-reply/scripts/setup.sh +++ b/plugins/slack-reply/scripts/setup.sh @@ -15,7 +15,15 @@ fi VENV="${SLACK_LISTENER_VENV:-$HOME/.venvs/slack-listener}" mkdir -p "$(dirname "$VENV")" -uv venv "$VENV" + +# Idempotent: the automation sandbox is reused across runs (cron ticks +# share state), and `uv venv` errors out if the target already exists. +# Only create the venv if it isn't already there; the install step +# below is itself idempotent (uv pip install is a no-op when versions +# match) and will pull in any upgrades on subsequent runs. +if [ ! -x "$VENV/bin/python" ]; then + uv venv "$VENV" +fi uv pip install --python "$VENV/bin/python" -q \ openhands-sdk \