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..f48a02f5 --- /dev/null +++ b/plugins/slack-reply/SKILL.md @@ -0,0 +1,79 @@ +--- +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`, 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. | +| `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` | 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) + +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 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, 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 + +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..e283b352 --- /dev/null +++ b/plugins/slack-reply/scripts/agent_poll.py @@ -0,0 +1,232 @@ +"""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). 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 + +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 +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") + + +def main() -> int: + cfg = Config.from_env() + slack = SlackClient(cfg.bot_token) + + oldest = _oldest_ts(cfg) + candidates = _collect_candidates(cfg, slack, oldest) + log.info("found %d candidate message(s) since ts=%s", len(candidates), oldest) + + exit_code = 0 + 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) + log.info( + "skipping %s/%s - already %s", + trigger.channel, trigger.ts, existing or "claimed", + ) + 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 + + +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/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 new file mode 100755 index 00000000..7b86da76 --- /dev/null +++ b/plugins/slack-reply/scripts/setup.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Setup script for Slack-triggered OpenHands automations. +# +# 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 + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +fi + +VENV="${SLACK_LISTENER_VENV:-$HOME/.venvs/slack-listener}" +mkdir -p "$(dirname "$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 \ + 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/plugins/slack-reply/scripts/state.py b/plugins/slack-reply/scripts/state.py new file mode 100644 index 00000000..29640089 --- /dev/null +++ b/plugins/slack-reply/scripts/state.py @@ -0,0 +1,400 @@ +"""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" + +# 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: + preferred = Path(os.environ.get("SLACK_STATE_DIR", DEFAULT_STATE_DIR)) + try: + preferred.mkdir(parents=True, exist_ok=True) + 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 + + +_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) +); +""" + + +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) + self._conn.execute("PRAGMA journal_mode=WAL;") + self._conn.execute("PRAGMA synchronous=NORMAL;") + self._conn.executescript(_SQLITE_SCHEMA) + self._conn.commit() + + def claim(self, channel_id: str, ts: str) -> bool: + cur = self._conn.execute( + "INSERT OR IGNORE INTO processed_messages " + "(channel_id, ts, status, claimed_at) VALUES (?, ?, 'claimed', ?)", + (channel_id, ts, _now_iso()), + ) + 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() + + 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 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 close(self) -> None: + try: + self._conn.close() + except sqlite3.Error: + pass 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..c106cd8e --- /dev/null +++ b/skills/slack-channel-listener/SKILL.md @@ -0,0 +1,140 @@ +--- +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. + - `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** + (`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: `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). + +### 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..449222e2 --- /dev/null +++ b/skills/slack-channel-listener/references/poll-setup.md @@ -0,0 +1,155 @@ +# 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 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, `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 ✅ +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. 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). | +| `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. | + +## 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\": \"* * * * *\", + \"timezone\": \"UTC\" + }, + \"tarball_path\": \"${TARBALL_PATH}\", + \"entrypoint\": \"bash run.sh agent_poll.py\", + \"setup_script_path\": \"setup.sh\", + \"enable_kv_store\": true, + \"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\", + \"SLACK_POLL_LOOKBACK_MINUTES\": \"15\", + \"SLACK_STATE_DIR\": \"/automation/storage/state\" + } + }" +``` + +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 | +|---|---| +| 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 when there are no matches, so a +busy cron schedule mostly just costs a few cron dispatches per day, not +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 + +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. + +## 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' +``` + +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 + +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..a3e862ff --- /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\": \"bash run.sh 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..52663bf0 --- /dev/null +++ b/skills/slack-channel-listener/references/tarball-build.md @@ -0,0 +1,153 @@ +# 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/" +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 ) + +# Tar it up +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 +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\": \"bash run.sh 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\": \"* * * * *\"}, + \"tarball_path\": \"${tarball_path}\", + \"entrypoint\": \"bash run.sh agent_poll.py\", + \"setup_script_path\": \"setup.sh\", + \"enable_kv_store\": true, + \"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.