Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2c8f7e2
feat(adapters/slack/slackapi): add Delivery, Handler, and the Webhook…
ethanndickson Oct 15, 2026
5064f7d
refactor(adapters/slack): run the inbound path on slackapi.Webhook an…
ethanndickson Oct 15, 2026
91facb2
feat(adapters/slack/slackapi/slackapitest): add a fake Socket Mode
ethanndickson Oct 15, 2026
78a80a2
docs(adr): add ADR 0017 for Slack Socket Mode
ethanndickson Oct 15, 2026
347e017
feat: add the Listener capability, Chat.Listen, and the listener Shut…
ethanndickson Oct 15, 2026
01c1e5d
test(adapters/slack/slackapi/slackapitest): count the pings that a So…
ethanndickson Oct 15, 2026
f2a496f
feat(adapters/slack/slackapi): add the Socket Mode client with OpenCo…
ethanndickson Oct 15, 2026
c0a927a
fix(adapters/slack/slackapi): back off Socket Mode reconnects as the …
ethanndickson Oct 15, 2026
5785dba
docs(adr): say that Listen retries apps.connections.open with its own…
ethanndickson Oct 15, 2026
07e3869
fix(adapters/slack/slackapi/slackapitest): wake Server.Wait when a Ha…
ethanndickson Oct 15, 2026
646ca52
test(adapters/slack/slackapi): ack on the delivering connection after…
ethanndickson Oct 15, 2026
855c839
fix(adapters/slack/slackapi): close a connection with a close frame w…
ethanndickson Oct 15, 2026
16c38ef
feat(adapters/slack): run Socket Mode with Options.AppToken and Listen
ethanndickson Oct 15, 2026
111655e
docs: rename Webhook Event to Delivery and add the Listener and Socke…
ethanndickson Oct 15, 2026
390b662
docs(adapters/slack): describe both transports, AppToken, Listen, and…
ethanndickson Oct 15, 2026
7edbc74
docs: document Socket Mode, the Listener capability, Chat.Listen, and…
ethanndickson Oct 15, 2026
943b0c1
feat(examples/slack-hello-world): use Socket Mode when SLACK_APP_TOKE…
ethanndickson Oct 15, 2026
470e0dc
docs(tutorials): make Socket Mode the main path of the Slack tutorial
ethanndickson Oct 15, 2026
10402c2
docs: lead the README with Socket Mode and cover both transports in t…
ethanndickson Oct 15, 2026
d38543e
chore: drop the go.work.sum lines that a docs commit added by mistake
ethanndickson Oct 15, 2026
796e735
test(adapters/slack): compare the resends of a rejected delivery in s…
ethanndickson Oct 15, 2026
83a06cb
fix(examples/slack-hello-world): stop Socket Mode with Runtime Shutdo…
ethanndickson Oct 15, 2026
85a32cf
docs: stop listeners with Shutdown, not with the Listen context
ethanndickson Oct 15, 2026
a32cfad
docs(adapters/slack): say that the busy reply covers every admission …
ethanndickson Oct 15, 2026
53546a7
refactor(adapters/slack/slackapi/slackapitest): share one encoder for…
ethanndickson Oct 15, 2026
6817dbc
fix(adapters/slack/slackapi): end the Socket Mode backoff when a repl…
ethanndickson Oct 15, 2026
192cfd6
feat(adapters/slack/slackapi): export ErrConnectTimeout for OnError
ethanndickson Oct 15, 2026
1c2d84a
test(adapters/slack): give the reconnect timing check more slack and …
ethanndickson Oct 15, 2026
a37a30e
docs(adr): note in ADR 0015 that ADR 0017 closes sync admission at Sh…
ethanndickson Oct 15, 2026
526b1a9
test(adapters/slack/slackapi): read a Socket Mode envelope larger tha…
ethanndickson Oct 15, 2026
f92fa13
docs(adapters/slack/slackapi): say when OnError gets a DisconnectError
ethanndickson Oct 15, 2026
a386e13
test(slackapi): live Socket Mode smoke test
ethanndickson Oct 16, 2026
b9ef85f
fix(slackapi): hide the Socket Mode ticket in dial errors
ethanndickson Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
.clawpatch/

