Add slack-channel-listener skill and slack-reply plugin (draft)#245
Draft
tofarr wants to merge 5 commits into
Draft
Add slack-channel-listener skill and slack-reply plugin (draft)#245tofarr wants to merge 5 commits into
tofarr wants to merge 5 commits into
Conversation
Introduce a guided skill that creates an OpenHands automation to monitor one or more Slack channels for configurable trigger phrases and start new agent conversations per match, with results posted as threaded replies. Supports two architectures: - Push mode via Slack Events API (lowest latency, requires backend reachable from Slack). - Poll mode via cron + Slack Web API (works behind firewalls / on laptops; uses reactions as persistent state). Scope can be a single channel, a list, all public channels the bot has joined, or every channel the user can see (via search.messages). Reply includes optional thread context, recent channel context, and user-ID resolution; all behind environment-variable flags. The slack-reply plugin contains the reusable Python scripts that the skill packages into a custom-automation tarball. Co-authored-by: openhands <openhands@all-hands.dev>
Replace the reaction-as-state design with a small SQLite database under $SLACK_STATE_DIR (default /automation/storage/state). claim() does an atomic INSERT OR IGNORE, mark_done()/mark_failed() update the row. Reactions stay available as opt-in visual UX via reply_mode=thread+reaction but no longer drive control flow, so users without reactions:read/write scopes can use plain thread mode. Also bump the recommended cron cadence from */2 * * * * to * * * * *. The state store makes high-frequency polling safe (each message gets exactly one run regardless of how often the cron fires), and the script exits in well under a second when there's nothing to do, so the cost is just a few extra sandbox dispatches per day. Co-authored-by: openhands <openhands@all-hands.dev>
…SQLite Refactor scripts/state.py around a Store protocol with two implementations: - KVApiStore: persists state in the per-automation KV store from OpenHands/automation#69. Storage layout is a single JSON document keyed 'slack_listener_state' holding {<channel>:<ts>: {status, claimed_at, finished_at, error}}. Mutations are read-modify-write with ?if_version=N, with jittered exponential backoff on 409 (lock timeout or version mismatch). - SQLiteStore: existing on-disk implementation, unchanged in behavior, kept as a fallback for environments without the KV store. Defaults to /automation/storage/state but the path is overridable via SLACK_STATE_DIR. A get_store() factory picks KVApiStore when both AUTOMATION_KV_TOKEN and an API URL are present, otherwise falls back to SQLiteStore. Both backends now expose prune_older_than(cutoff_iso) so agent_poll.py can keep state within the KV API's 64 KB cap and stop the SQLite file from growing without bound; the cron entrypoint prunes at 2x lookback on every run. The skill now sets enable_kv_store: true on the automation create calls and the docs explain the two-backend selection logic. Reactions are now strictly opt-in visual UX (reply_mode=thread+reaction); plain thread mode no longer requires reactions:read/write scopes. Tested both backends end-to-end (lifecycle + factory + KV 409 retry) with an in-process stub of the KV API. Co-authored-by: openhands <openhands@all-hands.dev>
setup.sh was using 'uv pip install --system', which writes to the interpreter's site-packages (/usr/local/lib/pythonX.Y/...). That directory is root-owned in the automation sandbox; the run process runs as an unprivileged user, so the install died on EACCES halfway through (annotated_doc was the package that surfaced it during dogfood on PR OpenHands#245). Worse, the dispatcher didn't propagate the setup failure into run.error_detail, so runs stayed in RUNNING forever until the 10-minute timeout. Fix is a dedicated venv at $HOME/.venvs/slack-listener, plus a companion run.sh entrypoint wrapper that exec's the venv's python on the requested script (agent_event.py or agent_poll.py). The venv path is overridable via $SLACK_LISTENER_VENV for testing. Smoke-tested locally as an unprivileged user (matching the failing sandbox's constraint): venv creation, package install, and import of openhands.sdk / openhands.workspace / openhands.tools / slack_sdk all succeed end-to-end. SDK v1.22.1 confirmed working in the venv. Documentation updated: - plugins/slack-reply/SKILL.md describes setup.sh + run.sh roles. - skills/slack-channel-listener/SKILL.md, references/{tarball-build, poll-setup,push-setup}.md now stage run.sh into the tarball and set entrypoint to 'bash run.sh agent_{event,poll}.py'. Co-authored-by: openhands <openhands@all-hands.dev>
The automation sandbox is reused across runs (cron ticks share state).
'uv venv' aborts with exit code 2 if the target directory already
exists, which made the first cron tick succeed but every subsequent
one fail with:
error: Failed to create virtual environment
Caused by: A virtual environment already exists at
`/home/openhands/.venvs/slack-listener`. Use `--clear` to replace it
Guard the venv creation with '[ ! -x $VENV/bin/python ]' so it's a
no-op on re-runs. 'uv pip install' itself is already idempotent and
will still surface dependency upgrades when versions change.
Smoke-tested two back-to-back runs locally — first creates the venv
and installs, second is a no-op exiting 0.
Co-authored-by: openhands <openhands@all-hands.dev>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a guided skill (
slack-channel-listener) and a paired plugin (slack-reply) that together let a user stand up an OpenHands automation which:Motivation: a user asked "create an automation that monitors
#tim-test-openhands-5and starts a conversation whenever someone posts@thems-fightin-words". That request generalises naturally into a reusable skill; this PR packages that generalisation.What's in the box
skills/slack-channel-listener/- guided flow withSKILL.mdplus four reference docs covering push setup, poll setup, multi-channel scope, context options, and tarball assembly.plugins/slack-reply/- reusable Python scripts (agent_event.py,agent_poll.py,slack_client.py,prompt.py,config.py,setup.sh) that the skill packages into a custom-automation tarball.scripts/sync_extensions.pyregeneration.Architecture decisions worth flagging for review
Two trigger paths, picked at install time
eventautomation)conversations.history/search.messages)The push path is the obvious right answer for hosted deployments; the poll path exists so the skill is usable in dev environments and behind corporate firewalls, which would otherwise be a blocker.
Poll uses Slack reactions as persistent state
Rather than persisting
last_seen_tssomewhere, the poll script claims a message by adding a👀reaction before working on it and finishes with✅(or⚠️on failure). This is idempotent, concurrent-safe, visible in the Slack UI, and needs no external store - which matters because the automation sandbox isn't a stable persistence layer.Multi-channel scope is a first-class config
SLACK_CHANNEL_SCOPEis one ofsingle/list/all-public/all-accessible. The most interesting mode isall-accessible, which uses a user token withsearch:readandsearch.messagesto surface matches across every channel the user can see in one API call. Push mode can't doall-accessible(apps can't self-join private channels en masse), so the skill steers users toward poll for that scope.Trigger phrases are a configurable list
Not a single string -
SLACK_TRIGGER_PHRASESis a comma-separated list, case-insensitive substring match. Common case is one phrase; multiple is supported for teams that want@bot,/triage, andhey openhandsto all work."Post on idle" without an in-Conversation hook
The
Conversation.run()call already blocks until the agent terminates (finished, awaiting input, or errored). The script reads the last assistant message fromconversation.state.eventsafterrun()returns and posts it. No callback gymnastics needed - the post-run boundary in the script is the idle boundary. The script also wraps the run intry/exceptso errors produce a threaded⚠️reply rather than silent failure.How I'd recommend reviewing
skills/slack-channel-listener/SKILL.md- that's the entry point a future agent would see.plugins/slack-reply/scripts/agent_event.pyandagent_poll.py- those are the meat.Status / known follow-ups
This is draft intentionally - I want to dogfood it locally (creating the original
@thems-fightin-wordsautomation against#tim-test-openhands-5) and feed back any rough edges before requesting review. Known TODOs:👀reaction; could also post a placeholder message and edit it).SLACK_INCLUDE_FILES/SLACK_INCLUDE_REACTIONSflags (sketched inreferences/context-options.md).timeout).No new dependencies in the repo itself; the plugin's
setup.shaddsslack_sdkonly inside automation sandboxes.This PR was opened by an AI agent (OpenHands) on behalf of @tofarr.