# Example build artifacts (go build in examples/* writes binaries next to main.go)
# Example build artifacts (go build in examples/* writes binaries next to main.go,
# and go build ./examples/slack-hello-world at the root writes one there)
/slack-hello-world
examples/slack-hello-world/slack-hello-world
examples/slack-nats-state/slack-nats-state
examples/slack-postgres-state/slack-postgres-state
examples/slack-redis-state/slack-redis-state
72 changes: 44 additions & 28 deletions CONTEXT.md

Large diffs are not rendered by default.

53 changes: 36 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
[![Latest release](https://img.shields.io/github/v/release/coder/chat)](https://github.com/coder/chat/releases/latest)

Build Slack and Linear bots in Go. You write handlers for mentions and
thread replies; the runtime verifies webhooks, dedupes redeliveries,
serializes work per thread, and retries rate-limited API calls.
thread replies; the runtime receives events over verified webhooks or Slack
Socket Mode, dedupes redeliveries, serializes work per thread, and retries
rate-limited API calls.

- **Plain Go.** `context.Context`, `net/http`, small interfaces, and returned
errors. It is a library, not a framework.
- **Scales out.** Replicas share state on Redis, Postgres, or NATS JetStream,
so they dedupe redeliveries and serialize work per thread.
- **Handles slow work.** Opt in to deferred dispatch to acknowledge the
webhook first, then run LLM calls and other long handlers on a detached
event first, then run LLM calls and other long handlers on a detached
context.

## Hello, Slack
Expand All @@ -27,8 +28,8 @@ package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"

"github.com/coder/chat"
"github.com/coder/chat/adapters/slack"
Expand All @@ -39,8 +40,8 @@ func main() {
ctx := context.Background()

slackAdapter, err := slack.New(ctx, slack.Options{
SigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
BotToken: os.Getenv("SLACK_BOT_TOKEN"),
AppToken: os.Getenv("SLACK_APP_TOKEN"), // Socket Mode: no public URL needed
BotToken: os.Getenv("SLACK_BOT_TOKEN"),
})
if err != nil {
log.Fatal(err)
Expand All @@ -59,26 +60,39 @@ func main() {
return err
})

webhook, err := bot.Webhook("slack")
if err != nil {
// Listen runs until Shutdown stops it.
go func() {
if err := bot.Listen(ctx, "slack"); err != nil {
log.Fatal(err)
}
}()

// On Ctrl-C, Shutdown stops acking new events, and then closes the connection.
interrupted, stop := signal.NotifyContext(ctx, os.Interrupt)
defer stop()
<-interrupted.Done()
if err := bot.Shutdown(ctx); err != nil {
log.Fatal(err)
}
http.Handle("/webhooks/slack", webhook)
log.Fatal(http.ListenAndServe(":8080", nil))
}
```

Point your Slack app's Event Subscriptions request URL at
`https://YOUR_HOST/webhooks/slack`, mention the bot, and it replies in a
thread. The [tutorial](docs/tutorials/slack-bot.md) walks through the Slack
app setup in under 30 minutes using
Turn on Socket Mode in your Slack app and create an app-level token with the
`connections:write` scope. Run the bot, mention it, and it replies in a
thread. The bot opens a websocket to Slack, so it needs no public URL. To
receive events over HTTP instead, set `SigningSecret` and serve
`bot.Webhook("slack")` at your app's Event Subscriptions request URL. The
[tutorial](docs/tutorials/slack-bot.md) walks through the Slack app setup in
under 30 minutes using
[`examples/slack-hello-world`](examples/slack-hello-world/), the same bot
with server timeouts and environment checks.
with both transports, server timeouts, graceful shutdown, and environment
checks.

## Install

Chat SDK Go requires Go 1.26.3 or newer. The core module contains the
runtime, the Slack and Linear adapters, and the memory state backend:
runtime, the Slack and Linear adapters, and the memory state backend. Its
one dependency is `github.com/coder/websocket`, for Slack Socket Mode:

```sh
go get github.com/coder/chat
Expand All @@ -101,8 +115,13 @@ go get github.com/coder/chat/state/nats
- **Shared state you already run.** Memory for development; Redis, Postgres,
or NATS JetStream in production, all tested by one conformance suite —
[choose a state backend](docs/how-to/choose-a-state-backend.md).
- **Two Slack transports.** Socket Mode needs no public URL; the Events API
over HTTP suits apps on a hosting service and Slack Marketplace apps. Both
use one adapter pipeline, so handlers get the same events. `Chat.Listen`
runs Socket Mode, `Chat.Webhook` serves HTTP, and a bot can use both. See
[receiving deliveries](docs/reference.md#receiving-deliveries).
- **Ack-then-work dispatch.** Handlers run synchronously by default; opt in
to `DispatchDeferred` to run them after the webhook is acknowledged, with
to `DispatchDeferred` to run them after the event is acknowledged, with
lock renewal and an admission cap. Five concurrency strategies (drop,
queue, debounce, concurrent, burst) decide what happens when events
overlap on one thread; debounce and burst require deferred dispatch —
Expand Down
39 changes: 32 additions & 7 deletions adapters/slack/doc.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
// Package slack provides the Slack adapter for the chat runtime.
//
// # Transports (ADR 0017)
//
// The adapter receives deliveries from Slack over HTTP, over Socket Mode, or
// over both at the same time. Options.SigningSecret enables the HTTP
// transport: Webhook returns an http.Handler for the request URLs of the
// Slack app (Events API, slash commands, and interactivity), and it checks
// the signature of each request. Options.AppToken, an app-level token (xapp-)
// with the connections:write scope, enables the Socket Mode transport: Listen
// opens a websocket to Slack, so the app needs no public URL. Listen
// implements the chat.Listener Optional Capability, and the application runs
// it with Chat.Listen. At least one of SigningSecret and AppToken is
// required. Without a SigningSecret, Webhook answers 404 to each request.
// Without an AppToken, Listen returns an error.
//
// Both transports give each delivery to one delivery handler, so a payload
// becomes the same chat.Event over HTTP and over Socket Mode. When the runtime
// rejects a delivery at its Admission Bound or after Runtime Shutdown starts,
// the adapter acks a slash command with a busy reply that Slack shows to the
// user, because Slack does not send a slash command again. It does not ack an
// Events API or interactivity delivery, so Slack can send an Events API
// delivery again. Listen logs the errors that it recovers from to
// Options.Logger, and returns when Slack rejects the app token or turns Socket
// Mode off.
//
// # Multi-tenant installs (ADR 0006)
//
// Multi-tenant is opt-in. By default the adapter is a Single-Install Adapter with
Expand All @@ -14,13 +38,14 @@
// button, authorize redirect, callback, and token exchange) and account linking /
// Application Identity stay app-owned and out of the adapter.
//
// During webhook handling the adapter verifies the signature with the shared
// app-level Options.SigningSecret (Slack signs per app, never per workspace), then
// resolves the per-workspace install via InstallStore.Lookup keyed by the
// team_id Platform Tenant. The install-record bot token is used only for the
// reply. A chat.ErrInstallNotFound from Lookup is an Ignored Event (acknowledged,
// not dispatched); any other Lookup error is a transport failure the platform may
// retry. Lookup runs per webhook; the adapter does not cache install records.
// Both transports work the same in this mode. Options.SigningSecret and
// Options.AppToken are app-level (Slack has one of each per app, never one per
// workspace). For each delivery, the adapter resolves the per-workspace install
// via InstallStore.Lookup keyed by the team_id Platform Tenant. The
// install-record bot token is used only for the reply. A chat.ErrInstallNotFound
// from Lookup is an Ignored Event (acknowledged, not dispatched); any other
// Lookup error is a transport failure the platform may retry. Lookup runs per
// delivery; the adapter does not cache install records.
//
// The install credential rides as the adapter-specific slack.SlackInstall payload
// on chat.Install.Credential (a Platform Escape Hatch for credentials): the
Expand Down
173 changes: 59 additions & 114 deletions adapters/slack/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"

"github.com/coder/chat"
"github.com/coder/chat/adapters/slack/slackapi"
)

// Compile-time assertions for the Optional Capabilities reached via Adapter Access.
Expand All @@ -20,73 +19,50 @@ var (

// commandForm is the Supported Platform Shape for a Slack slash command.
type commandForm struct {
Command string
Text string
TeamID string
ChannelID string
ChannelName string
UserID string
TriggerID string
ResponseURL string
APIAppID string
Command string `json:"command"`
Text string `json:"text"`
TeamID string `json:"team_id"`
ChannelID string `json:"channel_id"`
ChannelName string `json:"channel_name"`
UserID string `json:"user_id"`
TriggerID string `json:"trigger_id"`
ResponseURL string `json:"response_url"`
APIAppID string `json:"api_app_id"`
}

// handleCommandForm resolves the per-tenant install for a slash command, then
// normalizes and dispatches it. A not-installed workspace is an Ignored Event
// (ack 200, no dispatch); a store transport error is a 5xx the platform may retry.
func (a *Adapter) handleCommandForm(w http.ResponseWriter, r *http.Request, dispatch chat.DispatchFunc, form url.Values) {
_, found, err := a.resolveInstall(r.Context(), form.Get("team_id"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
// commandBusyMessage is the truthful busy signal a slash command invoker sees
// when the runtime rejects the invocation at admission (ADR 0015). It renders
// as the command's ephemeral response.
const commandBusyMessage = "The bot is at capacity right now. Your command was not run — please try again in a moment."

// commandBusyReply is the ack body of a slash command that the runtime rejects
// at admission (the Admission Bound or Runtime Shutdown). Slack shows its text
// to the invoking user.
type commandBusyReply struct {
Text string `json:"text"`
}

// normalizeCommand returns the Command Event of a slash command delivery, or a
// nil Event when the workspace has no install. A missing required field or a
// cross-tenant team is an invalid payload, like a malformed event.
func (a *Adapter) normalizeCommand(ctx context.Context, d *slackapi.Delivery) (*chat.Event, error) {
var cmd commandForm
if err := json.Unmarshal(d.Payload, &cmd); err != nil {
return nil, invalidPayload(fmt.Errorf("slack: invalid command payload: %w", err))
}
if !found {
w.WriteHeader(http.StatusOK)
return
_, found, err := a.resolveInstall(ctx, cmd.TeamID)
if err != nil || !found {
return nil, err
}
event, err := a.normalizeCommand(r, form)
event, err := a.commandEvent(cmd, d)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := dispatch(r.Context(), event); err != nil {
if errors.Is(err, chat.ErrAdmissionRejected) {
// Slack does not redeliver slash commands, so a retry-inducing
// status would lose the invocation with only a generic Slack error
// shown. The shape's acknowledgement contract renders the ack body
// as an ephemeral message to the invoking user, so answer 200 with
// a truthful busy signal instead — never a silent failure, never a
// fake success.
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(commandBusyMessage))
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return nil, invalidPayload(err)
}
w.WriteHeader(http.StatusOK)
return event, nil
}

// commandBusyMessage is the truthful busy signal a slash command invoker sees
// when the runtime's Admission Bound rejects the invocation (ADR 0015). It
// renders as the command's ephemeral response.
const commandBusyMessage = "The bot is at capacity right now. Your command was not run — please try again in a moment."

// normalizeCommand decodes and validates a slash command into a Command Event. A
// missing required field or a cross-tenant team is a 400, like a malformed event.
func (a *Adapter) normalizeCommand(r *http.Request, form url.Values) (*chat.Event, error) {
cmd := commandForm{
Command: form.Get("command"),
Text: form.Get("text"),
TeamID: form.Get("team_id"),
ChannelID: form.Get("channel_id"),
ChannelName: form.Get("channel_name"),
UserID: form.Get("user_id"),
TriggerID: form.Get("trigger_id"),
ResponseURL: form.Get("response_url"),
APIAppID: form.Get("api_app_id"),
}
// commandEvent validates a slash command and returns its Command Event.
func (a *Adapter) commandEvent(cmd commandForm, d *slackapi.Delivery) (*chat.Event, error) {
if cmd.Command == "" {
return nil, errors.New("slack: command is required")
}
Expand Down Expand Up @@ -128,11 +104,8 @@ func (a *Adapter) normalizeCommand(r *http.Request, form url.Values) (*chat.Even
Tenant: cmd.TeamID,
ThreadID: threadID,
DirectMessage: direct,
Retry: chat.RetryMetadata{
Num: r.Header.Get("X-Slack-Retry-Num"),
Reason: r.Header.Get("X-Slack-Retry-Reason"),
},
Raw: cmd,
Retry: retryMetadata(d),
Raw: cmd,
Command: &chat.Command{
Name: cmd.Command,
Text: cmd.Text,
Expand Down Expand Up @@ -195,58 +168,33 @@ type selectedOption struct {
Value string `json:"value"`
}

// handleInteractionForm resolves the per-tenant install for an interactivity
// payload, then normalizes and dispatches it. An unsupported interaction type and
// a not-installed workspace are Ignored Events (ack 200, no dispatch); a store
// transport error is a 5xx the platform may retry; a missing required field is a
// 400.
func (a *Adapter) handleInteractionForm(w http.ResponseWriter, r *http.Request, dispatch chat.DispatchFunc, raw string) {
// normalizeInteraction returns the Interaction Event of an interactivity
// delivery, or a nil Event for an Ignored Event: an interaction type that the
// adapter does not support, or a workspace with no install. A missing required
// field is an invalid payload, never an ignore.
func (a *Adapter) normalizeInteraction(ctx context.Context, d *slackapi.Delivery) (*chat.Event, error) {
var payload interactionPayload
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
http.Error(w, "slack: invalid interactivity payload", http.StatusBadRequest)
return
if err := json.Unmarshal(d.Payload, &payload); err != nil {
return nil, invalidPayload(fmt.Errorf("slack: invalid interactivity payload: %w", err))
}
payload.Raw = json.RawMessage(raw)

payload.Raw = d.Payload
if payload.Type != "block_actions" {
w.WriteHeader(http.StatusOK)
return
}

_, found, err := a.resolveInstall(r.Context(), payload.Team.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return nil, nil
}
if !found {
w.WriteHeader(http.StatusOK)
return
_, found, err := a.resolveInstall(ctx, payload.Team.ID)
if err != nil || !found {
return nil, err
}

event, err := a.normalizeInteraction(r, payload)
event, err := a.interactionEvent(payload, d)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := dispatch(r.Context(), event); err != nil {
if errors.Is(err, chat.ErrAdmissionRejected) {
// Slack does not redeliver block_actions, and the shape's ack body
// is not rendered to the user; a non-2xx ack is the truthful
// signal the shape supports — Slack surfaces the failed delivery
// as a visible warning on the activated component, and the
// rejection stays observable through Runtime Observation.
http.Error(w, "slack runtime at capacity", http.StatusServiceUnavailable)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return nil, invalidPayload(err)
}
w.WriteHeader(http.StatusOK)
return event, nil
}

// normalizeInteraction decodes a verified Slack block_actions payload into an
// Interaction Event. A missing required field is a 400, never an ignore.
func (a *Adapter) normalizeInteraction(r *http.Request, payload interactionPayload) (*chat.Event, error) {
// interactionEvent validates a block_actions payload and returns its
// Interaction Event.
func (a *Adapter) interactionEvent(payload interactionPayload, d *slackapi.Delivery) (*chat.Event, error) {
if payload.Team.ID == "" {
return nil, errors.New("slack: interaction team id is required")
}
Expand Down Expand Up @@ -301,11 +249,8 @@ func (a *Adapter) normalizeInteraction(r *http.Request, payload interactionPaylo
Tenant: payload.Team.ID,
ThreadID: threadID,
DirectMessage: direct,
Retry: chat.RetryMetadata{
Num: r.Header.Get("X-Slack-Retry-Num"),
Reason: r.Header.Get("X-Slack-Retry-Reason"),
},
Raw: payload,
Retry: retryMetadata(d),
Raw: payload,
Interaction: &chat.Interaction{
Kind: chat.InteractionBlockAction,
ActionID: action.ActionID,
Expand Down
Loading
Loading