From 2c8f7e23bc904d499f3115a4f4e2439a4f5aa7b5 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 02:38:10 +0000 Subject: [PATCH 01/33] feat(adapters/slack/slackapi): add Delivery, Handler, and the Webhook HTTP transport A Delivery is one inbound Slack payload, the same for every transport, and a Handler gives its acknowledgement. Webhook is the HTTP transport: it checks the signature, answers url_verification, turns each request into a Delivery, and maps the Handler result to a status. --- adapters/slack/slackapi/delivery.go | 69 ++++++++ adapters/slack/slackapi/doc.go | 15 +- adapters/slack/slackapi/webhook.go | 171 +++++++++++++++++++ adapters/slack/slackapi/webhook_test.go | 218 ++++++++++++++++++++++++ 4 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 adapters/slack/slackapi/delivery.go create mode 100644 adapters/slack/slackapi/webhook.go create mode 100644 adapters/slack/slackapi/webhook_test.go diff --git a/adapters/slack/slackapi/delivery.go b/adapters/slack/slackapi/delivery.go new file mode 100644 index 0000000..8bd9903 --- /dev/null +++ b/adapters/slack/slackapi/delivery.go @@ -0,0 +1,69 @@ +package slackapi + +import ( + "context" + "encoding/json" + "errors" +) + +// maxPayloadBytes is the largest inbound payload that Webhook and Listen +// accept. +const maxPayloadBytes = 1 << 20 + +// DeliveryType is the kind of payload in a Delivery. The values are the +// Socket Mode envelope types. +type DeliveryType string + +// Delivery types. +const ( + // DeliveryEventsAPI is an Events API payload: the same JSON body that + // Slack posts to the Events API request URL, which ParseEnvelope decodes. + DeliveryEventsAPI DeliveryType = "events_api" + // DeliverySlashCommands is a slash command: a JSON object with one string + // value for each command field, for example "command", "text", and + // "team_id". + DeliverySlashCommands DeliveryType = "slash_commands" + // DeliveryInteractive is an interactivity payload, for example a + // block_actions payload. + DeliveryInteractive DeliveryType = "interactive" +) + +// Delivery is one inbound payload from Slack. It is the same for both +// transports: Webhook makes one from an HTTP request, and Listen makes one +// from a Socket Mode envelope. +type Delivery struct { + // Type is the kind of payload. Listen also passes an envelope type that + // is not one of the DeliveryType constants. + Type DeliveryType + // Payload is the JSON payload. Its shape depends on Type. + Payload json.RawMessage + // RetryAttempt is the number of the redelivery. It is 0 on the first + // delivery. + RetryAttempt int + // RetryReason is the reason that Slack gives for a redelivery, for example + // "timeout". It is empty on the first delivery. + RetryReason string +} + +// Handler handles one Delivery and gives the acknowledgement. A nil error +// acknowledges the delivery. When response is not nil, it is the +// acknowledgement body, encoded as JSON. A non-nil error does not acknowledge +// the delivery: +// +// - ErrBusy: Webhook answers 503. Listen sends no ack. +// - ErrInvalidPayload: Webhook answers 400. Listen sends no ack. +// - Another error: Webhook answers 500. Listen sends no ack. +// +// Slack redelivers an Events API payload that it does not get an ack for. +// Slack expects the acknowledgement within 3 seconds, so a Handler must not +// do slow work before it returns. +type Handler func(ctx context.Context, d *Delivery) (response any, err error) + +var ( + // ErrBusy is the error that a Handler returns, alone or wrapped, when it + // cannot accept a delivery now. Slack can redeliver it later. + ErrBusy = errors.New("slack: busy") + // ErrInvalidPayload is the error that a Handler returns, alone or + // wrapped, when a payload is malformed or not valid for the app. + ErrInvalidPayload = errors.New("slack: invalid payload") +) diff --git a/adapters/slack/slackapi/doc.go b/adapters/slack/slackapi/doc.go index 298a15c..2b8578b 100644 --- a/adapters/slack/slackapi/doc.go +++ b/adapters/slack/slackapi/doc.go @@ -44,10 +44,19 @@ // in Options.FileOrigins, and both check every redirect too, so a URL from an // untrusted payload cannot send the token or file content to another host. // -// # Inbound requests +// # Inbound payloads // -// VerifyRequest checks the Slack v0 signature of a webhook request. -// ParseEnvelope decodes an Events API request body, and Envelope.MessageEvent +// A Delivery is one inbound payload from Slack: an Events API payload, a slash +// command, or an interactivity payload. A Handler handles a Delivery and gives +// the acknowledgement. Its error tells the transport how to answer Slack: +// ErrBusy, ErrInvalidPayload, or another error. +// +// Webhook is the HTTP transport. It is an http.Handler for the request URLs of +// the app. It checks the signature with VerifyRequest, answers the +// url_verification request, and gives each other request to its Handler as a +// Delivery. +// +// ParseEnvelope decodes an Events API payload, and Envelope.MessageEvent // decodes its app_mention or message event. // // # Formats diff --git a/adapters/slack/slackapi/webhook.go b/adapters/slack/slackapi/webhook.go new file mode 100644 index 0000000..36eede3 --- /dev/null +++ b/adapters/slack/slackapi/webhook.go @@ -0,0 +1,171 @@ +package slackapi + +import ( + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "net/url" + "strconv" + "time" +) + +// Webhook is the HTTP transport for inbound Slack payloads. It serves the +// Events API request URL, the slash command request URL, and the +// interactivity request URL, and makes a Delivery for Handler from each +// request. It is safe for concurrent use. +// +// ServeHTTP does these steps: +// +// 1. It accepts only POST (else 405) and a body of at most 1 MiB (else +// 413). +// 2. It checks the request signature with VerifyRequest before it parses +// the body (else 401). +// 3. A form body with a "payload" field becomes a DeliveryInteractive. +// A form body with a "command" field becomes a DeliverySlashCommands, +// encoded as a JSON object of strings, which is the payload that Socket +// Mode sends. Another form body, or a "payload" field that is not valid +// JSON, is a 400. +// 4. A JSON body of type EnvelopeURLVerification gets the challenge as the +// answer, and Handler does not see it. Another JSON body becomes a +// DeliveryEventsAPI. A body that is not valid JSON is a 400. +// 5. The X-Slack-Retry-Num and X-Slack-Retry-Reason headers become +// Delivery.RetryAttempt and Delivery.RetryReason. +// 6. The result of Handler gives the status, as the Handler documentation +// shows. +type Webhook struct { + // SigningSecret is the signing secret of the Slack app. It is required. + SigningSecret string + // Handler handles each delivery. It is required. + Handler Handler + // Now returns the time for the signature check. Nil uses time.Now. + Now func() time.Time + // SignatureTolerance is the maximum clock skew for the signature check. + // Zero or less uses DefaultSignatureTolerance. + SignatureTolerance time.Duration +} + +var _ http.Handler = (*Webhook)(nil) + +// ServeHTTP handles one request from Slack. +func (h *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxPayloadBytes)) + if err != nil { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + http.Error(w, "slack: payload too large", http.StatusRequestEntityTooLarge) + return + } + http.Error(w, "slack: read body", http.StatusBadRequest) + return + } + now := time.Now + if h.Now != nil { + now = h.Now + } + if err := VerifyRequest(r.Header, body, h.SigningSecret, now(), h.SignatureTolerance); err != nil { + http.Error(w, "slack: invalid signature", http.StatusUnauthorized) + return + } + + d, challenge, err := parseRequest(r.Header, body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if d == nil { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = io.WriteString(w, challenge) + return + } + + response, err := h.Handler(r.Context(), d) + switch { + case err == nil: + writeResponse(w, response) + case errors.Is(err, ErrBusy): + http.Error(w, err.Error(), http.StatusServiceUnavailable) + case errors.Is(err, ErrInvalidPayload): + http.Error(w, err.Error(), http.StatusBadRequest) + default: + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// parseRequest returns the Delivery of a verified request body. For a +// url_verification request it returns a nil Delivery and the challenge. +func parseRequest(header http.Header, body []byte) (*Delivery, string, error) { + d := &Delivery{RetryReason: header.Get("X-Slack-Retry-Reason")} + if attempt, err := strconv.Atoi(header.Get("X-Slack-Retry-Num")); err == nil { + d.RetryAttempt = attempt + } + + if isFormContentType(header.Get("Content-Type")) { + form, err := url.ParseQuery(string(body)) + if err != nil { + return nil, "", errors.New("slack: invalid form body") + } + switch { + case form.Has("payload"): + payload := form.Get("payload") + if !json.Valid([]byte(payload)) { + return nil, "", errors.New("slack: invalid interactivity payload") + } + d.Type = DeliveryInteractive + d.Payload = json.RawMessage(payload) + case form.Has("command"): + fields := make(map[string]string, len(form)) + for key := range form { + fields[key] = form.Get(key) + } + payload, err := json.Marshal(fields) + if err != nil { + return nil, "", err + } + d.Type = DeliverySlashCommands + d.Payload = payload + default: + return nil, "", errors.New("slack: unsupported form body") + } + return d, "", nil + } + + var head struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + } + if err := json.Unmarshal(body, &head); err != nil { + return nil, "", errors.New("slack: invalid JSON body") + } + if head.Type == EnvelopeURLVerification { + return nil, head.Challenge, nil + } + d.Type = DeliveryEventsAPI + d.Payload = json.RawMessage(body) + return d, "", nil +} + +// writeResponse writes a 200 with response as the JSON body, or with no body +// when response is nil. +func writeResponse(w http.ResponseWriter, response any) { + if response == nil { + w.WriteHeader(http.StatusOK) + return + } + body, err := json.Marshal(response) + if err != nil { + http.Error(w, "slack: encode response", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) +} + +func isFormContentType(contentType string) bool { + mediaType, _, err := mime.ParseMediaType(contentType) + return err == nil && mediaType == "application/x-www-form-urlencoded" +} diff --git a/adapters/slack/slackapi/webhook_test.go b/adapters/slack/slackapi/webhook_test.go new file mode 100644 index 0000000..460db50 --- /dev/null +++ b/adapters/slack/slackapi/webhook_test.go @@ -0,0 +1,218 @@ +package slackapi_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/coder/chat/adapters/slack/slackapi" + "github.com/coder/chat/adapters/slack/slackapi/slackapitest" +) + +const webhookSecret = "webhook-secret" + +var webhookNow = time.Unix(1_700_000_000, 0) + +// serveWebhook sends a signed POST with body to a Webhook and returns the +// recorder and the deliveries that the handler got. +func serveWebhook(t *testing.T, contentType, body string, header http.Header, handler slackapi.Handler) (*httptest.ResponseRecorder, []*slackapi.Delivery) { + t.Helper() + var got []*slackapi.Delivery + webhook := &slackapi.Webhook{ + SigningSecret: webhookSecret, + Now: func() time.Time { return webhookNow }, + Handler: func(ctx context.Context, d *slackapi.Delivery) (any, error) { + got = append(got, d) + if handler == nil { + return nil, nil + } + return handler(ctx, d) + }, + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/slack", strings.NewReader(body)) + req.Header.Set("Content-Type", contentType) + for key, values := range header { + req.Header[key] = values + } + slackapitest.SignRequest(req, []byte(body), webhookSecret, webhookNow) + rec := httptest.NewRecorder() + webhook.ServeHTTP(rec, req) + return rec, got +} + +func TestWebhookDeliveries(t *testing.T) { + t.Parallel() + + const eventBody = `{"type":"event_callback","team_id":"T1","event_id":"Ev1","event":{"type":"app_mention"}}` + const interaction = `{"type":"block_actions","team":{"id":"T1"}}` + tests := []struct { + name string + contentType string + body string + header http.Header + want slackapi.Delivery + }{ + { + name: "events api", + contentType: "application/json", + body: eventBody, + want: slackapi.Delivery{Type: slackapi.DeliveryEventsAPI, Payload: json.RawMessage(eventBody)}, + }, + { + name: "events api retry", + contentType: "application/json", + body: eventBody, + header: http.Header{"X-Slack-Retry-Num": {"2"}, "X-Slack-Retry-Reason": {"http_timeout"}}, + want: slackapi.Delivery{Type: slackapi.DeliveryEventsAPI, Payload: json.RawMessage(eventBody), RetryAttempt: 2, RetryReason: "http_timeout"}, + }, + { + name: "other envelope type", + contentType: "application/json", + body: `{"type":"app_rate_limited"}`, + want: slackapi.Delivery{Type: slackapi.DeliveryEventsAPI, Payload: json.RawMessage(`{"type":"app_rate_limited"}`)}, + }, + { + name: "interactivity", + contentType: "application/x-www-form-urlencoded", + body: url.Values{"payload": {interaction}}.Encode(), + want: slackapi.Delivery{Type: slackapi.DeliveryInteractive, Payload: json.RawMessage(interaction)}, + }, + { + name: "slash command", + contentType: "application/x-www-form-urlencoded; charset=utf-8", + body: url.Values{"command": {"/deploy"}, "text": {"api now"}, "team_id": {"T1"}}.Encode(), + want: slackapi.Delivery{Type: slackapi.DeliverySlashCommands, Payload: json.RawMessage(`{"command":"/deploy","team_id":"T1","text":"api now"}`)}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rec, got := serveWebhook(t, tt.contentType, tt.body, tt.header, nil) + if rec.Code != http.StatusOK || rec.Body.Len() != 0 { + t.Fatalf("response = %d %q, want 200 with no body", rec.Code, rec.Body.String()) + } + if len(got) != 1 { + t.Fatalf("deliveries = %d, want 1", len(got)) + } + d := got[0] + if d.Type != tt.want.Type || string(d.Payload) != string(tt.want.Payload) || + d.RetryAttempt != tt.want.RetryAttempt || d.RetryReason != tt.want.RetryReason { + t.Fatalf("delivery = %+v (payload %s), want %+v (payload %s)", *d, d.Payload, tt.want, tt.want.Payload) + } + }) + } +} + +func TestWebhookURLVerification(t *testing.T) { + t.Parallel() + + rec, got := serveWebhook(t, "application/json", `{"type":"url_verification","challenge":"abc123"}`, nil, nil) + if rec.Code != http.StatusOK || rec.Body.String() != "abc123" { + t.Fatalf("response = %d %q, want 200 with the challenge", rec.Code, rec.Body.String()) + } + if len(got) != 0 { + t.Fatalf("handler got %d deliveries, want 0", len(got)) + } +} + +func TestWebhookRejectsRequests(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + contentType string + body string + want int + }{ + {name: "invalid JSON", contentType: "application/json", body: `{`, want: http.StatusBadRequest}, + {name: "invalid interactivity JSON", contentType: "application/x-www-form-urlencoded", body: url.Values{"payload": {"{"}}.Encode(), want: http.StatusBadRequest}, + {name: "unsupported form", contentType: "application/x-www-form-urlencoded", body: "foo=bar", want: http.StatusBadRequest}, + {name: "too large", contentType: "application/json", body: strings.Repeat("a", 1<<20+1), want: http.StatusRequestEntityTooLarge}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rec, got := serveWebhook(t, tt.contentType, tt.body, nil, nil) + if rec.Code != tt.want { + t.Fatalf("status = %d, want %d (body %q)", rec.Code, tt.want, rec.Body.String()) + } + if len(got) != 0 { + t.Fatalf("handler got %d deliveries, want 0", len(got)) + } + }) + } +} + +func TestWebhookRejectsUnsignedAndNonPost(t *testing.T) { + t.Parallel() + + webhook := &slackapi.Webhook{ + SigningSecret: webhookSecret, + Handler: func(context.Context, *slackapi.Delivery) (any, error) { + t.Error("handler must not run") + return nil, nil + }, + } + body := `{"type":"event_callback"}` + + unsigned := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/slack", strings.NewReader(body)) + rec := httptest.NewRecorder() + webhook.ServeHTTP(rec, unsigned) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("unsigned status = %d, want 401", rec.Code) + } + + get := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/slack", nil) + rec = httptest.NewRecorder() + webhook.ServeHTTP(rec, get) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("GET status = %d, want 405", rec.Code) + } +} + +func TestWebhookHandlerOutcomes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + response any + err error + status int + body string + }{ + {name: "ack", status: http.StatusOK}, + {name: "ack with response", response: map[string]string{"text": "hi"}, status: http.StatusOK, body: `{"text":"hi"}`}, + {name: "busy", err: fmt.Errorf("queue full: %w", slackapi.ErrBusy), status: http.StatusServiceUnavailable}, + {name: "invalid payload", err: fmt.Errorf("%w: no team", slackapi.ErrInvalidPayload), status: http.StatusBadRequest}, + {name: "other error", err: errors.New("store down"), status: http.StatusInternalServerError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rec, _ := serveWebhook(t, "application/json", `{"type":"event_callback"}`, nil, func(context.Context, *slackapi.Delivery) (any, error) { + return tt.response, tt.err + }) + if rec.Code != tt.status { + t.Fatalf("status = %d, want %d", rec.Code, tt.status) + } + if tt.body != "" { + if got := rec.Body.String(); got != tt.body { + t.Fatalf("body = %q, want %q", got, tt.body) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + } + if tt.status == http.StatusOK && tt.body == "" && rec.Body.Len() != 0 { + t.Fatalf("body = %q, want empty", rec.Body.String()) + } + }) + } +} From 5064f7d972e4f790bc0d2af2e19af82f55cfb0d1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 02:38:10 +0000 Subject: [PATCH 02/33] refactor(adapters/slack): run the inbound path on slackapi.Webhook and one delivery handler The adapter now has one inbound pipeline that takes a slackapi.Delivery, so a second transport needs no new parse or dispatch code. HTTP statuses stay the same. The busy reply of a slash command is now JSON, because a Socket Mode ack must be JSON and Slack accepts JSON over HTTP too. --- adapters/slack/interactive.go | 172 +++++++++--------------- adapters/slack/slack.go | 237 ++++++++++++++++------------------ 2 files changed, 166 insertions(+), 243 deletions(-) diff --git a/adapters/slack/interactive.go b/adapters/slack/interactive.go index 179d178..0a775da 100644 --- a/adapters/slack/interactive.go +++ b/adapters/slack/interactive.go @@ -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. @@ -20,52 +19,15 @@ 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 -} - -// 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 - } - if !found { - w.WriteHeader(http.StatusOK) - return - } - event, err := a.normalizeCommand(r, form) - 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 - } - w.WriteHeader(http.StatusOK) + 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"` } // commandBusyMessage is the truthful busy signal a slash command invoker sees @@ -73,20 +35,33 @@ func (a *Adapter) handleCommandForm(w http.ResponseWriter, r *http.Request, disp // 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"), +// commandBusyReply is the ack body of a slash command that the Admission Bound +// rejects. 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)) } + _, found, err := a.resolveInstall(ctx, cmd.TeamID) + if err != nil || !found { + return nil, err + } + event, err := a.commandEvent(cmd, d) + if err != nil { + return nil, invalidPayload(err) + } + return event, nil +} + +// 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") } @@ -128,11 +103,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, @@ -195,58 +167,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") } @@ -301,11 +248,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, diff --git a/adapters/slack/slack.go b/adapters/slack/slack.go index d910b01..2ba1235 100644 --- a/adapters/slack/slack.go +++ b/adapters/slack/slack.go @@ -6,11 +6,9 @@ import ( "encoding/json" "errors" "fmt" - "io" "log/slog" - "mime" "net/http" - "net/url" + "strconv" "strings" "time" @@ -18,10 +16,7 @@ import ( "github.com/coder/chat/adapters/slack/slackapi" ) -const ( - adapterName = "slack" - maxWebhookBodyBytes = 1 << 20 -) +const adapterName = "slack" type Options struct { SigningSecret string @@ -189,110 +184,85 @@ func (a *Adapter) Shutdown(context.Context) error { return nil } +// Webhook returns the HTTP transport for the request URLs of the Slack app: the +// Events API, slash command, and interactivity URLs. It uses the same delivery +// handler as Listen. func (a *Adapter) Webhook(dispatch chat.DispatchFunc) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)) - if err != nil { - if _, ok := errors.AsType[*http.MaxBytesError](err); ok { - http.Error(w, "slack payload too large", http.StatusRequestEntityTooLarge) - return - } - http.Error(w, "read body", http.StatusBadRequest) - return - } - // Slack signs with the shared app-level signing secret in both modes, so - // verification stays before tenant parse; the per-workspace bot token is used - // only for replies. - if err := a.verifySignature(r, body); err != nil { - http.Error(w, "invalid slack signature", http.StatusUnauthorized) - return - } + return &slackapi.Webhook{ + SigningSecret: a.signingSecret, + Handler: a.deliveryHandler(dispatch), + Now: a.now, + SignatureTolerance: a.signatureTolerance, + } +} - // Commands and interactivity arrive as x-www-form-urlencoded; event callbacks - // arrive as JSON. Branch only after signature verification. - if isFormContentType(r.Header.Get("Content-Type")) { - a.handleFormWebhook(w, r, dispatch, body) - return +// deliveryHandler returns the one inbound pipeline of the adapter, for both +// transports. It normalizes a delivery to an Event and dispatches it. An Ignored +// Event is acknowledged without dispatch. +func (a *Adapter) deliveryHandler(dispatch chat.DispatchFunc) slackapi.Handler { + return func(ctx context.Context, d *slackapi.Delivery) (any, error) { + event, err := a.normalizeDelivery(ctx, d) + if err != nil || event == nil { + return nil, err } - - var envelope eventEnvelope - if err := json.Unmarshal(body, &envelope); err != nil { - http.Error(w, "invalid slack payload", http.StatusBadRequest) - return + err = dispatch(ctx, event) + if !errors.Is(err, chat.ErrAdmissionRejected) { + return nil, err } - switch envelope.Type { - case "url_verification": - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(envelope.Challenge)) - case "event_callback": - install, found, err := a.resolveInstall(r.Context(), envelope.TeamID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if !found { - // ErrInstallNotFound (or no tenant): Ignored Event. Acknowledge without - // dispatch. - w.WriteHeader(http.StatusOK) - return - } - event, ok, err := a.normalizeEvent(r, envelope, body, install.botUserID) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if ok { - if err := dispatch(r.Context(), event); err != nil { - if errors.Is(err, chat.ErrAdmissionRejected) { - // Events API callbacks are platform-redelivered: a - // retry-inducing status lets Slack's own retry cover - // the event, and because the delivery was never - // dedupe-marked that retry is not deduped away. - http.Error(w, "slack runtime at capacity", http.StatusServiceUnavailable) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } - w.WriteHeader(http.StatusOK) - default: - w.WriteHeader(http.StatusOK) + if event.Command != nil { + // Slack does not redeliver slash commands, so a missing ack would + // lose the invocation with only a generic Slack error shown. Slack + // shows the ack body to the invoking user as an ephemeral message, so + // the ack carries a truthful busy signal instead: never a silent + // failure, never a fake success. + return commandBusyReply{Text: commandBusyMessage}, nil } - }) + // Slack redelivers an Events API payload that gets no ack, and the + // delivery was never dedupe-marked, so the redelivery is not deduped + // away. Slack does not redeliver block_actions, but a missing ack is the + // truthful signal: Slack shows a warning on the activated component, and + // the rejection stays observable through Runtime Observation. + return nil, fmt.Errorf("%w: %w", slackapi.ErrBusy, err) + } +} + +// normalizeDelivery returns the Event of a delivery, or a nil Event for an +// Ignored Event: a delivery type, envelope type, or interaction type that the +// adapter does not support, or a workspace with no install. A malformed or +// cross-tenant payload is an error that wraps slackapi.ErrInvalidPayload. An +// install store failure is another error. +func (a *Adapter) normalizeDelivery(ctx context.Context, d *slackapi.Delivery) (*chat.Event, error) { + switch d.Type { + case slackapi.DeliveryEventsAPI: + return a.normalizeEvent(ctx, d) + case slackapi.DeliverySlashCommands: + return a.normalizeCommand(ctx, d) + case slackapi.DeliveryInteractive: + return a.normalizeInteraction(ctx, d) + default: + return nil, nil + } } -func isFormContentType(contentType string) bool { - mediaType, _, err := mime.ParseMediaType(contentType) - if err != nil { - return false - } - return mediaType == "application/x-www-form-urlencoded" +// invalidPayload marks err as a malformed or rejected payload, so the +// transport answers it with slackapi.ErrInvalidPayload. +func invalidPayload(err error) error { + return invalidPayloadError{err} } -// handleFormWebhook decodes a slash command or interactivity payload from the -// verified form body and dispatches it through the same DispatchFunc seam as -// messages. The empty 200 is the Ack-Then-Work ack within Slack's 3-second budget. -func (a *Adapter) handleFormWebhook(w http.ResponseWriter, r *http.Request, dispatch chat.DispatchFunc, body []byte) { - form, err := url.ParseQuery(string(body)) - if err != nil { - http.Error(w, "invalid slack form payload", http.StatusBadRequest) - return - } +type invalidPayloadError struct{ error } - switch { - case form.Get("payload") != "": - a.handleInteractionForm(w, r, dispatch, form.Get("payload")) - case form.Get("command") != "": - a.handleCommandForm(w, r, dispatch, form) - default: - http.Error(w, "unsupported slack form payload", http.StatusBadRequest) +func (e invalidPayloadError) Unwrap() []error { + return []error{e.error, slackapi.ErrInvalidPayload} +} + +// retryMetadata returns the Retry Metadata of a delivery. +func retryMetadata(d *slackapi.Delivery) chat.RetryMetadata { + retry := chat.RetryMetadata{Reason: d.RetryReason} + if d.RetryAttempt > 0 { + retry.Num = strconv.Itoa(d.RetryAttempt) } + return retry } func (a *Adapter) ValidateThreadID(id chat.ThreadID) (chat.ThreadRef, error) { @@ -457,46 +427,70 @@ func slackCredential(credential any) (SlackInstall, error) { } } -func (a *Adapter) normalizeEvent(r *http.Request, envelope eventEnvelope, raw []byte, botUserID string) (*chat.Event, bool, error) { +// normalizeEvent returns the Message Event of an Events API delivery, or a nil +// Event for an Ignored Event. +func (a *Adapter) normalizeEvent(ctx context.Context, d *slackapi.Delivery) (*chat.Event, error) { + var envelope slackapi.Envelope + if err := json.Unmarshal(d.Payload, &envelope); err != nil { + return nil, invalidPayload(fmt.Errorf("slack: invalid event payload: %w", err)) + } + if envelope.Type != slackapi.EnvelopeEventCallback { + return nil, nil + } + install, found, err := a.resolveInstall(ctx, envelope.TeamID) + if err != nil || !found { + return nil, err + } + event, err := a.messageEvent(&envelope, d, install.botUserID) + if err != nil { + return nil, invalidPayload(err) + } + return event, nil +} + +// messageEvent validates an event_callback envelope and returns its Message +// Event, or a nil Event for an event type that the adapter does not support or +// for the bot's own message in multi-tenant mode. +func (a *Adapter) messageEvent(envelope *slackapi.Envelope, d *slackapi.Delivery, botUserID string) (*chat.Event, error) { if envelope.TeamID == "" { - return nil, false, errors.New("slack: team_id is required") + return nil, errors.New("slack: team_id is required") } // The cross-tenant guard only applies to a single-install adapter pinned to one // team; in multi-tenant mode every workspace the store serves is valid. if !a.multiTenant() && a.teamID != "" && envelope.TeamID != a.teamID { - return nil, false, fmt.Errorf("slack: team_id %q does not match configured team", envelope.TeamID) + return nil, fmt.Errorf("slack: team_id %q does not match configured team", envelope.TeamID) } if envelope.EventID == "" { - return nil, false, errors.New("slack: event_id is required") + return nil, errors.New("slack: event_id is required") } var ev slackEvent if err := json.Unmarshal(envelope.Event, &ev); err != nil { - return nil, false, fmt.Errorf("slack: invalid event: %w", err) + return nil, fmt.Errorf("slack: invalid event: %w", err) } if !supportedMessageEvent(ev) { - return nil, false, nil + return nil, nil } if ev.Channel == "" { - return nil, false, errors.New("slack: event channel is required") + return nil, errors.New("slack: event channel is required") } if ev.TS == "" { - return nil, false, errors.New("slack: event ts is required") + return nil, errors.New("slack: event ts is required") } threadID, direct, err := a.threadIDForEvent(envelope.TeamID, ev) if err != nil { - return nil, false, err + return nil, err } author := a.actorForEvent(envelope.TeamID, ev, botUserID) if author.ID == "" { - return nil, false, errors.New("slack: event author is required") + return nil, errors.New("slack: event author is required") } // Multi-tenant self-filtering is tenant-correct from the per-install bot id: the // runtime's BotActor() filter cannot match because there is no single bot // identity, so drop the bot's own messages here as an Ignored Event. if a.multiTenant() && author.BotKind == chat.BotBot && botUserID != "" && author.ID == botUserID { - return nil, false, nil + return nil, nil } return &chat.Event{ @@ -505,11 +499,8 @@ func (a *Adapter) normalizeEvent(r *http.Request, envelope eventEnvelope, raw [] Tenant: envelope.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: json.RawMessage(raw), + Retry: retryMetadata(d), + Raw: d.Payload, Message: &chat.Message{ ID: ev.TS, Text: ev.Text, @@ -517,7 +508,7 @@ func (a *Adapter) normalizeEvent(r *http.Request, envelope eventEnvelope, raw [] Mentioned: direct || ev.Type == "app_mention" || strings.Contains(ev.Text, "<@"+botUserID+">"), Raw: ev.Raw, }, - }, true, nil + }, nil } func (a *Adapter) actorForEvent(teamID string, ev slackEvent, botUserID string) chat.Actor { @@ -563,10 +554,6 @@ func supportedMessageEvent(ev slackEvent) bool { return ev.Subtype == "" || ev.Subtype == "bot_message" } -func (a *Adapter) verifySignature(r *http.Request, body []byte) error { - return slackapi.VerifyRequest(r.Header, body, a.signingSecret, a.now(), a.signatureTolerance) -} - func (a *Adapter) postEphemeralFallback(ctx context.Context, token string, tenant string, actor chat.Actor, msg chat.PostableMessage) (*chat.SentMessage, error) { messageFields, err := slackMessageFields(msg) if err != nil { @@ -643,14 +630,6 @@ func apiRejection(err error) (*slackapi.APIError, bool) { return apiErr, true } -type eventEnvelope struct { - Type string `json:"type"` - Challenge string `json:"challenge"` - TeamID string `json:"team_id"` - EventID string `json:"event_id"` - Event json.RawMessage `json:"event"` -} - type slackEvent struct { Type string `json:"type"` Subtype string `json:"subtype"` From 91facb299da082c38a41410f7f3cc5af0d61a249 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 03:13:54 +0000 Subject: [PATCH 03/33] feat(adapters/slack/slackapi/slackapitest): add a fake Socket Mode Add github.com/coder/websocket v1.8.15, the first dependency of the module. The fake Socket Mode uses it on the existing httptest server. (*Server).SocketMode makes apps.connections.open return a ws:// URL, sends hello and pings, sends envelopes to a random live connection, resends unacked envelopes up to 3 times, and rejects connections over MaxConnections with a too_many_websockets disconnect. Snapshots record which connection got each envelope and sent each ack, the order of hellos and closes, and the close frame of each close. Server.Wait and the SocketMode Wait helpers let tests wait without sleeps. --- adapters/slack/slackapi/doc.go | 4 +- adapters/slack/slackapi/slackapitest/doc.go | 18 +- adapters/slack/slackapi/slackapitest/files.go | 1 + .../slack/slackapi/slackapitest/server.go | 57 ++ .../slack/slackapi/slackapitest/socketmode.go | 724 ++++++++++++++++++ .../slackapi/slackapitest/socketmode_test.go | 713 +++++++++++++++++ examples/slack-nats-state/go.sum | 2 + examples/slack-postgres-state/go.sum | 2 + examples/slack-redis-state/go.sum | 2 + go.mod | 2 + go.sum | 2 + 11 files changed, 1523 insertions(+), 4 deletions(-) create mode 100644 adapters/slack/slackapi/slackapitest/socketmode.go create mode 100644 adapters/slack/slackapi/slackapitest/socketmode_test.go create mode 100644 go.sum diff --git a/adapters/slack/slackapi/doc.go b/adapters/slack/slackapi/doc.go index 2b8578b..556f631 100644 --- a/adapters/slack/slackapi/doc.go +++ b/adapters/slack/slackapi/doc.go @@ -71,6 +71,6 @@ // // # Tests // -// Package slackapitest provides a fake Slack Web API server and a request -// signer for tests of code that uses this package. +// Package slackapitest provides a fake Slack Web API server with a fake Socket +// Mode, and a request signer, for tests of code that uses this package. package slackapi diff --git a/adapters/slack/slackapi/slackapitest/doc.go b/adapters/slack/slackapi/slackapitest/doc.go index 0a64d61..d0baaad 100644 --- a/adapters/slack/slackapi/slackapitest/doc.go +++ b/adapters/slack/slackapi/slackapitest/doc.go @@ -1,5 +1,5 @@ -// Package slackapitest provides a fake Slack Web API server and a request -// signer for tests of code that uses package slackapi. +// Package slackapitest provides a fake Slack Web API server with a fake Socket +// Mode, and a request signer, for tests of code that uses package slackapi. // // NewServer starts a Server and closes it when the test ends. A test points // slackapi.Options.BaseURL at Server.URL and slackapi.Options.HTTPClient at @@ -18,6 +18,20 @@ // HandleFile, and inspects the requests with FileRequests. A request to a file // path with no handler fails the test and gets status 404. // +// Server.SocketMode adds a fake Socket Mode to the Server. It makes +// apps.connections.open return a ws:// URL on the Server, and sends hello and +// websocket pings on each connection to that URL. Send sends an envelope to a +// random live connection and resends it until a client acks it, as Slack does. +// A test can send disconnect messages, stop pings, and drop connections. It can +// also inspect each connection and envelope: which connection got each envelope +// and sent each ack, the order of hellos and closes, and the close frame of each +// close. +// +// Server.Wait waits until a condition is true. It checks the condition again +// after each Web API call, file request, and Socket Mode change, so a test does +// not need to sleep. SocketMode.WaitLive, SocketMode.WaitClosed, and +// SocketMode.WaitAcked wait for common conditions with a timeout. +// // SignRequest sets the Slack signature headers of a request so that // slackapi.VerifyRequest accepts it. // diff --git a/adapters/slack/slackapi/slackapitest/files.go b/adapters/slack/slackapi/slackapitest/files.go index 8ab6861..6ac0e29 100644 --- a/adapters/slack/slackapi/slackapitest/files.go +++ b/adapters/slack/slackapi/slackapitest/files.go @@ -90,6 +90,7 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) { s.fileRequests[req.Path] = append(s.fileRequests[req.Path], req) fn := s.fileHandlers[req.Path] s.mu.Unlock() + s.changes.notify() if fn == nil { s.t.Errorf("slackapitest: no file at path %q", req.Path) diff --git a/adapters/slack/slackapi/slackapitest/server.go b/adapters/slack/slackapi/slackapitest/server.go index 63a3ea1..d4d6f33 100644 --- a/adapters/slack/slackapi/slackapitest/server.go +++ b/adapters/slack/slackapi/slackapitest/server.go @@ -1,6 +1,7 @@ package slackapitest import ( + "context" "encoding/json" "io" "mime" @@ -26,11 +27,14 @@ type Server struct { srv *httptest.Server files *httptest.Server + changes notifier + mu sync.Mutex handlers map[string]func(Call) any calls map[string][]Call fileHandlers map[string]func(FileRequest) any fileRequests map[string][]FileRequest + socketMode *SocketMode } // Call is one request that the Server received. @@ -107,7 +111,34 @@ func (s *Server) Calls(method string) []Call { return slices.Clone(s.calls[method]) } +// Wait waits until cond returns true. It calls cond at the start and again +// after each change in the Server: a Web API call, a file request, or a Socket +// Mode change. It returns nil when cond returns true, and the error of ctx when +// ctx is done first. cond must not block. +func (s *Server) Wait(ctx context.Context, cond func() bool) error { + for { + changed := s.changes.next() + if cond() { + return nil + } + select { + case <-changed: + case <-ctx.Done(): + return ctx.Err() + } + } +} + func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == socketModePath { + s.mu.Lock() + m := s.socketMode + s.mu.Unlock() + if m != nil { + m.serve(w, r) + return + } + } method, ok := strings.CutPrefix(r.URL.Path, apiPrefix) if !ok || method == "" { s.t.Errorf("slackapitest: unexpected request path %q", r.URL.Path) @@ -135,6 +166,7 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { s.calls[method] = append(s.calls[method], call) fn := s.handlers[method] s.mu.Unlock() + s.changes.notify() if fn == nil { s.t.Errorf("slackapitest: no response for method %q", method) @@ -196,3 +228,28 @@ func isForm(contentType string) bool { mediaType, _, err := mime.ParseMediaType(contentType) return err == nil && mediaType == "application/x-www-form-urlencoded" } + +// notifier wakes the goroutines in Server.Wait when the Server changes. +type notifier struct { + mu sync.Mutex + changed chan struct{} +} + +// next returns a channel that is closed at the next change. +func (n *notifier) next() <-chan struct{} { + n.mu.Lock() + defer n.mu.Unlock() + if n.changed == nil { + n.changed = make(chan struct{}) + } + return n.changed +} + +func (n *notifier) notify() { + n.mu.Lock() + defer n.mu.Unlock() + if n.changed != nil { + close(n.changed) + n.changed = nil + } +} diff --git a/adapters/slack/slackapi/slackapitest/socketmode.go b/adapters/slack/slackapi/slackapitest/socketmode.go new file mode 100644 index 0000000..62823d3 --- /dev/null +++ b/adapters/slack/slackapi/slackapitest/socketmode.go @@ -0,0 +1,724 @@ +package slackapitest + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "math/rand/v2" + "net/http" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" +) + +// socketModePath is the websocket path of the URLs that Open returns. Slack +// uses the same path. +const socketModePath = "/link/" + +const ( + defaultAppID = "A0123456789" + defaultPingInterval = 10 * time.Second + defaultResendAfter = 3 * time.Second + defaultMaxConnections = 10 + maxResends = 3 + socketReadLimit = 1 << 20 +) + +// SocketModeOptions configures a SocketMode. +type SocketModeOptions struct { + // AppToken is the app-level token that apps.connections.open accepts. A + // call with another token gets the error code invalid_auth. Empty accepts + // every token. A call with no token gets not_authed. + AppToken string + // AppID is the connection_info.app_id value of hello. Empty uses + // "A0123456789". + AppID string + // PingInterval is the time between two websocket pings on each + // connection. 0 uses 10 seconds, as Slack does. A negative value sends no + // pings. + PingInterval time.Duration + // ResendAfter is the time that SocketMode waits for an ack before it + // resends an envelope. 0 uses 3 seconds, the time that Slack gives an app + // to ack. A negative value turns resends off. + ResendAfter time.Duration + // MaxConnections is the largest number of live connections. A connection + // over the limit gets a disconnect message with the reason + // too_many_websockets instead of hello. 0 uses 10, the Slack limit. + MaxConnections int +} + +// SocketMode is a fake Slack Socket Mode on a Server. It makes +// apps.connections.open return a ws:// URL on the Server, accepts websocket +// connections to that URL, sends envelopes, and records acks and closes. It is +// safe for concurrent use. +// +// Each connection gets hello at once, then a websocket ping every +// PingInterval. Send sends an envelope to a random live connection, and +// resends it to a random live connection after ResendAfter until it gets an +// ack, at most 3 times, with retry_reason "timeout", as Slack does. A +// connection is live from its hello until it closes. SocketMode never closes a +// connection itself, also not after a disconnect message, except with Drop +// and at the end of the test. +// +// Snapshots of connections and envelopes carry sequence numbers (HelloSeq, +// CloseSeq, and Seq). They come from one counter, so a smaller number happened +// first. +type SocketMode struct { + srv *Server + appID string + pingInterval time.Duration + resendAfter time.Duration + maxConns int + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + + mu sync.Mutex + stopped bool + appToken string + tickets map[string]string + conns []*socketConn + envelopes map[string]*socketEnvelope + order []*socketEnvelope + seq int +} + +// SocketConn is a snapshot of one websocket connection to a SocketMode. +type SocketConn struct { + // ID numbers the connections from 1, in the order that SocketMode + // accepted them. + ID int + // Token is the app-level token of the apps.connections.open call that + // gave the URL. + Token string + // Host is the debug_info.host value of the messages on the connection. + // It is unique for each connection. + Host string + // Rejected reports that the connection got a too_many_websockets + // disconnect message instead of hello. + Rejected bool + // HelloSeq is the sequence number of hello. It is 0 when the connection + // got no hello. + HelloSeq int + // CloseSeq is the sequence number of the close. It is 0 while the + // connection is open. + CloseSeq int + // CloseFrame reports that the client sent a close frame. + CloseFrame bool + // CloseCode is the status code in the close frame of the client. It is 0 + // when CloseFrame is false. + CloseCode websocket.StatusCode + // Dropped reports that SocketMode closed the connection without a close + // frame: with Drop, or at the end of the test. + Dropped bool +} + +// Live reports whether the connection got hello and is not closed. Send +// sends envelopes only to live connections. +func (c SocketConn) Live() bool { + return c.HelloSeq != 0 && c.CloseSeq == 0 && !c.Dropped +} + +// SocketEnvelope is a snapshot of one envelope that Send sent. +type SocketEnvelope struct { + // ID is the envelope_id. + ID string + // Type is the envelope type, for example "events_api". + Type string + // Payload is the payload of the envelope. + Payload json.RawMessage + // Deliveries lists the sends of the envelope in order. The first has + // RetryAttempt 0. + Deliveries []SocketDelivery + // Acks lists the acks for the envelope in order. + Acks []SocketAck +} + +// SocketDelivery is one send of an envelope. +type SocketDelivery struct { + // Conn is the ID of the connection. It is 0 when no connection was live, + // so the envelope went nowhere. + Conn int + // RetryAttempt is the retry_attempt value: 0 for the first send. + RetryAttempt int + // RetryReason is the retry_reason value: empty for the first send, else + // "timeout". + RetryReason string + // Seq is the sequence number of the send. + Seq int +} + +// SocketAck is one ack that a client sent. +type SocketAck struct { + // Conn is the ID of the connection that the ack came on. + Conn int + // Payload is the payload field of the ack as the client sent it. It is + // nil when the ack has no payload. + Payload json.RawMessage + // Seq is the sequence number of the ack. + Seq int +} + +type socketConn struct { + id int + ws *websocket.Conn + done chan struct{} + wake chan struct{} + pingsStopped atomic.Bool + + // info and queue are guarded by SocketMode.mu. + info SocketConn + queue [][]byte +} + +type socketEnvelope struct { + // info and timer are guarded by SocketMode.mu. + info SocketEnvelope + acceptsResponsePayload bool + timer *time.Timer +} + +// SocketMode starts a fake Socket Mode on s with opts. It sets the response of +// apps.connections.open to Open, and stops with t.Cleanup: it closes the +// connections that are open without a close frame. Call it at most once for +// each Server. +func (s *Server) SocketMode(opts SocketModeOptions) *SocketMode { + s.t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + m := &SocketMode{ + srv: s, + appID: cmp.Or(opts.AppID, defaultAppID), + pingInterval: cmp.Or(opts.PingInterval, defaultPingInterval), + resendAfter: cmp.Or(opts.ResendAfter, defaultResendAfter), + maxConns: cmp.Or(opts.MaxConnections, defaultMaxConnections), + ctx: ctx, + cancel: cancel, + appToken: opts.AppToken, + tickets: map[string]string{}, + envelopes: map[string]*socketEnvelope{}, + } + s.mu.Lock() + twice := s.socketMode != nil + if !twice { + s.socketMode = m + } + s.mu.Unlock() + if twice { + cancel() + s.t.Fatalf("slackapitest: SocketMode called twice on one Server") + } + s.Handle("apps.connections.open", m.Open) + s.t.Cleanup(m.stop) + return m +} + +// Open answers an apps.connections.open call. SocketMode installs it with +// Handle. A test that sets another Handle function for apps.connections.open, +// for example to return a rate limit first, can call Open to give the normal +// answer: a ws:// URL on the Server, or the error code not_authed or +// invalid_auth. +func (m *SocketMode) Open(call Call) any { + token, _ := strings.CutPrefix(call.Header.Get("Authorization"), "Bearer ") + if token == "" { + return map[string]any{"ok": false, "error": "not_authed"} + } + m.mu.Lock() + defer m.mu.Unlock() + if m.appToken != "" && token != m.appToken { + return map[string]any{"ok": false, "error": "invalid_auth"} + } + ticket := fmt.Sprintf("ticket-%d", len(m.tickets)+1) + m.tickets[ticket] = token + wsURL := "ws" + strings.TrimPrefix(m.srv.srv.URL, "http") + socketModePath + "?ticket=" + ticket + "&app_id=" + m.appID + return map[string]any{"ok": true, "url": wsURL} +} + +// SetAppToken changes the app-level token that apps.connections.open accepts. +// It does not change the connections that are open. +func (m *SocketMode) SetAppToken(token string) { + m.mu.Lock() + defer m.mu.Unlock() + m.appToken = token +} + +// Send sends an envelope of type typ, for example "events_api", with payload +// to a random live connection, and returns its envelope ID. payload follows +// the rules of Respond: a json.RawMessage or []byte is sent as is, and any +// other value is sent as JSON. The envelope has accepts_response_payload true +// only for "slash_commands", as Slack sends it. When no connection is live, +// the envelope goes nowhere, and a resend can reach a later connection. +func (m *SocketMode) Send(typ string, payload any) string { + raw, err := encodeJSON(payload) + if err != nil { + m.srv.t.Errorf("slackapitest: encode %s envelope payload: %v", typ, err) + return "" + } + m.mu.Lock() + env := &socketEnvelope{ + info: SocketEnvelope{ + ID: fmt.Sprintf("envelope-%d", len(m.order)+1), + Type: typ, + Payload: raw, + }, + acceptsResponsePayload: typ == "slash_commands", + } + m.envelopes[env.info.ID] = env + m.order = append(m.order, env) + m.deliverLocked(env) + m.mu.Unlock() + m.srv.changes.notify() + return env.info.ID +} + +// Disconnect sends a disconnect message with reason, for example "warning", +// "refresh_requested", or "link_disabled", to every live connection. +func (m *SocketMode) Disconnect(reason string) { + m.mu.Lock() + defer m.mu.Unlock() + for _, c := range m.conns { + if c.info.Live() { + m.disconnectLocked(c, reason) + } + } +} + +// DisconnectConn sends a disconnect message with reason to the connection id. +func (m *SocketMode) DisconnectConn(id int, reason string) { + m.mu.Lock() + defer m.mu.Unlock() + if c := m.connLocked(id); c != nil { + m.disconnectLocked(c, reason) + } +} + +// StopPings stops the websocket pings on the connection id, so that a client +// sees a silent connection. +func (m *SocketMode) StopPings(id int) { + m.mu.Lock() + c := m.connLocked(id) + m.mu.Unlock() + if c != nil { + c.pingsStopped.Store(true) + } +} + +// Drop closes the connection id without a close frame, as a broken network +// does. +func (m *SocketMode) Drop(id int) { + m.mu.Lock() + c := m.connLocked(id) + if c != nil && c.info.CloseSeq == 0 { + c.info.Dropped = true + } + m.mu.Unlock() + if c != nil { + _ = c.ws.CloseNow() + m.srv.changes.notify() + } +} + +// Live returns the number of live connections. +func (m *SocketMode) Live() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.liveLocked()) +} + +// Conns returns a snapshot of every connection, in ID order. +func (m *SocketMode) Conns() []SocketConn { + m.mu.Lock() + defer m.mu.Unlock() + conns := make([]SocketConn, len(m.conns)) + for i, c := range m.conns { + conns[i] = c.info + } + return conns +} + +// Closes counts the connections that the client closed: withFrame with a +// close frame and withoutFrame without one. It does not count connections +// that SocketMode dropped. +func (m *SocketMode) Closes() (withFrame, withoutFrame int) { + m.mu.Lock() + defer m.mu.Unlock() + for _, c := range m.conns { + switch { + case c.info.CloseSeq == 0 || c.info.Dropped: + case c.info.CloseFrame: + withFrame++ + default: + withoutFrame++ + } + } + return withFrame, withoutFrame +} + +// Envelope returns a snapshot of the envelope id. An unknown id fails the test +// and returns the zero SocketEnvelope. +func (m *SocketMode) Envelope(id string) SocketEnvelope { + m.mu.Lock() + env, ok := m.envelopes[id] + var snap SocketEnvelope + if ok { + snap = env.snapshot() + } + m.mu.Unlock() + if !ok { + m.srv.t.Errorf("slackapitest: unknown envelope %q", id) + } + return snap +} + +// Envelopes returns a snapshot of every envelope, in the order of Send. +func (m *SocketMode) Envelopes() []SocketEnvelope { + m.mu.Lock() + defer m.mu.Unlock() + envs := make([]SocketEnvelope, len(m.order)) + for i, env := range m.order { + envs[i] = env.snapshot() + } + return envs +} + +// WaitLive waits until at least n connections are live, and returns the live +// connections in ID order. It fails the test with tb.Fatalf when timeout +// passes first, so call it from the test goroutine. +func (m *SocketMode) WaitLive(tb testing.TB, timeout time.Duration, n int) []SocketConn { + tb.Helper() + var live []SocketConn + m.wait(tb, timeout, fmt.Sprintf("%d live connections", n), func() bool { + live = slices.DeleteFunc(m.Conns(), func(c SocketConn) bool { return !c.Live() }) + return len(live) >= n + }) + return live +} + +// WaitClosed waits until the connection id is closed, and returns it. The +// connection does not need to exist yet. It fails the test with tb.Fatalf when +// timeout passes first, so call it from the test goroutine. +func (m *SocketMode) WaitClosed(tb testing.TB, timeout time.Duration, id int) SocketConn { + tb.Helper() + var conn SocketConn + m.wait(tb, timeout, fmt.Sprintf("close of connection %d", id), func() bool { + m.mu.Lock() + defer m.mu.Unlock() + if id >= 1 && id <= len(m.conns) { + conn = m.conns[id-1].info + } + return conn.CloseSeq != 0 + }) + return conn +} + +// WaitAcked waits until the envelope id has an ack, and returns it. It fails +// the test with tb.Fatalf when timeout passes first, so call it from the test +// goroutine. +func (m *SocketMode) WaitAcked(tb testing.TB, timeout time.Duration, id string) SocketEnvelope { + tb.Helper() + var env SocketEnvelope + m.wait(tb, timeout, fmt.Sprintf("ack of %s", id), func() bool { + env = m.Envelope(id) + return len(env.Acks) > 0 + }) + return env +} + +func (m *SocketMode) wait(tb testing.TB, timeout time.Duration, what string, cond func() bool) { + tb.Helper() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := m.srv.Wait(ctx, cond); err != nil { + tb.Fatalf("slackapitest: wait for %s: %v", what, err) + } +} + +// serve accepts a websocket connection to a URL from Open, and runs it until +// it closes. +func (m *SocketMode) serve(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + token, ok := m.tickets[r.URL.Query().Get("ticket")] + if !ok || m.stopped { + m.mu.Unlock() + http.NotFound(w, r) + return + } + m.wg.Add(1) + m.mu.Unlock() + defer m.wg.Done() + + ws, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + ws.SetReadLimit(socketReadLimit) + + m.mu.Lock() + if m.stopped { + m.mu.Unlock() + _ = ws.CloseNow() + return + } + id := len(m.conns) + 1 + c := &socketConn{ + id: id, + ws: ws, + done: make(chan struct{}), + wake: make(chan struct{}, 1), + info: SocketConn{ID: id, Token: token, Host: fmt.Sprintf("applink-%d", id)}, + } + m.conns = append(m.conns, c) + live := len(m.liveLocked()) + if live >= m.maxConns { + c.info.Rejected = true + c.enqueueLocked(m.marshal(map[string]any{ + "type": "disconnect", + "reason": "too_many_websockets", + "num_connections": live, + "debug_info": map[string]any{"host": c.info.Host}, + "connection_info": map[string]any{"app_id": m.appID}, + })) + } else { + c.info.HelloSeq = m.nextSeqLocked() + c.enqueueLocked(m.marshal(map[string]any{ + "type": "hello", + "num_connections": live + 1, + "debug_info": map[string]any{"host": c.info.Host}, + "connection_info": map[string]any{"app_id": m.appID}, + })) + } + m.mu.Unlock() + m.srv.changes.notify() + + m.wg.Go(func() { m.write(c) }) + m.wg.Go(func() { m.ping(c) }) + m.read(c) +} + +func (m *SocketMode) read(c *socketConn) { + defer close(c.done) + for { + typ, data, err := c.ws.Read(m.ctx) + if err != nil { + m.closed(c, err) + return + } + if typ != websocket.MessageText { + m.srv.t.Errorf("slackapitest: socket mode connection %d sent a binary message", c.id) + continue + } + m.ack(c, data) + } +} + +func (m *SocketMode) closed(c *socketConn, err error) { + code := websocket.CloseStatus(err) + _ = c.ws.CloseNow() + m.mu.Lock() + c.info.CloseSeq = m.nextSeqLocked() + if code != -1 && !c.info.Dropped { + c.info.CloseFrame = true + c.info.CloseCode = code + } + m.mu.Unlock() + m.srv.changes.notify() +} + +func (m *SocketMode) ack(c *socketConn, data []byte) { + var msg struct { + EnvelopeID string `json:"envelope_id"` + Payload json.RawMessage `json:"payload"` + } + if err := json.Unmarshal(data, &msg); err != nil || msg.EnvelopeID == "" { + m.srv.t.Errorf("slackapitest: socket mode connection %d sent %s, want an ack with an envelope_id", c.id, data) + return + } + m.mu.Lock() + env := m.envelopes[msg.EnvelopeID] + if env != nil { + env.info.Acks = append(env.info.Acks, SocketAck{Conn: c.id, Payload: msg.Payload, Seq: m.nextSeqLocked()}) + if env.timer != nil { + env.timer.Stop() + } + } + m.mu.Unlock() + if env == nil { + m.srv.t.Errorf("slackapitest: socket mode connection %d sent an ack for unknown envelope %q", c.id, msg.EnvelopeID) + return + } + m.srv.changes.notify() +} + +// write sends the queued messages of c in order, so that hello is the first +// message on a connection. +func (m *SocketMode) write(c *socketConn) { + for { + select { + case <-c.done: + return + case <-c.wake: + } + m.mu.Lock() + queue := c.queue + c.queue = nil + m.mu.Unlock() + for _, msg := range queue { + if err := c.ws.Write(m.ctx, websocket.MessageText, msg); err != nil { + return + } + } + } +} + +func (m *SocketMode) ping(c *socketConn) { + if m.pingInterval < 0 { + return + } + ticker := time.NewTicker(m.pingInterval) + defer ticker.Stop() + for { + select { + case <-c.done: + return + case <-ticker.C: + } + if c.pingsStopped.Load() { + return + } + // Ping has no timeout: websocket closes the connection when the + // context of a Ping ends during its write. + _ = c.ws.Ping(m.ctx) + } +} + +// deliverLocked sends env to a random live connection and arms its resend. +func (m *SocketMode) deliverLocked(env *socketEnvelope) { + d := SocketDelivery{RetryAttempt: len(env.info.Deliveries)} + if d.RetryAttempt > 0 { + d.RetryReason = "timeout" + } + if live := m.liveLocked(); len(live) > 0 { + c := live[rand.IntN(len(live))] + d.Conn = c.id + c.enqueueLocked(m.marshal(map[string]any{ + "type": env.info.Type, + "envelope_id": env.info.ID, + "payload": env.info.Payload, + "accepts_response_payload": env.acceptsResponsePayload, + "retry_attempt": d.RetryAttempt, + "retry_reason": d.RetryReason, + })) + } + d.Seq = m.nextSeqLocked() + env.info.Deliveries = append(env.info.Deliveries, d) + if d.RetryAttempt < maxResends && m.resendAfter > 0 && !m.stopped { + env.timer = time.AfterFunc(m.resendAfter, func() { m.resend(env) }) + } +} + +func (m *SocketMode) resend(env *socketEnvelope) { + m.mu.Lock() + if m.stopped || len(env.info.Acks) > 0 { + m.mu.Unlock() + return + } + m.deliverLocked(env) + m.mu.Unlock() + m.srv.changes.notify() +} + +func (m *SocketMode) disconnectLocked(c *socketConn, reason string) { + c.enqueueLocked(m.marshal(map[string]any{ + "type": "disconnect", + "reason": reason, + "debug_info": map[string]any{"host": c.info.Host}, + })) +} + +func (c *socketConn) enqueueLocked(msg []byte) { + c.queue = append(c.queue, msg) + select { + case c.wake <- struct{}{}: + default: + } +} + +func (m *SocketMode) liveLocked() []*socketConn { + var live []*socketConn + for _, c := range m.conns { + if c.info.Live() { + live = append(live, c) + } + } + return live +} + +func (m *SocketMode) connLocked(id int) *socketConn { + if id < 1 || id > len(m.conns) { + m.srv.t.Errorf("slackapitest: unknown socket mode connection %d", id) + return nil + } + return m.conns[id-1] +} + +func (m *SocketMode) nextSeqLocked() int { + m.seq++ + return m.seq +} + +func (m *SocketMode) marshal(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + m.srv.t.Errorf("slackapitest: encode socket mode message: %v", err) + } + return b +} + +// stop closes every open connection without a close frame and waits for the +// goroutines of SocketMode. +func (m *SocketMode) stop() { + m.mu.Lock() + m.stopped = true + for _, env := range m.order { + if env.timer != nil { + env.timer.Stop() + } + } + for _, c := range m.conns { + if c.info.CloseSeq == 0 { + c.info.Dropped = true + } + } + m.mu.Unlock() + m.cancel() + m.wg.Wait() +} + +func (env *socketEnvelope) snapshot() SocketEnvelope { + info := env.info + info.Deliveries = slices.Clone(info.Deliveries) + info.Acks = slices.Clone(info.Acks) + return info +} + +// encodeJSON encodes payload with the rules of Respond. +func encodeJSON(payload any) (json.RawMessage, error) { + var raw []byte + switch p := payload.(type) { + case json.RawMessage: + raw = p + case []byte: + raw = p + default: + return json.Marshal(p) + } + if !json.Valid(raw) { + return nil, fmt.Errorf("payload %q is not valid JSON", raw) + } + return raw, nil +} diff --git a/adapters/slack/slackapi/slackapitest/socketmode_test.go b/adapters/slack/slackapi/slackapitest/socketmode_test.go new file mode 100644 index 0000000..0dc9a97 --- /dev/null +++ b/adapters/slack/slackapi/slackapitest/socketmode_test.go @@ -0,0 +1,713 @@ +package slackapitest_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/coder/chat/adapters/slack/slackapi/slackapitest" +) + +const ( + socketToken = "xapp-test" + waitTimeout = 5 * time.Second +) + +// socketMessage is any message that the fake sends on a connection. +type socketMessage struct { + Type string `json:"type"` + Reason string `json:"reason"` + NumConnections int `json:"num_connections"` + ConnectionInfo struct { + AppID string `json:"app_id"` + } `json:"connection_info"` + DebugInfo struct { + Host string `json:"host"` + } `json:"debug_info"` + EnvelopeID string `json:"envelope_id"` + Payload json.RawMessage `json:"payload"` + AcceptsResponsePayload bool `json:"accepts_response_payload"` + RetryAttempt int `json:"retry_attempt"` + RetryReason string `json:"retry_reason"` +} + +// socketClient is a minimal Socket Mode client: it reads every message into a +// channel, answers pings, and counts them. +type socketClient struct { + ws *websocket.Conn + msgs chan socketMessage + pings atomic.Int64 + pinged chan struct{} + readErr error + done chan struct{} +} + +// openConnection calls apps.connections.open with token and returns the +// response. +func openConnection(t *testing.T, srv *slackapitest.Server, token string) (ok bool, wsURL, errCode string) { + t.Helper() + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL()+"/apps.connections.open", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("apps.connections.open: %v", err) + } + defer resp.Body.Close() + var body struct { + OK bool `json:"ok"` + URL string `json:"url"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode apps.connections.open: %v", err) + } + return body.OK, body.URL, body.Error +} + +// dialSocket opens a connection with socketToken and closes it without a close +// frame when the test ends. +func dialSocket(t *testing.T, srv *slackapitest.Server) *socketClient { + t.Helper() + ok, wsURL, code := openConnection(t, srv, socketToken) + if !ok { + t.Fatalf("apps.connections.open error = %q", code) + } + return dialURL(t, srv, wsURL) +} + +func dialURL(t *testing.T, srv *slackapitest.Server, wsURL string) *socketClient { + t.Helper() + c := &socketClient{ + msgs: make(chan socketMessage, 256), + pinged: make(chan struct{}, 256), + done: make(chan struct{}), + } + ws, _, err := websocket.Dial(t.Context(), wsURL, &websocket.DialOptions{ + HTTPClient: srv.Client(), + OnPingReceived: func(context.Context, []byte) bool { + c.pings.Add(1) + select { + case c.pinged <- struct{}{}: + default: + } + return true + }, + }) + if err != nil { + t.Fatalf("dial %s: %v", wsURL, err) + } + c.ws = ws + go c.read(t) + t.Cleanup(func() { + _ = ws.CloseNow() + <-c.done + }) + return c +} + +func (c *socketClient) read(t *testing.T) { + defer close(c.done) + defer close(c.msgs) + for { + _, data, err := c.ws.Read(context.Background()) + if err != nil { + c.readErr = err + return + } + var msg socketMessage + if err := json.Unmarshal(data, &msg); err != nil { + t.Errorf("decode message %s: %v", data, err) + return + } + c.msgs <- msg + } +} + +// next returns the next message and fails the test if none comes. +func (c *socketClient) next(t *testing.T) socketMessage { + t.Helper() + select { + case msg, ok := <-c.msgs: + if !ok { + t.Fatalf("connection ended: %v", c.readErr) + } + return msg + case <-time.After(waitTimeout): + t.Fatal("no message") + } + return socketMessage{} +} + +// hello reads hello and fails the test if the first message is not hello. +func (c *socketClient) hello(t *testing.T) socketMessage { + t.Helper() + msg := c.next(t) + if msg.Type != "hello" { + t.Fatalf("first message = %+v, want hello", msg) + } + return msg +} + +func (c *socketClient) send(t *testing.T, v any) { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("encode message: %v", err) + } + if err := c.ws.Write(t.Context(), websocket.MessageText, data); err != nil { + t.Fatalf("write message: %v", err) + } +} + +func (c *socketClient) ack(t *testing.T, envelopeID string) { + t.Helper() + c.send(t, map[string]any{"envelope_id": envelopeID}) +} + +// waitPings waits until the client has received n pings in total. +func (c *socketClient) waitPings(t *testing.T, n int64) { + t.Helper() + timeout := time.After(waitTimeout) + for c.pings.Load() < n { + select { + case <-c.pinged: + case <-timeout: + t.Fatalf("pings = %d, want %d", c.pings.Load(), n) + } + } +} + +func TestSocketModeOpen(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: "xapp-1", AppID: "A1"}) + + if ok, _, code := openConnection(t, srv, ""); ok || code != "not_authed" { + t.Fatalf("no token: ok = %v, error = %q, want not_authed", ok, code) + } + if ok, _, code := openConnection(t, srv, "xapp-2"); ok || code != "invalid_auth" { + t.Fatalf("wrong token: ok = %v, error = %q, want invalid_auth", ok, code) + } + ok, wsURL, code := openConnection(t, srv, "xapp-1") + if !ok { + t.Fatalf("right token: error = %q", code) + } + u, err := url.Parse(wsURL) + if err != nil { + t.Fatalf("parse URL %q: %v", wsURL, err) + } + if u.Scheme != "ws" || u.Path != "/link/" || u.Query().Get("ticket") == "" || u.Query().Get("app_id") != "A1" { + t.Fatalf("URL = %q, want ws://.../link/?ticket=...&app_id=A1", wsURL) + } + if !strings.HasPrefix(srv.URL(), "http://"+u.Host+"/") { + t.Fatalf("URL host %q is not the Server host %q", u.Host, srv.URL()) + } + + sm.SetAppToken("xapp-new") + if ok, _, code := openConnection(t, srv, "xapp-1"); ok || code != "invalid_auth" { + t.Fatalf("old token after SetAppToken: ok = %v, error = %q, want invalid_auth", ok, code) + } + ok, newURL, _ := openConnection(t, srv, "xapp-new") + if !ok { + t.Fatal("new token after SetAppToken: not ok") + } + if calls := srv.Calls("apps.connections.open"); len(calls) != 5 { + t.Fatalf("apps.connections.open calls = %d, want 5", len(calls)) + } + + // A URL from before SetAppToken still works, and each connection keeps + // the token of its URL. + dialURL(t, srv, wsURL).hello(t) + dialURL(t, srv, newURL).hello(t) + conns := sm.WaitLive(t, waitTimeout, 2) + if conns[0].Token != "xapp-1" || conns[1].Token != "xapp-new" { + t.Fatalf("tokens = %q, %q, want xapp-1, xapp-new", conns[0].Token, conns[1].Token) + } +} + +func TestSocketModeOpenAnyToken(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.SocketMode(slackapitest.SocketModeOptions{}) + if ok, _, code := openConnection(t, srv, "xapp-anything"); !ok { + t.Fatalf("error = %q, want ok with an empty AppToken", code) + } +} + +func TestSocketModeOpenComposesWithHandle(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{}) + var calls atomic.Int64 + srv.Handle("apps.connections.open", func(call slackapitest.Call) any { + if calls.Add(1) == 1 { + return slackapitest.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": {"1"}}, + Body: map[string]any{"ok": false, "error": "ratelimited"}, + } + } + return sm.Open(call) + }) + + if ok, _, code := openConnection(t, srv, socketToken); ok || code != "ratelimited" { + t.Fatalf("first call: ok = %v, error = %q, want ratelimited", ok, code) + } + dialSocket(t, srv).hello(t) +} + +func TestSocketModeUnknownTicket(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.SocketMode(slackapitest.SocketModeOptions{}) + wsURL := "ws" + strings.TrimPrefix(strings.TrimSuffix(srv.URL(), "/api"), "http") + "/link/?ticket=nope" + _, resp, err := websocket.Dial(t.Context(), wsURL, &websocket.DialOptions{HTTPClient: srv.Client()}) + if err == nil { + t.Fatal("dial with an unknown ticket succeeded") + } + if resp == nil || resp.StatusCode != http.StatusNotFound { + t.Fatalf("response = %v, want 404", resp) + } +} + +func TestSocketModeHello(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppID: "A42", PingInterval: -1}) + + first := dialSocket(t, srv).hello(t) + if first.NumConnections != 1 || first.ConnectionInfo.AppID != "A42" || first.DebugInfo.Host != "applink-1" { + t.Fatalf("first hello = %+v", first) + } + second := dialSocket(t, srv).hello(t) + if second.NumConnections != 2 || second.DebugInfo.Host != "applink-2" { + t.Fatalf("second hello = %+v", second) + } + + conns := sm.WaitLive(t, waitTimeout, 2) + if len(conns) != 2 || conns[0].ID != 1 || conns[1].ID != 2 { + t.Fatalf("live connections = %+v", conns) + } + if conns[0].Token != socketToken || conns[0].Host != "applink-1" || !conns[0].Live() { + t.Fatalf("connection 1 = %+v", conns[0]) + } + if conns[0].HelloSeq == 0 || conns[0].HelloSeq >= conns[1].HelloSeq { + t.Fatalf("HelloSeq = %d, %d, want increasing", conns[0].HelloSeq, conns[1].HelloSeq) + } + if got := sm.Live(); got != 2 { + t.Fatalf("Live = %d, want 2", got) + } +} + +func TestSocketModeSendAndAck(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{PingInterval: -1, ResendAfter: -1}) + c := dialSocket(t, srv) + c.hello(t) + sm.WaitLive(t, waitTimeout, 1) + + id := sm.Send("events_api", map[string]any{"event_id": "Ev1"}) + msg := c.next(t) + if msg.Type != "events_api" || msg.EnvelopeID != id || string(msg.Payload) != `{"event_id":"Ev1"}` { + t.Fatalf("envelope = %+v", msg) + } + if msg.AcceptsResponsePayload || msg.RetryAttempt != 0 || msg.RetryReason != "" { + t.Fatalf("envelope = %+v, want no response payload and no retry", msg) + } + if got := sm.Envelope(id); len(got.Acks) != 0 || len(got.Deliveries) != 1 || got.Deliveries[0].Conn != 1 { + t.Fatalf("envelope before the ack = %+v", got) + } + c.send(t, map[string]any{"envelope_id": id, "payload": map[string]any{"text": "hi"}}) + env := sm.WaitAcked(t, waitTimeout, id) + if env.ID != id || env.Type != "events_api" || string(env.Payload) != `{"event_id":"Ev1"}` { + t.Fatalf("envelope = %+v", env) + } + if len(env.Acks) != 1 || env.Acks[0].Conn != 1 || string(env.Acks[0].Payload) != `{"text":"hi"}` { + t.Fatalf("acks = %+v", env.Acks) + } + if env.Acks[0].Seq <= env.Deliveries[0].Seq { + t.Fatalf("ack Seq %d is not after delivery Seq %d", env.Acks[0].Seq, env.Deliveries[0].Seq) + } + + cmd := sm.Send("slash_commands", json.RawMessage(`{"command":"/hi"}`)) + msg = c.next(t) + if msg.Type != "slash_commands" || !msg.AcceptsResponsePayload || string(msg.Payload) != `{"command":"/hi"}` { + t.Fatalf("slash command envelope = %+v", msg) + } + c.ack(t, cmd) + if acks := sm.WaitAcked(t, waitTimeout, cmd).Acks; acks[0].Payload != nil { + t.Fatalf("ack payload = %s, want nil", acks[0].Payload) + } + + other := sm.Send("hello_world", []byte(`"x"`)) + if msg := c.next(t); msg.Type != "hello_world" || msg.EnvelopeID != other { + t.Fatalf("unknown type envelope = %+v", msg) + } + if envs := sm.Envelopes(); len(envs) != 3 || envs[0].ID != id || envs[1].ID != cmd || envs[2].ID != other { + t.Fatalf("Envelopes = %+v", envs) + } +} + +func TestSocketModeResend(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{PingInterval: -1, ResendAfter: 10 * time.Millisecond}) + c := dialSocket(t, srv) + c.hello(t) + sm.WaitLive(t, waitTimeout, 1) + + id := sm.Send("events_api", map[string]any{}) + for attempt := range 4 { + msg := c.next(t) + wantReason := "timeout" + if attempt == 0 { + wantReason = "" + } + if msg.EnvelopeID != id || msg.RetryAttempt != attempt || msg.RetryReason != wantReason { + t.Fatalf("send %d = %+v, want retry_attempt %d and retry_reason %q", attempt, msg, attempt, wantReason) + } + } + // After 3 resends the fake gives up. + select { + case msg := <-c.msgs: + t.Fatalf("unexpected message after the last resend: %+v", msg) + case <-time.After(100 * time.Millisecond): + } + env := sm.Envelope(id) + if len(env.Deliveries) != 4 || len(env.Acks) != 0 { + t.Fatalf("envelope = %+v, want 4 sends and no ack", env) + } + + // An ack after a resend stops the resends. + id = sm.Send("events_api", map[string]any{}) + if msg := c.next(t); msg.RetryAttempt != 0 { + t.Fatalf("first send = %+v", msg) + } + msg := c.next(t) + if msg.RetryAttempt != 1 { + t.Fatalf("resend = %+v", msg) + } + c.ack(t, id) + sm.WaitAcked(t, waitTimeout, id) + select { + case msg := <-c.msgs: + if msg.RetryAttempt > 2 { + t.Fatalf("resend after the ack: %+v", msg) + } + case <-time.After(100 * time.Millisecond): + } +} + +func TestSocketModeSendWithNoLiveConnection(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + // The client must connect before the last resend, so ResendAfter is not + // too short. + sm := srv.SocketMode(slackapitest.SocketModeOptions{PingInterval: -1, ResendAfter: 250 * time.Millisecond}) + + id := sm.Send("events_api", map[string]any{}) + if env := sm.Envelope(id); len(env.Deliveries) != 1 || env.Deliveries[0].Conn != 0 { + t.Fatalf("envelope = %+v, want one send to no connection", env) + } + c := dialSocket(t, srv) + c.hello(t) + msg := c.next(t) + if msg.EnvelopeID != id || msg.RetryAttempt == 0 || msg.RetryReason != "timeout" { + t.Fatalf("resend = %+v", msg) + } + c.ack(t, id) + env := sm.WaitAcked(t, waitTimeout, id) + if last := env.Deliveries[len(env.Deliveries)-1]; last.Conn != 1 || env.Acks[0].Conn != 1 { + t.Fatalf("envelope = %+v, want the last send and the ack on connection 1", env) + } +} + +func TestSocketModeSendPicksRandomConnection(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{PingInterval: -1, ResendAfter: -1}) + dialSocket(t, srv).hello(t) + dialSocket(t, srv).hello(t) + sm.WaitLive(t, waitTimeout, 2) + + counts := map[int]int{} + for range 40 { + env := sm.Envelope(sm.Send("events_api", map[string]any{})) + counts[env.Deliveries[0].Conn]++ + } + if counts[1] == 0 || counts[2] == 0 || counts[1]+counts[2] != 40 { + t.Fatalf("sends per connection = %v, want both connections", counts) + } +} + +func TestSocketModeMaxConnections(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppID: "A1", PingInterval: -1, ResendAfter: -1, MaxConnections: 1}) + first := dialSocket(t, srv) + first.hello(t) + + second := dialSocket(t, srv) + msg := second.next(t) + if msg.Type != "disconnect" || msg.Reason != "too_many_websockets" || msg.NumConnections != 1 || + msg.ConnectionInfo.AppID != "A1" || msg.DebugInfo.Host != "applink-2" { + t.Fatalf("over the limit = %+v, want a too_many_websockets disconnect", msg) + } + conns := sm.Conns() + if len(conns) != 2 || !conns[1].Rejected || conns[1].HelloSeq != 0 || conns[1].Live() { + t.Fatalf("connections = %+v, want connection 2 rejected", conns) + } + if got := sm.Live(); got != 1 { + t.Fatalf("Live = %d, want 1", got) + } + for range 5 { + if env := sm.Envelope(sm.Send("events_api", map[string]any{})); env.Deliveries[0].Conn != 1 { + t.Fatalf("send went to connection %d, want 1", env.Deliveries[0].Conn) + } + } + + if err := second.ws.Close(websocket.StatusNormalClosure, ""); err != nil { + t.Fatalf("close connection 2: %v", err) + } + if err := first.ws.Close(websocket.StatusNormalClosure, ""); err != nil { + t.Fatalf("close connection 1: %v", err) + } + sm.WaitClosed(t, waitTimeout, 1) + if msg := dialSocket(t, srv).next(t); msg.Type != "hello" || msg.NumConnections != 1 { + t.Fatalf("after the close = %+v, want hello", msg) + } +} + +func TestSocketModeDisconnect(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{PingInterval: -1}) + first := dialSocket(t, srv) + first.hello(t) + second := dialSocket(t, srv) + second.hello(t) + sm.WaitLive(t, waitTimeout, 2) + + sm.DisconnectConn(2, "refresh_requested") + if msg := second.next(t); msg.Type != "disconnect" || msg.Reason != "refresh_requested" || msg.DebugInfo.Host != "applink-2" { + t.Fatalf("connection 2 = %+v, want a refresh_requested disconnect", msg) + } + sm.Disconnect("warning") + if msg := first.next(t); msg.Type != "disconnect" || msg.Reason != "warning" || msg.DebugInfo.Host != "applink-1" { + t.Fatalf("connection 1 = %+v, want only a warning disconnect", msg) + } + if msg := second.next(t); msg.Type != "disconnect" || msg.Reason != "warning" { + t.Fatalf("connection 2 = %+v, want a warning disconnect", msg) + } + // A disconnect message does not close the connection. + if got := sm.Live(); got != 2 { + t.Fatalf("Live = %d, want 2", got) + } +} + +func TestSocketModePings(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{PingInterval: 5 * time.Millisecond}) + first := dialSocket(t, srv) + first.hello(t) + second := dialSocket(t, srv) + second.hello(t) + first.waitPings(t, 2) + second.waitPings(t, 2) + + sm.StopPings(1) + // One ping can be on the way when StopPings returns. + stoppedAt := first.pings.Load() + 1 + second.waitPings(t, second.pings.Load()+10) + if got := first.pings.Load(); got > stoppedAt { + t.Fatalf("connection 1 pings = %d after StopPings, want at most %d", got, stoppedAt) + } + if got := sm.Live(); got != 2 { + t.Fatalf("Live = %d, want 2", got) + } +} + +func TestSocketModeNoPings(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.SocketMode(slackapitest.SocketModeOptions{PingInterval: -1}) + c := dialSocket(t, srv) + c.hello(t) + time.Sleep(50 * time.Millisecond) + if got := c.pings.Load(); got != 0 { + t.Fatalf("pings = %d, want 0", got) + } +} + +func TestSocketModeCloses(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{PingInterval: -1}) + first := dialSocket(t, srv) + first.hello(t) + second := dialSocket(t, srv) + second.hello(t) + third := dialSocket(t, srv) + third.hello(t) + sm.WaitLive(t, waitTimeout, 3) + + if err := first.ws.Close(websocket.StatusNormalClosure, ""); err != nil { + t.Fatalf("close connection 1: %v", err) + } + conn := sm.WaitClosed(t, waitTimeout, 1) + if !conn.CloseFrame || conn.CloseCode != websocket.StatusNormalClosure || conn.Dropped || conn.Live() { + t.Fatalf("connection 1 = %+v, want a normal close frame", conn) + } + if conns := sm.Conns(); conns[1].HelloSeq >= conn.CloseSeq { + t.Fatalf("hello of connection 2 (%d) is not before the close of connection 1 (%d)", conns[1].HelloSeq, conn.CloseSeq) + } + + if err := second.ws.CloseNow(); err != nil { + t.Fatalf("close connection 2 without a close frame: %v", err) + } + if conn := sm.WaitClosed(t, waitTimeout, 2); conn.CloseFrame || conn.CloseCode != 0 || conn.Dropped { + t.Fatalf("connection 2 = %+v, want no close frame", conn) + } + + sm.Drop(3) + if conn := sm.WaitClosed(t, waitTimeout, 3); !conn.Dropped || conn.CloseFrame { + t.Fatalf("connection 3 = %+v, want dropped", conn) + } + <-third.done + if code := websocket.CloseStatus(third.readErr); code != -1 { + t.Fatalf("client of connection 3 got close code %d, want none", code) + } + + if withFrame, withoutFrame := sm.Closes(); withFrame != 1 || withoutFrame != 1 { + t.Fatalf("Closes = %d, %d, want 1, 1", withFrame, withoutFrame) + } + if got := sm.Live(); got != 0 { + t.Fatalf("Live = %d, want 0", got) + } +} + +// cleanupTB runs the cleanups of a Server when the test calls cleanup, not at +// the end of the test. +type cleanupTB struct { + testing.TB + + cleanups []func() +} + +func (tb *cleanupTB) Cleanup(f func()) { + tb.cleanups = append(tb.cleanups, f) +} + +func (tb *cleanupTB) cleanup() { + for _, f := range slices.Backward(tb.cleanups) { + f() + } +} + +func TestSocketModeStopDropsOpenConnections(t *testing.T) { + t.Parallel() + + tb := &cleanupTB{TB: t} + srv := slackapitest.NewServer(tb) + sm := srv.SocketMode(slackapitest.SocketModeOptions{PingInterval: time.Millisecond, ResendAfter: time.Millisecond}) + client := dialSocket(t, srv) + sm.WaitLive(t, waitTimeout, 1) + sm.Send("events_api", map[string]any{}) + + tb.cleanup() + select { + case <-client.done: + case <-time.After(waitTimeout): + t.Fatal("connection still open after the cleanup of the Server") + } + if code := websocket.CloseStatus(client.readErr); code != -1 { + t.Fatalf("close code = %d, want none", code) + } + if conns := sm.Conns(); !conns[0].Dropped || conns[0].CloseFrame { + t.Fatalf("connection = %+v, want dropped", conns[0]) + } +} + +func TestSocketModeInvalidMessages(t *testing.T) { + t.Parallel() + + tb := &coreRecordingTB{TB: t} + srv := slackapitest.NewServer(tb) + sm := srv.SocketMode(slackapitest.SocketModeOptions{PingInterval: -1, ResendAfter: -1}) + c := dialSocket(t, srv) + c.hello(t) + sm.WaitLive(t, waitTimeout, 1) + id := sm.Send("events_api", map[string]any{}) + c.next(t) + + c.ack(t, "envelope-404") + c.send(t, map[string]any{"type": "pong"}) + if err := c.ws.Write(t.Context(), websocket.MessageBinary, []byte(`{}`)); err != nil { + t.Fatalf("write binary message: %v", err) + } + c.ack(t, id) + sm.WaitAcked(t, waitTimeout, id) + if got := tb.errorCount(); got != 3 { + t.Fatalf("Errorf calls = %d, want 3", got) + } + if got := sm.Envelope("envelope-404"); got.ID != "" || tb.errorCount() != 4 { + t.Fatalf("unknown envelope = %+v, Errorf calls = %d, want zero and 4", got, tb.errorCount()) + } +} + +func TestServerWait(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.Respond("auth.test", json.RawMessage(`{"ok":true}`)) + + done := make(chan error, 1) + go func() { + done <- srv.Wait(t.Context(), func() bool { return len(srv.Calls("auth.test")) == 1 }) + }() + corePost(t, srv, "auth.test", "application/json", `{}`) + select { + case err := <-done: + if err != nil { + t.Fatalf("Wait = %v", err) + } + case <-time.After(waitTimeout): + t.Fatal("Wait did not return after the call") + } + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond) + defer cancel() + if err := srv.Wait(ctx, func() bool { return false }); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Wait = %v, want context.DeadlineExceeded", err) + } +} diff --git a/examples/slack-nats-state/go.sum b/examples/slack-nats-state/go.sum index 3f95839..6d23614 100644 --- a/examples/slack-nats-state/go.sum +++ b/examples/slack-nats-state/go.sum @@ -1,5 +1,7 @@ github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= diff --git a/examples/slack-postgres-state/go.sum b/examples/slack-postgres-state/go.sum index 9546a8a..1ee5d6d 100644 --- a/examples/slack-postgres-state/go.sum +++ b/examples/slack-postgres-state/go.sum @@ -8,6 +8,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= diff --git a/examples/slack-redis-state/go.sum b/examples/slack-redis-state/go.sum index 8267ef7..55a9806 100644 --- a/examples/slack-redis-state/go.sum +++ b/examples/slack-redis-state/go.sum @@ -14,6 +14,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= diff --git a/go.mod b/go.mod index 05197fd..ff820b0 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/coder/chat go 1.26.3 + +require github.com/coder/websocket v1.8.15 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4e0a48d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= From 78a80a2945353a9fca7c47413cd0ab18b16e768f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 03:13:54 +0000 Subject: [PATCH 04/33] docs(adr): add ADR 0017 for Slack Socket Mode Record the two transports, the transport-neutral Delivery and Handler, the one adapter pipeline, the Listener capability, the Shutdown order, the Socket Mode connection rules, and the first dependency. Point ADR 0016 at ADR 0017 for the end of the zero-dependency rule. --- docs/adr/0016-slack-api-package.md | 4 ++ docs/adr/0017-slack-socket-mode.md | 106 +++++++++++++++++++++++++++++ docs/explanation.md | 1 + 3 files changed, 111 insertions(+) create mode 100644 docs/adr/0017-slack-socket-mode.md diff --git a/docs/adr/0016-slack-api-package.md b/docs/adr/0016-slack-api-package.md index 3d6f1a7..7025388 100644 --- a/docs/adr/0016-slack-api-package.md +++ b/docs/adr/0016-slack-api-package.md @@ -4,6 +4,10 @@ Accepted +One statement of this ADR is superseded by ADR 0017 (Slack Socket Mode): + +- The module no longer has zero dependencies. It depends on `github.com/coder/websocket` for the Socket Mode transport. The rejection of `slack-go/slack` stands. + ## Context The **Go Chat Runtime** reaches Slack only through the Slack **Platform Adapter**. Before this ADR, the adapter kept its Web API calls, its bounded rate-limit retry (ADR 0005), and its request signature check private. It exposes Slack behavior through the portable surface and through **Adapter Access** on a registered adapter. Both paths assume that the application runs the runtime. diff --git a/docs/adr/0017-slack-socket-mode.md b/docs/adr/0017-slack-socket-mode.md new file mode 100644 index 0000000..4f0b66d --- /dev/null +++ b/docs/adr/0017-slack-socket-mode.md @@ -0,0 +1,106 @@ +# ADR 0017: Slack Socket Mode + +## Status + +Accepted + +## Context + +Slack delivers events to an app in two ways. Over HTTP, Slack posts to the Events API request URL, the slash command URLs, and the interactivity URL. Over Socket Mode, the app opens a websocket to Slack, Slack sends each payload on it as an envelope, and the app acknowledges the envelope on the same connection. + +Before this ADR, the library supported only HTTP. HTTP needs a public URL, so an app behind a firewall, a VPN, or Tailscale needs a tunnel, and so does a developer on a laptop. + +coderd, the Coder server, uses the **Slack API Package** directly and runs its own runtime (ADR 0016). It will use Socket Mode only. Its spec lists outcomes that the Socket Mode client must give: no gap on planned changes, a shutdown that stops acknowledging before it closes, replacement of silent connections, fatal errors that stop retries, a capped backoff with jitter that honours `Retry-After`, a close frame on every close, and proxy support. + +The prior art is bolt-js (`SocketModeReceiver` and `HTTPReceiver`), node-slack-sdk (`SocketModeClient`), slack-go (`socketmode`), and python-slack-sdk (`socket_mode`). No SDK sends a close frame every time: slack-go and python close the raw socket. A connection that ends without a close frame can stay registered with Slack for hours, count towards Slack's limit of 10 connections for each app, and still get envelopes. + +Related decisions, not redefined here: deferred dispatch is ADR 0002; rate-limit retry is ADR 0005; the **Admission Bound** is ADR 0015; the **Slack API Package** is ADR 0016. + +## Decision + +1. **One transport-neutral delivery and handler in `slackapi`.** A `Delivery` is one inbound payload from Slack. Its type is `events_api`, `slash_commands`, or `interactive`, as in Socket Mode. Its payload has the Socket Mode shape, and it carries the retry attempt and reason. A `Handler` is `func(ctx context.Context, d *Delivery) (response any, err error)`. One outcome table serves both transports: + + | Handler result | HTTP | Socket Mode | + |---|---|---| + | nil error | 200, with the response as a JSON body if it is not nil | ack, with the response as `payload` if it is not nil | + | `ErrBusy` | 503 | no ack | + | `ErrInvalidPayload` | 400 | no ack | + | other error | 500 | no ack | + +2. **Two thin transports in `slackapi`.** Each transport adds only its own first step (receive and authenticate) and its own last step (answer Slack). + - `Webhook` is the HTTP transport, an `http.Handler`. It accepts only POST, limits the body to 1 MiB, and calls `VerifyRequest` before any parse. It answers `url_verification` itself (the **Platform Handshake**). It re-encodes a slash command form as a JSON object of strings, the shape that Socket Mode sends. It reads the retry headers into the delivery. + - `(*Client).Listen(ctx, handler, opts)` is the Socket Mode transport. The caller passes a Client with the app-level token, for example `api.WithToken(appToken).Listen(...)`. Listen uses the Client's HTTP client, base URL, retry policy, and logger. `(*Client).OpenConnection` calls `apps.connections.open`. + +3. **One adapter pipeline.** The Slack adapter has one `Handler` for both transports. It parses the payload by delivery type, resolves the install by team, normalizes the payload, and dispatches it. Parse and validation failures wrap `ErrInvalidPayload`. A workspace with no install gives an ack and an **Ignored Event**. When the runtime rejects a delivery at admission, a slash command acks with the busy reply as JSON, and events and interactions return `ErrBusy`. `Options.SigningSecret` enables `Webhook`, and `Options.AppToken` enables `Listen`. The adapter needs at least one of them, and accepts both. Multi-tenant installs work the same over both transports. + +4. **The `Listener` Optional Capability and `Chat.Listen`.** A **Platform Adapter** that opens its own connections to the platform implements `chat.Listener`: + + ```go + type Listener interface { + Listen(ctx context.Context, dispatch DispatchFunc) error + } + ``` + + Listen dispatches each inbound payload through `dispatch`, and blocks until ctx is done. Then it drains, closes its connections, and returns nil. A non-nil error means that Listen cannot continue. `(*Chat).Listen(ctx, adapterName)` runs the capability. It returns an error at once if the adapter is not registered, or if the adapter is not a `Listener` (the error wraps `ErrUnsupportedCapability`). It returns nil at once after **Runtime Shutdown** starts. Else it blocks until ctx is done or Runtime Shutdown stops the listeners. Concurrent calls are allowed. + +5. **Shutdown order.** Runtime Shutdown runs these steps in order, bounded by the Shutdown context: + 1. Mark the runtime as shut down, and close admission. Every new dispatch, in both **Dispatch Modes**, returns an error that matches `ErrAdmissionRejected`. The Slack adapter then gives no ack for events and interactions, and the busy reply for slash commands, so Slack sends new envelopes to other connections. + 2. Cancel and drain deferred work. + 3. Stop the listeners: cancel every Listen context, and wait for every Listen call to return. The listeners drain and send close frames. + 4. Shut down the adapters, then the state. + +6. **Socket Mode connection rules.** + - **Connect:** call `OpenConnection`, dial the websocket with the Client's HTTP client, and wait for `hello`. The whole connect has a 30 second limit. The HTTP client gives the handshake its proxy settings. The read limit is 1 MiB, the same as the `Webhook` body limit. `ListenOptions.OnConnect` gets each hello, with `num_connections`, the app ID, and the host. + - **Envelopes:** each envelope runs the handler in its own goroutine. The ack goes on the connection that the envelope came on. The handler context ends when that connection closes, not when the Listen context ends. An envelope of an unknown type still goes to the handler, with its type. + - **Planned replacement with no gap:** a `disconnect` message with the reason `warning`, `refresh_requested`, or an unknown reason starts a replacement connection, and so does a ping timeout. The old connection keeps reading and acknowledging until the new connection has its hello. Then the old connection closes. + - **Close order:** a connection closes in four steps. First, it waits for its in-flight handlers, at most 3 seconds (Slack's ack window). Envelopes that arrive during this wait still get handled and acknowledged. Second, `Close(StatusNormalClosure)` sends the close frame. Third, it waits for the read loop to end. Fourth, it cancels the handler context. The read loop never uses a context that can be cancelled, because a cancelled read context closes the connection without a close frame. Only `Close` ends the read loop, so every close sends a close frame. + - **Ping timeout:** Slack sends a websocket ping about every 10 seconds. A timer restarts on each ping, and starts only after the first ping, as in node-slack-sdk. When `ListenOptions.PingTimeout` (30 seconds by default) passes with no ping, Listen replaces the connection and reports `ErrPingTimeout` to `OnError`. The timer does not depend on the read loop, so a stuck read cannot block it. + - **Fatal errors:** Listen stops and returns the error. These errors are fatal: an `*APIError` from `apps.connections.open` with the code `not_authed`, `invalid_auth`, `account_inactive`, `token_revoked`, `token_expired`, `user_removed_from_team`, `team_disabled`, `not_allowed_token_type`, or `missing_scope`, and a `*DisconnectError` with the reason `link_disabled`. + - **`too_many_websockets`:** a `disconnect` message or an API error code with this reason closes that connection at once. Listen reports it to `OnError`, and waits `MaxBackoff` with jitter before the next connect. + - **Other errors:** connect failures and dropped connections go to `OnError`, and Listen retries with an exponential backoff with equal jitter, from `MinBackoff` (1 second by default) to `MaxBackoff` (1 minute by default). A `*RateLimited` error with `RetryAfter` waits at least `RetryAfter`. A dropped connection that was up for at least `MaxBackoff` reconnects at once. Else the drop counts as a failure, so a connection that drops again and again backs off. + - **Stop:** when ctx is done, Listen closes every connection in the close order, waits for every handler goroutine, and returns nil. + +7. **The first dependency: `github.com/coder/websocket`.** Socket Mode needs a websocket client, so the module now depends on `github.com/coder/websocket`. This ends the zero-dependency rule of ADR 0016. The reasons for this module: + - Its API takes a context in `Dial`, `Read`, `Write`, and `Ping`, which fits the limits of Listen. + - It has no dependencies, so it adds no transitive dependencies. + - Coder maintains it, and coderd is the main user of Socket Mode in this module. + - Its `Close` sends a close frame, waits for the close handshake, and works while another goroutine reads. The close rule needs exactly this. + +8. **`slackapitest` gets a fake Socket Mode.** `(*Server).SocketMode` makes `apps.connections.open` return a `ws://` URL on the fake server. The fake sends hello and websocket pings on each connection. It sends each envelope to a random live connection, and resends an envelope that gets no ack up to 3 times, with `retry_reason` `timeout`, as Slack does. A connection over `MaxConnections` gets a `too_many_websockets` disconnect message. Tests can send disconnect messages, stop pings, and drop connections. They can check which connection got each envelope and sent each ack, the order of hellos and closes, and the close frame of each close. The Listen tests use the fake, and coderd can use it for its own Socket Mode tests. + +## Consequences + +- An app can receive Slack events with no public URL and no tunnel. +- coderd gets the outcomes that the library owns: Slack refresh with no gap, drain then close frames, silent connection replacement, fatal errors, backoff, close frames, and proxies. Listen gives hooks for the rest: `Hello.NumConnections` for the extra connection warning, and `OnConnect` for health. coderd keeps the choice of holders, the handover timing, and the rest of its spec: no flapping in rolling deploys, token change and delete, disabled bots, and link button clicks. Its handler returns an error to stop acks during its shutdown. +- Both transports share one pipeline, so a payload gives the same `chat.Event` over HTTP and over Socket Mode. +- The glossary term **Webhook Event** becomes **Delivery**, to match `slackapi.Delivery`. **Listener** and **Socket Mode** are new terms. +- After Runtime Shutdown starts, a dispatch in the synchronous **Dispatch Mode** returns `ErrAdmissionRejected`. Before, sync mode had no admission gate. +- The slash command busy reply is now JSON, because a Socket Mode ack must be JSON. Slack also accepts JSON over HTTP. +- The module has a dependency. Every module that requires this module gets `github.com/coder/websocket` in its module graph, but only builds that import `slackapi` or `slackapitest` compile it. The rejection of `slack-go/slack` in ADR 0016 stands. +- Cost: the Socket Mode client is concurrent code with timing rules. Its tests run against the fake with `-race`. Each connection uses a read loop, a ping timer, and one goroutine for each in-flight envelope. + +## Alternatives Considered + +### Keep HTTP only + +Rejected. coderd needs Socket Mode for deployments behind a firewall, a VPN, or Tailscale. Every other user needs a tunnel to try the library on a laptop. + +### A separate Socket Mode pipeline in the adapter + +Rejected. The adapter would have two copies of the parse, install resolution, normalization, and admission mapping, and the copies would drift. Slash commands would also reach the handler in two shapes. + +### Socket Mode only in `slackapi`, with no runtime capability + +Rejected. Each runtime user would have to connect `Listen` to the adapter pipeline by hand. Runtime Shutdown could not close admission before the connections close, so Slack would keep sending envelopes to a runtime that stops. + +### Use `slack-go/slack` `socketmode` + +Rejected. It closes the raw socket, so it sends no close frame. It is also a large dependency with its own retry and error types, which ADR 0016 already rejects. + +### Use `github.com/gorilla/websocket` + +Rejected. Its API has no context, only deadlines. Its `Close` closes the network connection without a close frame. A close frame needs a manual handshake: write a close message, then wait for the peer's close in the reader. + +### Write a websocket client in this repository + +Rejected. The module would own the framing, masking, fragmentation, control frames, and close handshake of the websocket protocol, and the tests for all of them. `github.com/coder/websocket` already does this, passes the Autobahn test suite, and has no dependencies. diff --git a/docs/explanation.md b/docs/explanation.md index 648a865..61fc687 100644 --- a/docs/explanation.md +++ b/docs/explanation.md @@ -208,3 +208,4 @@ Operations: | [0014](adr/0014-nats-state-adapter.md) | NATS JetStream state adapter | Accepted | | [0015](adr/0015-runtime-coordination.md) | Deferred-dispatch admission bound; cross-instance coalescing rejected for now | Accepted | | [0016](adr/0016-slack-api-package.md) | Low-level `slackapi` package for applications that own their runtime; the Slack adapter builds on it and the portable surface does not grow | Accepted | +| [0017](adr/0017-slack-socket-mode.md) | Slack Socket Mode next to HTTP: one transport-neutral delivery and handler, one adapter pipeline, the `Listener` capability, and the first dependency (`github.com/coder/websocket`) | Accepted | From 347e0174dbd4972903794f191db07ffac24ddcbf Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 02:58:19 +0000 Subject: [PATCH 05/33] feat: add the Listener capability, Chat.Listen, and the listener Shutdown order Add chat.Listener, an Optional Capability for adapters that open their own connections to the platform, and Chat.Listen to run it. Runtime Shutdown now runs in this order: close admission, cancel and drain deferred work, stop listeners (cancel every Listen call and wait for it, bounded by ctx), then shut down adapters and state. Admission now gates both Dispatch Modes. After Shutdown starts, a sync dispatch is rejected with ErrAdmissionRejected too, so a listener stops acking new deliveries before it closes its connections. The error text drops the word "deferred". --- admission.go | 31 ++-- errors.go | 17 +-- export_test.go | 3 - listen_test.go | 382 +++++++++++++++++++++++++++++++++++++++++++++++++ observer.go | 15 +- runtime.go | 189 ++++++++++++++++-------- types.go | 11 ++ 7 files changed, 553 insertions(+), 95 deletions(-) create mode 100644 listen_test.go diff --git a/admission.go b/admission.go index bc8216f..6d5a0f9 100644 --- a/admission.go +++ b/admission.go @@ -11,15 +11,16 @@ type tenantKey struct { tenant string } -// admissionGate is the deferred-dispatch admission bound (ADR 0015): a -// per-instance cap on admitted-but-incomplete deferred deliveries, with an -// optional per-installation ceiling through the same rejection path. It is -// constructed only under DispatchDeferred and closed by Runtime Shutdown so -// a shutting-down instance sheds new deliveries to the platform's retry -// instead of admitting work it is about to cancel. +// admissionGate admits every delivery into Runtime Dispatch. Runtime Shutdown +// closes it, so a shutting-down instance sheds new deliveries to the +// platform's retry in both Dispatch Modes, instead of admitting work it is +// about to cancel. Under DispatchDeferred it is also the Admission Bound (ADR +// 0015): a per-instance cap on admitted-but-incomplete deferred deliveries, +// with an optional per-installation ceiling through the same rejection path. type admissionGate struct { mu sync.Mutex - // capacity is MaxDetached; perTenant is MaxDetachedPerTenant (0 = disabled). + // capacity is MaxDetached and perTenant is MaxDetachedPerTenant. Zero + // disables each bound; under DispatchSync both are zero. capacity int perTenant int inUse int @@ -35,7 +36,7 @@ type admissionGate struct { } func newAdmissionGate(capacity, perTenant int) *admissionGate { - assert(capacity > 0, "admission gate capacity must be positive") + assert(capacity >= 0, "admission gate capacity must not be negative") assert(perTenant >= 0, "admission per-tenant ceiling must not be negative") gate := &admissionGate{capacity: capacity, perTenant: perTenant} if perTenant > 0 { @@ -44,16 +45,16 @@ func newAdmissionGate(capacity, perTenant int) *admissionGate { return gate } -// admit reserves one admission slot for a deferred delivery. It reports false -// when the gate is closed (Runtime Shutdown), the instance is at capacity, or -// the delivery's installation is at its per-tenant ceiling. The returned -// release is idempotent and must be called exactly when the delivery stops -// retaining work: when its prelude resolves or fails, or when its detached -// tail goroutine returns. +// admit reserves one admission slot for a delivery. It reports false when the +// gate is closed (Runtime Shutdown), the instance is at capacity, or the +// delivery's installation is at its per-tenant ceiling. The returned release +// is idempotent and must be called exactly when the delivery stops retaining +// work: at once for a sync delivery; for a deferred delivery, when its +// prelude resolves or fails, or when its detached tail goroutine returns. func (g *admissionGate) admit(adapter, tenant string) (release func(), ok bool) { g.mu.Lock() defer g.mu.Unlock() - if g.closed || g.inUse >= g.capacity { + if g.closed || (g.capacity > 0 && g.inUse >= g.capacity) { return nil, false } key := tenantKey{adapter: adapter, tenant: tenant} diff --git a/errors.go b/errors.go index 472e54d..8ad4094 100644 --- a/errors.go +++ b/errors.go @@ -9,16 +9,17 @@ var ( // runtime instance, expired, or no longer refreshable), so mutual // exclusion could no longer be guaranteed. ErrPreempted = errors.New("chat: handler preempted") - // ErrAdmissionRejected is returned by deferred dispatch when the Admission - // Bound rejects a delivery: the instance is at MaxDetached, the delivery's - // (adapter, tenant) installation is at MaxDetachedPerTenant, or Runtime - // Shutdown has closed admission. The delivery was rejected before - // acknowledgement and before dedupe marking — it is never recorded in - // Event Identity, so a platform retry is not deduped away. Adapters map it - // to a shape-aware overload response: a retry-inducing status for + // ErrAdmissionRejected is returned by dispatch when the runtime rejects a + // delivery: Runtime Shutdown has closed admission (in both Dispatch + // Modes), or, under deferred dispatch, the Admission Bound is full (the + // instance is at MaxDetached, or the delivery's (adapter, tenant) + // installation is at MaxDetachedPerTenant). The delivery was rejected + // before acknowledgement and before dedupe marking. It is never recorded + // in Event Identity, so a platform retry is not deduped away. Adapters map + // it to a shape-aware overload response: a retry-inducing status for // platform-redelivered shapes, a truthful busy signal for direct // invocations the platform does not redeliver (ADR 0015). - ErrAdmissionRejected = errors.New("chat: deferred dispatch admission rejected") + ErrAdmissionRejected = errors.New("chat: dispatch admission rejected") ) func assert(ok bool, message string) { diff --git a/export_test.go b/export_test.go index 2c0dc42..c044582 100644 --- a/export_test.go +++ b/export_test.go @@ -5,9 +5,6 @@ package chat // released capacity retains no counter entry (ADR 0015 bounded retention). func AdmissionTenantEntries(c *Chat) int { assert(c != nil, "AdmissionTenantEntries called on nil runtime") - if c.admission == nil { - return 0 - } return c.admission.tenantEntries() } diff --git a/listen_test.go b/listen_test.go new file mode 100644 index 0000000..efbbabb --- /dev/null +++ b/listen_test.go @@ -0,0 +1,382 @@ +package chat_test + +import ( + "context" + "errors" + "log/slog" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/coder/chat" +) + +// listenerAdapter is a fake adapter with the Listener capability. listen runs +// for each Listen call. +type listenerAdapter struct { + *fakeAdapter + listen func(ctx context.Context, dispatch chat.DispatchFunc) error + calls atomic.Int32 +} + +var _ chat.Listener = (*listenerAdapter)(nil) + +func (a *listenerAdapter) Listen(ctx context.Context, dispatch chat.DispatchFunc) error { + a.calls.Add(1) + return a.listen(ctx, dispatch) +} + +func newListenRuntime(t *testing.T, state chat.State, adapter chat.Adapter, observer chat.Observer, mode chat.DispatchMode) *chat.Chat { + t.Helper() + options := chat.DefaultRuntimeOptions() + options.Dispatch = mode + options.DetachTimeout = 5 * time.Second + bot, err := chat.New(context.Background(), + chat.WithState(state), + chat.WithAdapter(adapter), + chat.WithLogger(slog.New(slog.NewTextHandler(newSyncBuffer(), nil))), + chat.WithObserver(observer), + chat.WithRuntimeOptions(options), + ) + if err != nil { + t.Fatalf("new listen runtime: %v", err) + } + return bot +} + +// receive returns the next value from ch, or fails the test after 5 seconds. +func receive[T any](t *testing.T, ch <-chan T, what string) (v T) { + t.Helper() + select { + case v = <-ch: + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %s", what) + } + return v +} + +func TestListenRejectsUnknownAdapterAndMissingCapability(t *testing.T) { + t.Parallel() + + bot := newListenRuntime(t, newFakeState(), newFakeAdapter("fake"), &recordingObserver{}, chat.DispatchSync) + + err := bot.Listen(t.Context(), "missing") + _, webhookErr := bot.Webhook("missing") + if err == nil || err.Error() != webhookErr.Error() { + t.Fatalf("Listen(missing) = %v, want %v", err, webhookErr) + } + + err = bot.Listen(t.Context(), "fake") + if !errors.Is(err, chat.ErrUnsupportedCapability) || !strings.Contains(err.Error(), `"fake"`) { + t.Fatalf("Listen(fake) = %v, want ErrUnsupportedCapability that names the adapter", err) + } +} + +func TestListenAfterShutdownReturnsNilWithoutCallingAdapter(t *testing.T) { + t.Parallel() + + adapter := &listenerAdapter{ + fakeAdapter: newFakeAdapter("fake"), + listen: func(context.Context, chat.DispatchFunc) error { + return errors.New("adapter Listen called after Shutdown") + }, + } + bot := newListenRuntime(t, newFakeState(), adapter, &recordingObserver{}, chat.DispatchSync) + if err := bot.Shutdown(t.Context()); err != nil { + t.Fatalf("shutdown: %v", err) + } + + if err := bot.Listen(t.Context(), "fake"); err != nil { + t.Fatalf("Listen after Shutdown = %v, want nil", err) + } + if calls := adapter.calls.Load(); calls != 0 { + t.Fatalf("adapter Listen calls = %d, want 0", calls) + } +} + +func TestListenReturnsNilOnCancelAndAdapterFatalError(t *testing.T) { + t.Parallel() + + t.Run("cancel", func(t *testing.T) { + t.Parallel() + started := make(chan struct{}) + adapter := &listenerAdapter{ + fakeAdapter: newFakeAdapter("fake"), + listen: func(ctx context.Context, _ chat.DispatchFunc) error { + close(started) + <-ctx.Done() + return nil + }, + } + bot := newListenRuntime(t, newFakeState(), adapter, &recordingObserver{}, chat.DispatchSync) + + ctx, cancel := context.WithCancel(t.Context()) + listenDone := make(chan error, 1) + go func() { listenDone <- bot.Listen(ctx, "fake") }() + receive(t, started, "adapter Listen to start") + cancel() + if err := receive(t, listenDone, "Listen to return after cancel"); err != nil { + t.Fatalf("Listen after cancel = %v, want nil", err) + } + }) + + t.Run("fatal", func(t *testing.T) { + t.Parallel() + errFatal := errors.New("link disabled") + adapter := &listenerAdapter{ + fakeAdapter: newFakeAdapter("fake"), + listen: func(context.Context, chat.DispatchFunc) error { + return errFatal + }, + } + bot := newListenRuntime(t, newFakeState(), adapter, &recordingObserver{}, chat.DispatchSync) + + err := bot.Listen(t.Context(), "fake") + if !errors.Is(err, errFatal) || !strings.Contains(err.Error(), `"fake"`) { + t.Fatalf("Listen = %v, want the adapter error wrapped with the adapter name", err) + } + }) +} + +// TestShutdownStopsListenersAfterAdmissionClosesAndDeferredWorkDrains pins the +// Shutdown order: when a listener sees its context done, admission is closed +// and the deferred handler that was in flight has finished. +func TestShutdownStopsListenersAfterAdmissionClosesAndDeferredWorkDrains(t *testing.T) { + t.Parallel() + + type stopView struct { + dispatchErr error + handlerDone bool + } + handlerStarted := make(chan struct{}) + handlerDone := make(chan struct{}) + dispatches := make(chan chat.DispatchFunc, 1) + stopped := make(chan stopView, 1) + adapter := &listenerAdapter{ + fakeAdapter: newFakeAdapter("fake"), + listen: func(ctx context.Context, dispatch chat.DispatchFunc) error { + dispatches <- dispatch + <-ctx.Done() + late := mentionEvent("late", "fake:v1:thread-2") + view := stopView{dispatchErr: dispatch(context.Background(), &late)} + select { + case <-handlerDone: + view.handlerDone = true + default: + } + stopped <- view + return nil + }, + } + bot := newListenRuntime(t, newFakeState(), adapter, &recordingObserver{}, chat.DispatchDeferred) + bot.OnNewMention(func(ctx context.Context, _ *chat.MessageEvent) error { + close(handlerStarted) + <-ctx.Done() + // Linger, so a listener stopped before the drain sees this handler + // still running. + time.Sleep(50 * time.Millisecond) + close(handlerDone) + return ctx.Err() + }) + + listenDone := make(chan error, 1) + go func() { listenDone <- bot.Listen(t.Context(), "fake") }() + dispatch := receive(t, dispatches, "adapter Listen to start") + inflight := mentionEvent("inflight", "fake:v1:thread-1") + if err := dispatch(t.Context(), &inflight); err != nil { + t.Fatalf("dispatch before Shutdown: %v", err) + } + receive(t, handlerStarted, "deferred handler to start") + + if err := bot.Shutdown(t.Context()); err != nil { + t.Fatalf("shutdown: %v", err) + } + view := receive(t, stopped, "listener to see its context done") + if !errors.Is(view.dispatchErr, chat.ErrAdmissionRejected) { + t.Fatalf("dispatch when the listener stopped = %v, want ErrAdmissionRejected", view.dispatchErr) + } + if !view.handlerDone { + t.Fatal("listener stopped before the in-flight deferred handler finished") + } + if err := receive(t, listenDone, "Listen to return"); err != nil { + t.Fatalf("Listen = %v, want nil", err) + } +} + +func TestShutdownWaitsForListenBoundedByCtx(t *testing.T) { + t.Parallel() + + t.Run("waits", func(t *testing.T) { + t.Parallel() + const listeners = 2 + started := make(chan struct{}, listeners) + var returned atomic.Int32 + inner := newFakeAdapter("fake") + var returnedAtAdapterShutdown int32 + inner.shutdownHook = func() { returnedAtAdapterShutdown = returned.Load() } + adapter := &listenerAdapter{ + fakeAdapter: inner, + listen: func(ctx context.Context, _ chat.DispatchFunc) error { + started <- struct{}{} + <-ctx.Done() + // Linger, so a Shutdown that does not wait shuts the + // adapter down first. + time.Sleep(50 * time.Millisecond) + returned.Add(1) + return nil + }, + } + state := newFakeState() + bot := newListenRuntime(t, state, adapter, &recordingObserver{}, chat.DispatchSync) + + listenDone := make(chan error, listeners) + for range listeners { + go func() { listenDone <- bot.Listen(t.Context(), "fake") }() + } + for range listeners { + receive(t, started, "adapter Listen to start") + } + if err := bot.Shutdown(t.Context()); err != nil { + t.Fatalf("shutdown: %v", err) + } + if returnedAtAdapterShutdown != listeners { + t.Fatalf("Listen calls returned before adapter shutdown = %d, want %d", returnedAtAdapterShutdown, listeners) + } + for range listeners { + if err := receive(t, listenDone, "Listen to return"); err != nil { + t.Fatalf("Listen = %v, want nil", err) + } + } + }) + + t.Run("bounded", func(t *testing.T) { + t.Parallel() + started := make(chan struct{}) + release := make(chan struct{}) + adapter := &listenerAdapter{ + fakeAdapter: newFakeAdapter("fake"), + listen: func(context.Context, chat.DispatchFunc) error { + close(started) + <-release // ignore ctx, so the wait must time out + return nil + }, + } + state := newFakeState() + bot := newListenRuntime(t, state, adapter, &recordingObserver{}, chat.DispatchSync) + + listenDone := make(chan error, 1) + go func() { listenDone <- bot.Listen(t.Context(), "fake") }() + receive(t, started, "adapter Listen to start") + + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + if err := bot.Shutdown(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("shutdown = %v, want context.DeadlineExceeded", err) + } + if adapter.shutdowns != 1 || state.shutdowns != 1 { + t.Fatalf("cleanup counts adapter=%d state=%d, want 1 and 1", adapter.shutdowns, state.shutdowns) + } + close(release) + if err := receive(t, listenDone, "Listen to return"); err != nil { + t.Fatalf("Listen = %v, want nil", err) + } + }) +} + +// TestListenRacingShutdown starts Listen calls while Shutdown runs. Each call +// either returns at once or is stopped by Shutdown, and no adapter Listen is +// still running when Shutdown shuts the adapter down. +func TestListenRacingShutdown(t *testing.T) { + t.Parallel() + + var active atomic.Int32 + inner := newFakeAdapter("fake") + var activeAtAdapterShutdown int32 + inner.shutdownHook = func() { activeAtAdapterShutdown = active.Load() } + adapter := &listenerAdapter{ + fakeAdapter: inner, + listen: func(ctx context.Context, _ chat.DispatchFunc) error { + active.Add(1) + defer active.Add(-1) + <-ctx.Done() + return nil + }, + } + bot := newListenRuntime(t, newFakeState(), adapter, &recordingObserver{}, chat.DispatchSync) + + const callers = 32 + listenDone := make(chan error, callers) + for range callers { + go func() { listenDone <- bot.Listen(t.Context(), "fake") }() + } + if err := bot.Shutdown(t.Context()); err != nil { + t.Fatalf("shutdown: %v", err) + } + if activeAtAdapterShutdown != 0 { + t.Fatalf("adapter Listen calls running at adapter shutdown = %d, want 0", activeAtAdapterShutdown) + } + for range callers { + if err := receive(t, listenDone, "Listen to return"); err != nil { + t.Fatalf("Listen = %v, want nil", err) + } + } +} + +// TestSyncDispatchRejectedAfterShutdownStarts pins admission in sync dispatch +// mode: once Shutdown starts, a new delivery gets ErrAdmissionRejected and +// does not run its handler. +func TestSyncDispatchRejectedAfterShutdownStarts(t *testing.T) { + t.Parallel() + + dispatches := make(chan chat.DispatchFunc, 1) + stoppedErr := make(chan error, 1) + adapter := &listenerAdapter{ + fakeAdapter: newFakeAdapter("fake"), + listen: func(ctx context.Context, dispatch chat.DispatchFunc) error { + dispatches <- dispatch + <-ctx.Done() + late := mentionEvent("during-shutdown", "fake:v1:thread-2") + stoppedErr <- dispatch(context.Background(), &late) + return nil + }, + } + observer := &recordingObserver{} + bot := newListenRuntime(t, newFakeState(), adapter, observer, chat.DispatchSync) + var handled atomic.Int32 + bot.OnNewMention(func(context.Context, *chat.MessageEvent) error { + handled.Add(1) + return nil + }) + + listenDone := make(chan error, 1) + go func() { listenDone <- bot.Listen(t.Context(), "fake") }() + dispatch := receive(t, dispatches, "adapter Listen to start") + before := mentionEvent("before-shutdown", "fake:v1:thread-1") + if err := dispatch(t.Context(), &before); err != nil { + t.Fatalf("dispatch before Shutdown: %v", err) + } + if got := handled.Load(); got != 1 { + t.Fatalf("handled before Shutdown = %d, want 1", got) + } + + if err := bot.Shutdown(t.Context()); err != nil { + t.Fatalf("shutdown: %v", err) + } + if err := receive(t, stoppedErr, "listener to see its context done"); !errors.Is(err, chat.ErrAdmissionRejected) { + t.Fatalf("dispatch during Shutdown = %v, want ErrAdmissionRejected", err) + } + after := mentionEvent("after-shutdown", "fake:v1:thread-3") + if err := dispatch(t.Context(), &after); !errors.Is(err, chat.ErrAdmissionRejected) { + t.Fatalf("dispatch after Shutdown = %v, want ErrAdmissionRejected", err) + } + if got := handled.Load(); got != 1 { + t.Fatalf("handled after Shutdown = %d, want 1 (no handler runs for a rejected delivery)", got) + } + if !observer.hasEvent(chat.ObsAdmissionRejected) || !hasOutcome(observer, chat.OutcomeAdmissionRejected) { + t.Fatalf("observations = %v, outcomes = %v, want admission_rejected", observer.eventNames(), observer.terminalOutcomes()) + } + if err := receive(t, listenDone, "Listen to return"); err != nil { + t.Fatalf("Listen = %v, want nil", err) + } +} diff --git a/observer.go b/observer.go index a1a80b3..f5dfbc2 100644 --- a/observer.go +++ b/observer.go @@ -32,9 +32,10 @@ const ( // observes platform rate limiting (ADR 0005). Adapter-owned, like // ObsAdapterCall. ObsRateLimit ObservationName = "rate_limit" - // ObsAdmissionRejected is emitted when the deferred-dispatch Admission - // Bound rejects a delivery (MaxDetached, MaxDetachedPerTenant, or Runtime - // Shutdown). Carries adapter and tenant attributes. + // ObsAdmissionRejected is emitted when the runtime rejects a delivery: + // Runtime Shutdown has closed admission (in both Dispatch Modes), or the + // deferred-dispatch Admission Bound is full (MaxDetached or + // MaxDetachedPerTenant). Carries adapter and tenant attributes. ObsAdmissionRejected ObservationName = "admission_rejected" ) @@ -52,10 +53,10 @@ const ( // its Lock Lease was lost mid-run: released by another runtime instance, // expired, or no longer refreshable. OutcomePreempted DispatchOutcome = "preempted" - // OutcomeAdmissionRejected is the terminal outcome of a delivery the - // Admission Bound rejected before acknowledgement and before dedupe - // marking: it never became an Accepted Event and owes only the overload - // response. + // OutcomeAdmissionRejected is the terminal outcome of a delivery that + // admission rejected (see ObsAdmissionRejected) before acknowledgement and + // before dedupe marking: it never became an Accepted Event and owes only + // the overload response. OutcomeAdmissionRejected DispatchOutcome = "admission-rejected" ) diff --git a/runtime.go b/runtime.go index bd58501..10e3ff2 100644 --- a/runtime.go +++ b/runtime.go @@ -247,10 +247,18 @@ type Chat struct { burstMu sync.Mutex burstScopes map[string]*burstScope - // admission is the deferred-dispatch Admission Bound (ADR 0015); nil under - // DispatchSync. + // admission admits every delivery, and Runtime Shutdown closes it. Under + // DispatchDeferred it is also the Admission Bound (ADR 0015). admission *admissionGate + // stopListeners cancels listenCtx, and that cancels the context of every + // Listen call. listeners counts the Listen calls that run. + listenCtx context.Context + stopListeners context.CancelFunc + listeners sync.WaitGroup + + // shutdownMu guards shutdown, and orders listeners.Add before the + // listeners.Wait in Shutdown. shutdownMu sync.Mutex shutdown bool shutdownDone chan struct{} @@ -289,7 +297,6 @@ func New(ctx context.Context, opts ...Option) (*Chat, error) { return nil, err } - baseCtx, baseCancel := context.WithCancel(context.Background()) chat := &Chat{ state: cfg.state, adapters: map[string]Adapter{}, @@ -297,8 +304,6 @@ func New(ctx context.Context, opts ...Option) (*Chat, error) { observer: cfg.observer, options: cfg.options, eventAcceptances: map[string]*eventAcceptance{}, - baseCtx: baseCtx, - baseCancel: baseCancel, pending: map[string]*pendingWaiter{}, burstScopes: map[string]*burstScope{}, } @@ -307,30 +312,30 @@ func New(ctx context.Context, opts ...Option) (*Chat, error) { } if cfg.options.Dispatch == DispatchDeferred { chat.admission = newAdmissionGate(cfg.options.MaxDetached, cfg.options.MaxDetachedPerTenant) + } else { + chat.admission = newAdmissionGate(0, 0) } for _, adapter := range cfg.adapters { if adapter == nil { - baseCancel() return nil, errors.New("chat: nil adapter") } name := adapter.Name() if name == "" { - baseCancel() return nil, errors.New("chat: adapter name is required") } if _, exists := chat.adapters[name]; exists { - baseCancel() return nil, fmt.Errorf("chat: adapter %q registered more than once", name) } chat.adapters[name] = adapter if err := adapter.Init(ctx); err != nil { - baseCancel() return nil, errors.Join( fmt.Errorf("chat: initialize adapter %q: %w", name, err), shutdownAdapters(ctx, chat.adapters), ) } } + chat.baseCtx, chat.baseCancel = context.WithCancel(context.Background()) + chat.listenCtx, chat.stopListeners = context.WithCancel(context.Background()) return chat, nil } @@ -443,6 +448,48 @@ func (c *Chat) Webhook(adapterName string) (http.Handler, error) { return adapter.Webhook(c.dispatch), nil } +// Listen runs the Listener of the named adapter. It blocks until ctx is done +// or Runtime Shutdown stops listeners, and then returns nil. If the adapter +// cannot continue to listen, Listen returns the adapter's error. +// +// Listen returns an error at once if the adapter is not registered, or if it +// does not implement Listener (ErrUnsupportedCapability). After Runtime +// Shutdown has started, Listen returns nil at once and does not call the +// adapter. Concurrent calls are allowed. +// +// Shutdown stops listeners only after it closes admission and drains deferred +// work. So while Shutdown runs, new deliveries from a listener get +// ErrAdmissionRejected before the listener closes its connections, and the +// platform can deliver them again. +func (c *Chat) Listen(ctx context.Context, adapterName string) error { + assert(c != nil, "Listen called on nil runtime") + adapter, ok := c.adapters[adapterName] + if !ok { + return fmt.Errorf("chat: adapter %q is not registered", adapterName) + } + listener, ok := adapter.(Listener) + if !ok { + return fmt.Errorf("chat: adapter %q cannot listen: %w", adapterName, ErrUnsupportedCapability) + } + + c.shutdownMu.Lock() + if c.shutdown { + c.shutdownMu.Unlock() + return nil + } + c.listeners.Add(1) + c.shutdownMu.Unlock() + defer c.listeners.Done() + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + defer context.AfterFunc(c.listenCtx, cancel)() + if err := listener.Listen(ctx, c.dispatch); err != nil { + return fmt.Errorf("chat: listen on adapter %q: %w", adapterName, err) + } + return nil +} + func (c *Chat) Thread(ctx context.Context, id ThreadID) (*Thread, error) { assert(c != nil, "Thread called on nil runtime") if err := ctx.Err(); err != nil { @@ -476,6 +523,17 @@ func AdapterAs[T any](c *Chat, adapterName string) (T, bool) { return typed, ok } +// Shutdown stops the runtime in this order: +// +// 1. It closes admission: every new delivery, in both Dispatch Modes, gets +// ErrAdmissionRejected. +// 2. It cancels deferred work and waits for it to finish. +// 3. It cancels every Listen call and waits for it to return. +// 4. It shuts down every adapter, then the State. +// +// ctx bounds the waits in steps 2 and 3. Shutdown returns the joined errors of +// the steps. It is idempotent: a later call waits for the first call to +// finish, bounded by its own ctx, and then returns nil. func (c *Chat) Shutdown(ctx context.Context) error { assert(c != nil, "Shutdown called on nil runtime") c.shutdownMu.Lock() @@ -495,10 +553,11 @@ func (c *Chat) Shutdown(ctx context.Context) error { c.shutdownMu.Unlock() defer close(done) - // Close admission first so a delivery racing Shutdown is rejected with - // ErrAdmissionRejected (the platform's retry covers it) instead of being - // admitted into a runtime about to cancel its work. Then cancel detached - // tails and drain (bounded by ctx) before shutting down adapters and state. + // Close admission first so a delivery racing Shutdown, in either Dispatch + // Mode, is rejected with ErrAdmissionRejected (the platform's retry covers + // it) instead of being admitted into a runtime about to cancel its work. + // Then cancel detached tails and drain (bounded by ctx), then stop + // listeners (bounded by ctx), before shutting down adapters and state. // // The drain waits on admission slots before the tail WaitGroup: a delivery // that won the admission race is retained from admit until its tail @@ -506,33 +565,41 @@ func (c *Chat) Shutdown(ctx context.Context) error { // their synchronous prelude — which hold no WaitGroup count yet — and // guarantees no tail is spawned (and no WaitGroup Add happens) after the // slot drain completes. - var admissionDrained <-chan struct{} - if c.admission != nil { - admissionDrained = c.admission.close() - } + admissionDrained := c.admission.close() c.baseCancel() - var drainErr error - drained := make(chan struct{}) - go func() { - if admissionDrained != nil { - <-admissionDrained - } + err := waitBounded(ctx, func() { + <-admissionDrained c.inflight.Wait() - close(drained) - }() - select { - case <-drained: - case <-ctx.Done(): - drainErr = ctx.Err() + }) + c.stopListeners() + // A drain error means that ctx is done, so there is no time to wait. + if err == nil { + err = waitBounded(ctx, c.listeners.Wait) } - err := errors.Join(drainErr, shutdownAdapters(ctx, c.adapters)) + err = errors.Join(err, shutdownAdapters(ctx, c.adapters)) if stateErr := c.state.Shutdown(ctx); stateErr != nil { return errors.Join(err, fmt.Errorf("shutdown state: %w", stateErr)) } return err } +// waitBounded runs wait and returns nil when it returns, or ctx.Err() if ctx +// is done first. On ctx done, wait keeps running in its goroutine. +func waitBounded(ctx context.Context, wait func()) error { + done := make(chan struct{}) + go func() { + wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + func shutdownAdapters(ctx context.Context, adapters map[string]Adapter) error { var errs []error for name, adapter := range adapters { @@ -548,9 +615,29 @@ func (c *Chat) dispatch(ctx context.Context, event *Event) error { // registration can order deliveries by admission: a delivery delayed in // validation/dedupe/routing can never displace a newer waiter. seq := c.dispatchSeq.Add(1) + if err := validateEvent(event); err != nil { + return err + } + // The admission gate runs before any acknowledgement and before dedupe + // marking: a rejected delivery fails fast with ErrAdmissionRejected, is + // never marked in Event Identity (so a platform retry is not deduped + // away), and does no State work at all. Under saturation even a + // redelivered duplicate receives the overload response, converging to the + // ordinary duplicate acknowledgement once capacity frees (ADR 0015). + release, admitted := c.admission.admit(event.Adapter, event.Tenant) + if !admitted { + spanCtx, span := c.safeDispatch(ctx, AdapterAttr(event.Adapter), TenantAttr(event.Tenant)) + c.logger.Warn("chat dispatch admission rejected", "adapter", event.Adapter, "tenant", event.Tenant, "event_id", event.ID) + c.safeEvent(spanCtx, ObsAdmissionRejected, AdapterAttr(event.Adapter), TenantAttr(event.Tenant)) + c.safeEnd(span, OutcomeAdmissionRejected) + return fmt.Errorf("chat: dispatch admission rejected for adapter %q: %w", event.Adapter, ErrAdmissionRejected) + } if c.options.Dispatch == DispatchDeferred { - return c.dispatchDeferred(ctx, event, seq) + return c.dispatchDeferred(ctx, event, seq, release) } + // A sync delivery retains no work for Shutdown to drain: its caller (an + // HTTP server or a Listener) owns its lifetime. + release() return c.dispatchSync(ctx, event, seq) } @@ -595,26 +682,8 @@ func (c *Chat) dispatchSync(ctx context.Context, event *Event, seq uint64) error // dispatchDeferred runs the prelude under the request context and, on a routed // event, launches the detached tail and returns so the adapter can acknowledge -// the platform (ack-then-work). The Admission Bound gate runs first, before -// any acknowledgement and before dedupe marking: a delivery rejected at the -// cap fails fast with ErrAdmissionRejected, is never marked in Event Identity -// (so a platform retry is not deduped away), and does no State work at all — -// under saturation even a redelivered duplicate receives the overload -// response, converging to the ordinary duplicate acknowledgement once -// capacity frees (ADR 0015). -func (c *Chat) dispatchDeferred(ctx context.Context, event *Event, seq uint64) error { - if err := validateEvent(event); err != nil { - return err - } - assert(c.admission != nil, "deferred dispatch requires the admission gate") - release, admitted := c.admission.admit(event.Adapter, event.Tenant) - if !admitted { - spanCtx, span := c.safeDispatch(ctx, AdapterAttr(event.Adapter), TenantAttr(event.Tenant)) - c.logger.Warn("chat deferred dispatch admission rejected", "adapter", event.Adapter, "tenant", event.Tenant, "event_id", event.ID) - c.safeEvent(spanCtx, ObsAdmissionRejected, AdapterAttr(event.Adapter), TenantAttr(event.Tenant)) - c.safeEnd(span, OutcomeAdmissionRejected) - return fmt.Errorf("chat: deferred dispatch admission rejected for adapter %q: %w", event.Adapter, ErrAdmissionRejected) - } +// the platform (ack-then-work). release frees the delivery's admission slot. +func (c *Chat) dispatchDeferred(ctx context.Context, event *Event, seq uint64, release func()) error { work, resolved, err := c.prelude(ctx, event, seq) if err != nil || resolved { // The delivery retains nothing past the prelude: an errored or @@ -680,13 +749,13 @@ type preludeWork struct { span DispatchSpan } -// prelude runs the synchronous portion of dispatch before ack: open the dispatch -// span, validate, dedupe, acquire the Thread Lock, validate the thread id, filter -// nil/self events, and route. A resolved event (duplicate, dropped Lock Conflict, -// ignored, unrouted) returns resolved=true with no work and closes the span with -// its terminal outcome. A failed prelude returns the error and, as today, leaves -// the event un-marked so a retry is not deduped away; its span is closed with the -// error outcome. +// prelude runs the synchronous portion of dispatch before ack, after dispatch +// validated and admitted the event: open the dispatch span, dedupe, acquire the +// Thread Lock, validate the thread id, filter nil/self events, and route. A +// resolved event (duplicate, dropped Lock Conflict, ignored, unrouted) returns +// resolved=true with no work and closes the span with its terminal outcome. A +// failed prelude returns the error and, as today, leaves the event un-marked so +// a retry is not deduped away; its span is closed with the error outcome. // // Routing precedence: command-ness and interaction-ness are Event kinds and take // precedence over subscription state, so a command/interaction in a Subscribed @@ -697,10 +766,6 @@ type preludeWork struct { // event is registered as pending (no lease) and returned with needsLock=true so // the tail acquires the lock, keeping ack prompt under DispatchDeferred. func (c *Chat) prelude(ctx context.Context, event *Event, seq uint64) (preludeWork, bool, error) { - if err := validateEvent(event); err != nil { - return preludeWork{}, true, err - } - spanCtx, span := c.safeDispatch(ctx, AdapterAttr(event.Adapter), TenantAttr(event.Tenant)) ctx = spanCtx diff --git a/types.go b/types.go index 13496fe..ee81aea 100644 --- a/types.go +++ b/types.go @@ -226,3 +226,14 @@ type HistoryQuery struct { type HistoryReader interface { ReadHistory(context.Context, ThreadID, HistoryQuery) ([]Message, error) } + +// Listener is the Optional Capability of an adapter that opens its own +// connections to the platform, as an alternative to a Webhook Handler. Run it +// with Chat.Listen. +// +// Listen dispatches each inbound payload through dispatch. It blocks until ctx +// is done. Then it drains, closes its connections, and returns nil. A non-nil +// error means that Listen cannot continue. +type Listener interface { + Listen(ctx context.Context, dispatch DispatchFunc) error +} From 01c1e5d514464c1dc1aa7333a712f0477b96be4d Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 03:47:27 +0000 Subject: [PATCH 06/33] test(adapters/slack/slackapi/slackapitest): count the pings that a Socket Mode client answers --- adapters/slack/slackapi/slackapitest/socketmode.go | 12 +++++++++++- .../slack/slackapi/slackapitest/socketmode_test.go | 8 ++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/adapters/slack/slackapi/slackapitest/socketmode.go b/adapters/slack/slackapi/slackapitest/socketmode.go index 62823d3..69b2714 100644 --- a/adapters/slack/slackapi/slackapitest/socketmode.go +++ b/adapters/slack/slackapi/slackapitest/socketmode.go @@ -109,6 +109,10 @@ type SocketConn struct { // CloseSeq is the sequence number of the close. It is 0 while the // connection is open. CloseSeq int + // Pings is the number of pings that the client answered with a pong. A + // client handles a ping before it sends the pong, so a test can wait for + // Pings to know that the client got a ping. + Pings int // CloseFrame reports that the client sent a close frame. CloseFrame bool // CloseCode is the status code in the close frame of the client. It is 0 @@ -592,7 +596,13 @@ func (m *SocketMode) ping(c *socketConn) { } // Ping has no timeout: websocket closes the connection when the // context of a Ping ends during its write. - _ = c.ws.Ping(m.ctx) + if c.ws.Ping(m.ctx) != nil { + continue + } + m.mu.Lock() + c.info.Pings++ + m.mu.Unlock() + m.srv.changes.notify() } } diff --git a/adapters/slack/slackapi/slackapitest/socketmode_test.go b/adapters/slack/slackapi/slackapitest/socketmode_test.go index 0dc9a97..b6f107e 100644 --- a/adapters/slack/slackapi/slackapitest/socketmode_test.go +++ b/adapters/slack/slackapi/slackapitest/socketmode_test.go @@ -542,6 +542,14 @@ func TestSocketModePings(t *testing.T) { second.hello(t) first.waitPings(t, 2) second.waitPings(t, 2) + ctx, cancel := context.WithTimeout(t.Context(), waitTimeout) + defer cancel() + if err := srv.Wait(ctx, func() bool { + conns := sm.Conns() + return conns[0].Pings >= 2 && conns[1].Pings >= 2 + }); err != nil { + t.Fatalf("wait for 2 answered pings on each connection: %v, conns = %+v", err, sm.Conns()) + } sm.StopPings(1) // One ping can be on the way when StopPings returns. From f2a496f6cfd3415818a6cf0ec98ad37212ba8dd4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 03:47:27 +0000 Subject: [PATCH 07/33] feat(adapters/slack/slackapi): add the Socket Mode client with OpenConnection and Listen --- adapters/slack/slackapi/doc.go | 17 + adapters/slack/slackapi/example_test.go | 46 ++ adapters/slack/slackapi/socketmode.go | 628 +++++++++++++++ .../slackapi/socketmode_internal_test.go | 60 ++ adapters/slack/slackapi/socketmode_test.go | 742 ++++++++++++++++++ examples/slack-nats-state/go.mod | 1 + examples/slack-postgres-state/go.mod | 1 + examples/slack-redis-state/go.mod | 1 + 8 files changed, 1496 insertions(+) create mode 100644 adapters/slack/slackapi/socketmode.go create mode 100644 adapters/slack/slackapi/socketmode_internal_test.go create mode 100644 adapters/slack/slackapi/socketmode_test.go diff --git a/adapters/slack/slackapi/doc.go b/adapters/slack/slackapi/doc.go index 556f631..5a4b598 100644 --- a/adapters/slack/slackapi/doc.go +++ b/adapters/slack/slackapi/doc.go @@ -59,6 +59,23 @@ // ParseEnvelope decodes an Events API payload, and Envelope.MessageEvent // decodes its app_mention or message event. // +// # Socket Mode +// +// Listen is the Socket Mode transport. The app opens a websocket to Slack, so +// it needs no public request URL. Call Listen on a Client with the app-level +// token: api.WithToken(appToken).Listen(ctx, handler, opts). Listen gets the +// websocket URL with OpenConnection, and opens the websocket with the HTTP +// client of the Client, so its proxy settings apply. It gives each envelope to +// its Handler as a Delivery, and acks the envelope on the connection that it +// came on. +// +// Listen keeps a connection up. When Slack asks for a refresh, or stops its +// pings, Listen opens a new connection before it closes the old one. It retries +// other errors with backoff, and reports them to ListenOptions.OnError. It +// returns when ctx is done, or on an error that a retry cannot fix, for example +// invalid_auth or link_disabled. A connection waits at most 3 seconds for its +// running handlers, and then closes with a close frame. +// // # Formats // // MarkdownBlock, ActionsBlock with ButtonElement, and ImageBlock are Block Kit diff --git a/adapters/slack/slackapi/example_test.go b/adapters/slack/slackapi/example_test.go index 1b23567..72d57c7 100644 --- a/adapters/slack/slackapi/example_test.go +++ b/adapters/slack/slackapi/example_test.go @@ -3,8 +3,12 @@ package slackapi_test import ( "context" "fmt" + "log" "net/http" "net/http/httptest" + "strings" + + "github.com/coder/websocket" "github.com/coder/chat/adapters/slack/slackapi" ) @@ -35,3 +39,45 @@ func ExampleClient_PostMessage() { fmt.Println(resp.Channel, resp.TS) // Output: C123 1700000000.000100 } + +func ExampleClient_Listen() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // A stand-in for Slack: it sends hello and one envelope, prints the ack, + // and then stops Listen. + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + mux.HandleFunc("POST /apps.connections.open", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"ok":true,"url":"ws%s/link/"}`, strings.TrimPrefix(srv.URL, "http")) + }) + mux.HandleFunc("/link/", func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.CloseNow() }() + _ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"hello","num_connections":1}`)) + _ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"events_api","envelope_id":"e1","payload":{"type":"event_callback"}}`)) + _, ack, _ := conn.Read(ctx) + fmt.Println("ack:", string(ack)) + cancel() + // Read until the close frame of Listen. + _, _, _ = conn.Read(context.Background()) + }) + + api := slackapi.New(slackapi.Options{BaseURL: srv.URL}) + err := api.WithToken("xapp-example").Listen(ctx, func(_ context.Context, d *slackapi.Delivery) (any, error) { + fmt.Println("delivery:", d.Type, string(d.Payload)) + return nil, nil + }, slackapi.ListenOptions{ + OnError: func(err error) { log.Println("slack:", err) }, + }) + fmt.Println("listen:", err) + // Output: + // delivery: events_api {"type":"event_callback"} + // ack: {"envelope_id":"e1"} + // listen: +} diff --git a/adapters/slack/slackapi/socketmode.go b/adapters/slack/slackapi/socketmode.go new file mode 100644 index 0000000..a16b2f5 --- /dev/null +++ b/adapters/slack/slackapi/socketmode.go @@ -0,0 +1,628 @@ +package slackapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/rand/v2" + "net/url" + "sync" + "time" + + "github.com/coder/websocket" + + "github.com/coder/chat/internal/ratelimit" +) + +const ( + defaultPingTimeout = 30 * time.Second + defaultMinBackoff = time.Second + defaultMaxBackoff = time.Minute + // connectTimeout bounds one connect: apps.connections.open, the websocket + // handshake, and the wait for hello. + connectTimeout = 30 * time.Second + // drainTimeout bounds the wait for running handlers before a close. It is + // the time that Slack gives an app to ack. + drainTimeout = 3 * time.Second +) + +const reasonTooManyWebsockets = "too_many_websockets" + +// ErrPingTimeout is the error that Listen gives to ListenOptions.OnError when +// Slack sends no ping on a connection for ListenOptions.PingTimeout. Listen +// then replaces the connection. +var ErrPingTimeout = errors.New("slack: socket mode ping timeout") + +var errConnectTimeout = errors.New("slack: socket mode connect timeout") + +// ListenOptions configures Listen. The zero value uses the defaults. +// +// OnConnect and OnError run on the goroutine that called Listen, so they must +// return quickly. +type ListenOptions struct { + // OnConnect is called with the hello message of each connection, when the + // connection is up. Nil is a no-op. + OnConnect func(Hello) + // OnError is called with each error that Listen recovers from: by a retry, + // or by a replacement of a connection. It is not called with the error + // that Listen returns. Nil is a no-op. + OnError func(error) + // PingTimeout is the longest time without a ping from Slack before Listen + // replaces a connection. Listen starts to check it after the first ping. + // 0 or less uses 30 seconds. + PingTimeout time.Duration + // MinBackoff is the wait before the first retry. The wait doubles with + // each failure in a row, up to MaxBackoff. 0 or less uses 1 second. + MinBackoff time.Duration + // MaxBackoff is the longest wait before a retry, and the wait after + // too_many_websockets. A connection that drops after it was up for at + // least MaxBackoff reconnects at once. 0 or less uses 1 minute. + MaxBackoff time.Duration +} + +func (o ListenOptions) withDefaults() ListenOptions { + if o.OnConnect == nil { + o.OnConnect = func(Hello) {} + } + if o.OnError == nil { + o.OnError = func(error) {} + } + if o.PingTimeout <= 0 { + o.PingTimeout = defaultPingTimeout + } + if o.MinBackoff <= 0 { + o.MinBackoff = defaultMinBackoff + } + if o.MaxBackoff <= 0 { + o.MaxBackoff = defaultMaxBackoff + } + o.MinBackoff = min(o.MinBackoff, o.MaxBackoff) + return o +} + +// backoff returns the wait before the next connect, after failures failed +// connects in a row. err is the last failure. The wait grows from MinBackoff +// to MaxBackoff, or is MaxBackoff after too_many_websockets, with equal +// jitter: a random value from half the wait to the full wait. A rate limit +// waits at least its RetryAfter. +func (o ListenOptions) backoff(failures int, err error) time.Duration { + d := ratelimit.BackoffDelay(o.MinBackoff, o.MaxBackoff, failures, 0) + if slackCode(err) == reasonTooManyWebsockets { + d = o.MaxBackoff + } + d = d/2 + rand.N(d/2+1) + if rl, ok := errors.AsType[*RateLimited](err); ok { + d = max(d, rl.RetryAfter) + } + return d +} + +// Hello is the hello message that starts a Socket Mode connection. +type Hello struct { + // NumConnections is the number of Socket Mode connections that the app + // has, this one included. + NumConnections int + // AppID is the ID of the app. + AppID string + // Host is the Slack host of the connection. It helps in logs. + Host string +} + +// DisconnectError is a disconnect message from Slack that Listen treats as an +// error. Listen returns it for the reason link_disabled (Socket Mode is off in +// the app settings). It gives it to ListenOptions.OnError for the reason +// too_many_websockets, and retries. +type DisconnectError struct { + // Reason is the reason in the message, for example "link_disabled". + Reason string +} + +// Error returns "slack: socket mode disconnect: ". +func (e *DisconnectError) Error() string { + return "slack: socket mode disconnect: " + e.Reason +} + +var _ error = (*DisconnectError)(nil) + +// slackCode returns the Slack error code of err: the code of an *APIError or +// the reason of a *DisconnectError. +func slackCode(err error) string { + if e, ok := errors.AsType[*APIError](err); ok { + return e.Code + } + if e, ok := errors.AsType[*DisconnectError](err); ok { + return e.Reason + } + return "" +} + +// fatal reports whether err is an error that a retry cannot fix: the token is +// not valid for Socket Mode, or Socket Mode is off. +func fatal(err error) bool { + switch slackCode(err) { + case "not_authed", "invalid_auth", "account_inactive", "token_revoked", "token_expired", + "user_removed_from_team", "team_disabled", "not_allowed_token_type", "missing_scope", + "link_disabled": + return true + } + return false +} + +// OpenConnection calls apps.connections.open with the token of c, which must +// be an app-level token (xapp-) with the connections:write scope. It returns +// the websocket URL for a new Socket Mode connection. Listen calls it for +// each connection. +func (c *Client) OpenConnection(ctx context.Context) (string, error) { + const method = "apps.connections.open" + var resp struct { + URL string `json:"url"` + } + if err := c.Call(ctx, method, url.Values{}, &resp); err != nil { + return "", err + } + if resp.URL == "" { + return "", fmt.Errorf("slack: %s returned no url", method) + } + return resp.URL, nil +} + +// Listen receives deliveries from Slack over Socket Mode, and gives each one +// to h in its own goroutine. c must send the app-level token (xapp-), for +// example api.WithToken(appToken). The HTTP client of c opens the websocket, +// so its proxy settings apply. +// +// Listen keeps one connection up. It acks each envelope on the connection +// that the envelope came on, as the result of h tells (see Handler). When +// Slack asks for a refresh, or sends no ping for ListenOptions.PingTimeout, +// Listen opens a new connection, and closes the old one only when the new one +// is up. The old connection keeps acking until it closes. A connection closes +// in this order: it waits at most 3 seconds for its running handlers, sends a +// close frame, and waits for its last message. Then the context of its +// handlers is cancelled. The context of a handler is not cancelled when ctx +// is done. +// +// Listen gives every other error to ListenOptions.OnError, and retries with +// backoff (see ListenOptions). It returns the error at once, and does not +// retry, when Slack rejects the token (an *APIError with a code such as +// invalid_auth or token_revoked) or turns Socket Mode off (a *DisconnectError +// with the reason link_disabled). When ctx is done, it returns nil. Before it +// returns, Listen closes every connection and waits for every handler. +// +// Listen logs connects, refreshes, and retries to the logger of c. +func (c *Client) Listen(ctx context.Context, h Handler, opts ListenOptions) error { + l := &listener{ + api: c, + handler: h, + opts: opts.withDefaults(), + events: make(chan connEvent), + stopped: make(chan struct{}), + } + err := l.run(ctx) + close(l.stopped) + l.wg.Wait() + return err +} + +// listener is the state of one Listen call. run owns the fields after wg. +type listener struct { + api *Client + handler Handler + opts ListenOptions + events chan connEvent + stopped chan struct{} + wg sync.WaitGroup + + active *socketConn + activeSince time.Time + replacing bool + pending *socketConn + retry <-chan time.Time + failures int +} + +// connEvent is a message from a connection to run: a hello, a disconnect +// message, or the end of the connection with err. +type connEvent struct { + conn *socketConn + hello *Hello + disconnect *DisconnectError + err error +} + +// run keeps a connection up until ctx is done or a fatal error. +func (l *listener) run(ctx context.Context) error { + defer func() { + for _, c := range []*socketConn{l.active, l.pending} { + if c != nil { + l.retire(c) + } + } + }() + for ctx.Err() == nil { + if (l.active == nil || l.replacing) && l.pending == nil && l.retry == nil { + l.connect(ctx) + } + var connectDone, silent <-chan struct{} + if l.pending != nil { + connectDone = l.pending.connectCtx.Done() + } + if l.active != nil && !l.replacing { + silent = l.active.silent + } + var err error + select { + case <-ctx.Done(): + case <-l.retry: + l.retry = nil + case <-connectDone: + if ctx.Err() == nil { + err = l.connectFailed(context.Cause(l.pending.connectCtx)) + } + case <-silent: + l.api.logger.Warn("slack socket mode ping timeout", "adapter", adapterName, "host", l.active.host) + l.opts.OnError(ErrPingTimeout) + l.replacing = true + case ev := <-l.events: + if ctx.Err() == nil { + err = l.handle(ev) + } + } + if err != nil { + return err + } + } + return nil +} + +func (l *listener) connect(ctx context.Context) { + c := newSocketConn(ctx, l) + l.pending = c + l.wg.Go(c.run) +} + +func (l *listener) handle(ev connEvent) error { + switch ev.conn { + case l.pending: + if ev.hello != nil { + l.promote(*ev.hello) + return nil + } + if ev.disconnect != nil { + return l.connectFailed(ev.disconnect) + } + return l.connectFailed(ev.err) + case l.active: + if ev.hello != nil { + return nil + } + if ev.disconnect != nil { + return l.disconnected(ev.disconnect) + } + healthy := l.settle() + l.drop() + if !healthy { + return l.failed(ev.err) + } + l.api.logger.Info("slack socket mode reconnect", "adapter", adapterName, "error", ev.err) + l.opts.OnError(ev.err) + } + return nil +} + +// promote makes the pending connection the active one, and retires the old +// active connection. +func (l *listener) promote(h Hello) { + l.settle() + c := l.pending + c.cancelConnect() + c.host = h.Host + l.pending = nil + if l.active != nil { + l.retire(l.active) + } + l.active, l.activeSince, l.replacing = c, time.Now(), false + l.api.logger.Info("slack socket mode connected", + "adapter", adapterName, "host", h.Host, "num_connections", h.NumConnections, "app_id", h.AppID) + l.opts.OnConnect(h) +} + +// disconnected handles a disconnect message on the active connection. +func (l *listener) disconnected(err *DisconnectError) error { + if fatal(err) || err.Reason == reasonTooManyWebsockets { + l.drop() + return l.failed(err) + } + l.api.logger.Info("slack socket mode refresh", "adapter", adapterName, "reason", err.Reason, "host", l.active.host) + l.replacing = true + return nil +} + +func (l *listener) connectFailed(err error) error { + l.retire(l.pending) + l.pending = nil + return l.failed(err) +} + +// failed returns err when it is fatal. Else it gives err to OnError and starts +// the backoff before the next connect. +func (l *listener) failed(err error) error { + if fatal(err) { + return err + } + l.settle() + l.failures++ + delay := l.opts.backoff(l.failures, err) + l.api.logger.Warn("slack socket mode retry", + "adapter", adapterName, "error", err, "failures", l.failures, "delay", delay) + l.opts.OnError(err) + l.retry = time.After(delay) + return nil +} + +// settle resets the failure count when the active connection has been up for +// at least MaxBackoff, so that only a connection that flaps backs off. It +// reports whether it did. +func (l *listener) settle() bool { + if l.active == nil || time.Since(l.activeSince) < l.opts.MaxBackoff { + return false + } + l.failures = 0 + return true +} + +// drop retires the active connection. +func (l *listener) drop() { + l.retire(l.active) + l.active = nil + l.replacing = false +} + +// retire closes c in the background. Listen retires each connection once. +func (l *listener) retire(c *socketConn) { + l.wg.Go(c.close) +} + +// socketConn is one Socket Mode connection. +type socketConn struct { + l *listener + connectCtx context.Context + cancelConnect context.CancelFunc + // ctx is the context of the handlers. cancel runs after the close. + ctx context.Context + cancel context.CancelFunc + // ws is set before dialed is closed. It stays nil when the dial fails. + ws *websocket.Conn + dialed chan struct{} + // done is closed when run returns. + done chan struct{} + // silent is closed when no ping came for PingTimeout. + silent chan struct{} + // host belongs to listener.run. + host string + + pingMu sync.Mutex + pingTimer *time.Timer + closing bool + + mu sync.Mutex + inflight int + idle chan struct{} +} + +func newSocketConn(ctx context.Context, l *listener) *socketConn { + connectCtx, cancelConnect := context.WithTimeoutCause(ctx, connectTimeout, errConnectTimeout) + handlerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + return &socketConn{ + l: l, + connectCtx: connectCtx, + cancelConnect: cancelConnect, + ctx: handlerCtx, + cancel: cancel, + dialed: make(chan struct{}), + done: make(chan struct{}), + silent: make(chan struct{}), + } +} + +func (c *socketConn) run() { + defer close(c.done) + ws, err := c.dial() + c.ws = ws + close(c.dialed) + if err != nil { + c.send(connEvent{conn: c, err: err}) + return + } + c.read() +} + +func (c *socketConn) dial() (*websocket.Conn, error) { + api := c.l.api + wsURL, err := api.OpenConnection(c.connectCtx) + if err != nil { + return nil, err + } + ws, _, err := websocket.Dial(c.connectCtx, wsURL, &websocket.DialOptions{ + HTTPClient: api.httpClient, + OnPingReceived: c.onPing, + }) + if err != nil { + return nil, fmt.Errorf("slack: socket mode dial: %w", err) + } + ws.SetReadLimit(maxPayloadBytes) + return ws, nil +} + +// socketMessage holds the fields of every message that Slack sends over +// Socket Mode. +type socketMessage struct { + Type string `json:"type"` + EnvelopeID string `json:"envelope_id"` + Payload json.RawMessage `json:"payload"` + RetryAttempt int `json:"retry_attempt"` + RetryReason string `json:"retry_reason"` + Reason string `json:"reason"` + NumConnections int `json:"num_connections"` + ConnectionInfo struct { + AppID string `json:"app_id"` + } `json:"connection_info"` + DebugInfo struct { + Host string `json:"host"` + } `json:"debug_info"` +} + +type socketAck struct { + EnvelopeID string `json:"envelope_id"` + Payload any `json:"payload,omitempty"` +} + +// read reads messages until the connection ends. Its context is never +// cancelled, because a cancelled read closes the connection without a close +// frame. Only Close ends it. +func (c *socketConn) read() { + logger := c.l.api.logger + for { + typ, data, err := c.ws.Read(context.Background()) + if err != nil { + c.send(connEvent{conn: c, err: fmt.Errorf("slack: socket mode connection closed: %w", err)}) + return + } + var msg socketMessage + if typ != websocket.MessageText || json.Unmarshal(data, &msg) != nil { + logger.Debug("slack socket mode message ignored: not JSON text", "adapter", adapterName) + continue + } + switch msg.Type { + case "hello": + c.send(connEvent{conn: c, hello: &Hello{ + NumConnections: msg.NumConnections, + AppID: msg.ConnectionInfo.AppID, + Host: msg.DebugInfo.Host, + }}) + case "disconnect": + c.send(connEvent{conn: c, disconnect: &DisconnectError{Reason: msg.Reason}}) + default: + if msg.EnvelopeID == "" { + logger.Debug("slack socket mode message ignored: no envelope_id", "adapter", adapterName, "type", msg.Type) + continue + } + c.begin() + c.l.wg.Go(func() { c.handle(msg) }) + } + } +} + +// handle runs the handler for an envelope, and acks it when the handler +// returns no error. +func (c *socketConn) handle(msg socketMessage) { + defer c.end() + logger := c.l.api.logger + resp, err := c.l.handler(c.ctx, &Delivery{ + Type: DeliveryType(msg.Type), + Payload: msg.Payload, + RetryAttempt: msg.RetryAttempt, + RetryReason: msg.RetryReason, + }) + if err != nil { + logger.Debug("slack socket mode envelope not acked", + "adapter", adapterName, "type", msg.Type, "envelope_id", msg.EnvelopeID, "error", err) + return + } + ack, err := json.Marshal(socketAck{EnvelopeID: msg.EnvelopeID, Payload: resp}) + if err != nil { + logger.Warn("slack socket mode envelope not acked: encode response", + "adapter", adapterName, "type", msg.Type, "envelope_id", msg.EnvelopeID, "error", err) + return + } + if err := c.ws.Write(c.ctx, websocket.MessageText, ack); err != nil { + logger.Debug("slack socket mode ack failed", + "adapter", adapterName, "type", msg.Type, "envelope_id", msg.EnvelopeID, "error", err) + } +} + +// send gives ev to listener.run, or drops it when Listen has stopped. +func (c *socketConn) send(ev connEvent) { + select { + case c.l.events <- ev: + case <-c.l.stopped: + } +} + +// onPing resets the ping timer. The first ping starts it. The timer fires at +// most once, because onPing resets it only when it has not fired. +func (c *socketConn) onPing(context.Context, []byte) bool { + c.pingMu.Lock() + defer c.pingMu.Unlock() + switch { + case c.closing: + case c.pingTimer == nil: + c.pingTimer = time.AfterFunc(c.l.opts.PingTimeout, func() { close(c.silent) }) + case c.pingTimer.Stop(): + c.pingTimer.Reset(c.l.opts.PingTimeout) + } + return true +} + +func (c *socketConn) begin() { + c.mu.Lock() + defer c.mu.Unlock() + if c.inflight == 0 { + c.idle = make(chan struct{}) + } + c.inflight++ +} + +func (c *socketConn) end() { + c.mu.Lock() + defer c.mu.Unlock() + c.inflight-- + if c.inflight == 0 { + close(c.idle) + } +} + +// close closes the connection: it waits for the running handlers, sends the +// close frame, waits for the read loop to end, and cancels the context of the +// handlers. +func (c *socketConn) close() { + c.cancelConnect() + c.pingMu.Lock() + c.closing = true + if c.pingTimer != nil { + c.pingTimer.Stop() + } + c.pingMu.Unlock() + <-c.dialed + if c.ws != nil { + c.drain() + if err := c.ws.Close(websocket.StatusNormalClosure, ""); err != nil { + c.l.api.logger.Debug("slack socket mode close failed", "adapter", adapterName, "error", err) + } + <-c.done + } + c.cancel() +} + +// drain waits until no handler runs, the read loop ends, or drainTimeout +// passes. Envelopes that come during the wait also run, and drain waits for +// them too. +func (c *socketConn) drain() { + timer := time.NewTimer(drainTimeout) + defer timer.Stop() + for { + c.mu.Lock() + inflight, idle := c.inflight, c.idle + c.mu.Unlock() + if inflight == 0 { + return + } + select { + case <-idle: + case <-c.done: + return + case <-timer.C: + return + } + } +} diff --git a/adapters/slack/slackapi/socketmode_internal_test.go b/adapters/slack/slackapi/socketmode_internal_test.go new file mode 100644 index 0000000..a6bb093 --- /dev/null +++ b/adapters/slack/slackapi/socketmode_internal_test.go @@ -0,0 +1,60 @@ +package slackapi + +import ( + "errors" + "testing" + "time" +) + +func TestListenBackoff(t *testing.T) { + t.Parallel() + + opts := ListenOptions{MinBackoff: time.Second, MaxBackoff: 10 * time.Second}.withDefaults() + dropped := errors.New("connection closed") + tests := []struct { + name string + opts ListenOptions + failures int + err error + min, max time.Duration + }{ + {name: "first failure", opts: opts, failures: 1, err: dropped, min: 500 * time.Millisecond, max: time.Second}, + {name: "third failure", opts: opts, failures: 3, err: dropped, min: 2 * time.Second, max: 4 * time.Second}, + {name: "capped", opts: opts, failures: 5, err: dropped, min: 5 * time.Second, max: 10 * time.Second}, + {name: "many failures", opts: opts, failures: 1000, err: dropped, min: 5 * time.Second, max: 10 * time.Second}, + { + name: "too_many_websockets disconnect", opts: opts, failures: 1, + err: &DisconnectError{Reason: "too_many_websockets"}, min: 5 * time.Second, max: 10 * time.Second, + }, + { + name: "too_many_websockets API error", opts: opts, failures: 1, + err: &APIError{Code: "too_many_websockets"}, min: 5 * time.Second, max: 10 * time.Second, + }, + { + name: "Retry-After above the backoff", opts: opts, failures: 1, + err: &RateLimited{RetryAfter: 30 * time.Second}, min: 30 * time.Second, max: 30 * time.Second, + }, + { + name: "Retry-After below the backoff", opts: opts, failures: 3, + err: &RateLimited{RetryAfter: 100 * time.Millisecond}, min: 2 * time.Second, max: 4 * time.Second, + }, + { + name: "MinBackoff above MaxBackoff", opts: ListenOptions{MinBackoff: time.Hour, MaxBackoff: time.Second}.withDefaults(), + failures: 1, err: dropped, min: 500 * time.Millisecond, max: time.Second, + }, + { + name: "defaults", opts: ListenOptions{MinBackoff: -1}.withDefaults(), + failures: 1, err: dropped, min: 500 * time.Millisecond, max: time.Second, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + for range 100 { + if got := tt.opts.backoff(tt.failures, tt.err); got < tt.min || got > tt.max { + t.Fatalf("backoff(%d, %v) = %s, want from %s to %s", tt.failures, tt.err, got, tt.min, tt.max) + } + } + }) + } +} diff --git a/adapters/slack/slackapi/socketmode_test.go b/adapters/slack/slackapi/socketmode_test.go new file mode 100644 index 0000000..a7ae98a --- /dev/null +++ b/adapters/slack/slackapi/socketmode_test.go @@ -0,0 +1,742 @@ +package slackapi_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/coder/chat/adapters/slack/slackapi" + "github.com/coder/chat/adapters/slack/slackapi/slackapitest" +) + +const ( + socketAppToken = "xapp-socket" + socketWait = 5 * time.Second +) + +// socketClient returns a client for srv that sends the app-level token. +func socketClient(srv *slackapitest.Server, policy slackapi.RetryPolicy) *slackapi.Client { + return coreNewClient(srv, slackapi.Options{Token: socketAppToken, RetryPolicy: policy}) +} + +// fastListen returns ListenOptions with short backoffs for tests. +func fastListen() slackapi.ListenOptions { + return slackapi.ListenOptions{MinBackoff: time.Millisecond, MaxBackoff: 10 * time.Millisecond} +} + +func ackAll(context.Context, *slackapi.Delivery) (any, error) { return nil, nil } + +// sendTo returns a callback that sends to ch, and drops the value when ch is +// full, so that a callback never blocks Listen. +func sendTo[T any](ch chan T) func(T) { + return func(v T) { + select { + case ch <- v: + default: + } + } +} + +func recv[T any](t *testing.T, ch <-chan T) T { + t.Helper() + select { + case v := <-ch: + return v + case <-time.After(socketWait): + t.Fatalf("timed out waiting for a %T", *new(T)) + panic("unreachable") + } +} + +func waitFor(t *testing.T, srv *slackapitest.Server, what string, cond func() bool) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), socketWait) + defer cancel() + if err := srv.Wait(ctx, cond); err != nil { + t.Fatalf("wait for %s: %v", what, err) + } +} + +// handleOpens makes srv answer apps.connections.open with answer, which gets +// the number of the call from 1. It returns a function that lists the times +// of the calls. +func handleOpens(srv *slackapitest.Server, answer func(n int, call slackapitest.Call) any) func() []time.Time { + var mu sync.Mutex + var times []time.Time + srv.Handle("apps.connections.open", func(call slackapitest.Call) any { + mu.Lock() + times = append(times, time.Now()) + n := len(times) + mu.Unlock() + return answer(n, call) + }) + return func() []time.Time { + mu.Lock() + defer mu.Unlock() + return slices.Clone(times) + } +} + +// listening is a Listen call that runs in a goroutine until the test ends. +type listening struct { + cancel context.CancelFunc + done chan struct{} + err error +} + +func startListen(t *testing.T, api *slackapi.Client, h slackapi.Handler, opts slackapi.ListenOptions) *listening { + t.Helper() + ctx, cancel := context.WithCancel(t.Context()) + l := &listening{cancel: cancel, done: make(chan struct{})} + go func() { + defer close(l.done) + l.err = api.Listen(ctx, h, opts) + }() + t.Cleanup(func() { + cancel() + select { + case <-l.done: + case <-time.After(socketWait): + t.Errorf("Listen did not return after its context ended") + } + }) + return l +} + +// wait waits for Listen to return, and returns its error. +func (l *listening) wait(t *testing.T) error { + t.Helper() + select { + case <-l.done: + return l.err + case <-time.After(socketWait): + t.Fatalf("Listen did not return") + return nil + } +} + +func (l *listening) stop(t *testing.T) error { + t.Helper() + l.cancel() + return l.wait(t) +} + +// requireCloseFrames checks that the client closed each connection that the +// fake did not drop, with a close frame and the normal status. +func requireCloseFrames(t *testing.T, sm *slackapitest.SocketMode) { + t.Helper() + for _, c := range sm.Conns() { + if c.Dropped { + continue + } + c = sm.WaitClosed(t, socketWait, c.ID) + if !c.CloseFrame || c.CloseCode != websocket.StatusNormalClosure { + t.Errorf("connection %d: CloseFrame = %v, CloseCode = %v, want a close frame with %v", + c.ID, c.CloseFrame, c.CloseCode, websocket.StatusNormalClosure) + } + } +} + +func requireNoError(t *testing.T, errs <-chan error) { + t.Helper() + select { + case err := <-errs: + t.Errorf("OnError(%v), want no call", err) + default: + } +} + +func TestOpenConnection(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) + api := socketClient(srv, slackapi.RetryPolicy{}) + + wsURL, err := api.OpenConnection(t.Context()) + if err != nil { + t.Fatalf("OpenConnection: %v", err) + } + if !strings.HasPrefix(wsURL, "ws://") { + t.Errorf("URL = %q, want a ws:// URL", wsURL) + } + calls := srv.Calls("apps.connections.open") + if len(calls) != 1 { + t.Fatalf("apps.connections.open calls = %d, want 1", len(calls)) + } + if got, want := calls[0].Header.Get("Authorization"), "Bearer "+socketAppToken; got != want { + t.Errorf("Authorization = %q, want %q", got, want) + } + if calls[0].Form == nil { + t.Errorf("request body %q is not form encoded", calls[0].Body) + } + + _, err = api.WithToken("xapp-other").OpenConnection(t.Context()) + if apiErr, ok := errors.AsType[*slackapi.APIError](err); !ok || apiErr.Code != "invalid_auth" { + t.Errorf("OpenConnection with another token: err = %v, want an *APIError with code invalid_auth", err) + } +} + +func TestOpenConnectionNoURL(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.Respond("apps.connections.open", map[string]any{"ok": true}) + if _, err := socketClient(srv, slackapi.RetryPolicy{}).OpenConnection(t.Context()); err == nil { + t.Fatal("OpenConnection: err = nil, want an error for a response with no url") + } +} + +func TestListenConnects(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, AppID: "A42"}) + hellos := make(chan slackapi.Hello, 1) + opts := fastListen() + opts.OnConnect = sendTo(hellos) + l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + + want := slackapi.Hello{NumConnections: 1, AppID: "A42", Host: "applink-1"} + if got := recv(t, hellos); got != want { + t.Errorf("OnConnect(%+v), want %+v", got, want) + } + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) +} + +func TestListenAcksOnTheSameConnection(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, ResendAfter: -1}) + deliveries := make(chan slackapi.Delivery, 10) + h := func(_ context.Context, d *slackapi.Delivery) (any, error) { + deliveries <- *d + if d.Type == slackapi.DeliverySlashCommands { + return map[string]string{"text": "pong"}, nil + } + return nil, nil + } + api := socketClient(srv, slackapi.RetryPolicy{}) + hellos := make(chan slackapi.Hello, 2) + opts := fastListen() + opts.OnConnect = sendTo(hellos) + first := startListen(t, api, h, opts) + second := startListen(t, api, h, opts) + recv(t, hellos) + recv(t, hellos) + + tests := []struct { + typ string + payload string + wantAck string + }{ + {typ: "slash_commands", payload: `{"command":"/ping","text":"hi"}`, wantAck: `{"text":"pong"}`}, + {typ: "events_api", payload: `{"type":"event_callback","n":1}`}, + {typ: "interactive", payload: `{"type":"block_actions"}`}, + {typ: "events_api", payload: `{"type":"event_callback","n":2}`}, + {typ: "some_future_type", payload: `{"n":3}`}, + {typ: "slash_commands", payload: `{"command":"/ping","text":"again"}`, wantAck: `{"text":"pong"}`}, + } + for _, tt := range tests { + id := sm.Send(tt.typ, json.RawMessage(tt.payload)) + env := sm.WaitAcked(t, socketWait, id) + d := recv(t, deliveries) + if d.Type != slackapi.DeliveryType(tt.typ) || string(d.Payload) != tt.payload || d.RetryAttempt != 0 || d.RetryReason != "" { + t.Errorf("%s: handler got %+v, want type %q and payload %s", id, d, tt.typ, tt.payload) + } + if len(env.Acks) != 1 { + t.Fatalf("%s: acks = %+v, want 1", id, env.Acks) + } + if ack, delivery := env.Acks[0], env.Deliveries[0]; ack.Conn != delivery.Conn { + t.Errorf("%s: ack on connection %d, delivery on connection %d", id, ack.Conn, delivery.Conn) + } + if got := string(env.Acks[0].Payload); got != tt.wantAck { + t.Errorf("%s: ack payload = %s, want %q", id, got, tt.wantAck) + } + } + + for _, l := range []*listening{first, second} { + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + } + requireCloseFrames(t, sm) +} + +func TestListenHandlerErrorSendsNoAck(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, ResendAfter: 50 * time.Millisecond}) + failures := map[string]error{ + "busy": slackapi.ErrBusy, + "invalid": fmt.Errorf("bad command: %w", slackapi.ErrInvalidPayload), + "other": errors.New("store down"), + } + retries := make(chan slackapi.Delivery, 10) + h := func(_ context.Context, d *slackapi.Delivery) (any, error) { + if d.RetryAttempt == 0 { + var p struct { + Case string `json:"case"` + } + if err := json.Unmarshal(d.Payload, &p); err != nil { + return nil, err + } + return nil, failures[p.Case] + } + retries <- *d + return nil, nil + } + l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), h, fastListen()) + sm.WaitLive(t, socketWait, 1) + + var ids []string + for name := range failures { + ids = append(ids, sm.Send("events_api", map[string]string{"case": name})) + } + for _, id := range ids { + env := sm.WaitAcked(t, socketWait, id) + if len(env.Deliveries) < 2 { + t.Fatalf("%s: deliveries = %+v, want a resend", id, env.Deliveries) + } + resend := env.Deliveries[1] + if resend.RetryAttempt != 1 || resend.RetryReason != "timeout" { + t.Errorf("%s: resend = %+v, want retry attempt 1 with reason timeout", id, resend) + } + if ack := env.Acks[0]; ack.Seq < resend.Seq { + t.Errorf("%s: ack %+v came before the resend %+v, want no ack for the failed delivery", id, ack, resend) + } + } + for range ids { + if d := recv(t, retries); d.RetryAttempt < 1 || d.RetryReason != "timeout" { + t.Errorf("handler got retry attempt %d with reason %q, want a resend with reason timeout", d.RetryAttempt, d.RetryReason) + } + } + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) +} + +func TestListenRefresh(t *testing.T) { + t.Parallel() + + for _, reason := range []string{"warning", "refresh_requested", "a_future_reason"} { + t.Run(reason, func(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, ResendAfter: -1}) + gate := make(chan struct{}) + release := sync.OnceFunc(func() { close(gate) }) + t.Cleanup(release) + opens := handleOpens(srv, func(n int, call slackapitest.Call) any { + if n == 2 { + <-gate + } + return sm.Open(call) + }) + errs := make(chan error, 10) + opts := fastListen() + opts.OnError = sendTo(errs) + l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + sm.WaitLive(t, socketWait, 1) + + sm.DisconnectConn(1, reason) + waitFor(t, srv, "the replacement connect", func() bool { return len(opens()) == 2 }) + during := sm.Send("events_api", map[string]string{"n": "during"}) + if env := sm.WaitAcked(t, socketWait, during); env.Acks[0].Conn != 1 { + t.Errorf("envelope during the refresh acked on connection %d, want 1", env.Acks[0].Conn) + } + + release() + old := sm.WaitClosed(t, socketWait, 1) + conns := sm.Conns() + if len(conns) != 2 || conns[1].HelloSeq == 0 || conns[1].HelloSeq > old.CloseSeq { + t.Fatalf("connections = %+v, want connection 2 up before connection 1 closed", conns) + } + after := sm.Send("events_api", map[string]string{"n": "after"}) + if env := sm.WaitAcked(t, socketWait, after); env.Acks[0].Conn != 2 { + t.Errorf("envelope after the refresh acked on connection %d, want 2", env.Acks[0].Conn) + } + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) + requireNoError(t, errs) + }) + } +} + +func TestListenPingTimeout(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, PingInterval: 5 * time.Millisecond}) + errs := make(chan error, 10) + opts := fastListen() + opts.PingTimeout = 100 * time.Millisecond + opts.OnError = sendTo(errs) + l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + sm.WaitLive(t, socketWait, 1) + waitFor(t, srv, "a ping on connection 1", func() bool { return sm.Conns()[0].Pings > 0 }) + + sm.StopPings(1) + if err := recv(t, errs); !errors.Is(err, slackapi.ErrPingTimeout) { + t.Errorf("OnError(%v), want ErrPingTimeout", err) + } + old := sm.WaitClosed(t, socketWait, 1) + if conns := sm.Conns(); len(conns) < 2 || conns[1].HelloSeq == 0 || conns[1].HelloSeq > old.CloseSeq { + t.Fatalf("connections = %+v, want connection 2 up before connection 1 closed", conns) + } + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) +} + +func TestListenFatalErrors(t *testing.T) { + t.Parallel() + + requireReturns := func(t *testing.T, srv *slackapitest.Server, l *listening, errs <-chan error, want string) { + t.Helper() + err := l.wait(t) + if got := slackCodeOf(err); got != want { + t.Errorf("Listen: err = %v, want code %s", err, want) + } + if n := len(srv.Calls("apps.connections.open")); n != 1 { + t.Errorf("apps.connections.open calls = %d, want 1", n) + } + requireNoError(t, errs) + } + + t.Run("invalid_auth", func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + srv.SocketMode(slackapitest.SocketModeOptions{AppToken: "xapp-other"}) + errs := make(chan error, 10) + opts := fastListen() + opts.OnError = sendTo(errs) + l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + requireReturns(t, srv, l, errs, "invalid_auth") + }) + + for _, code := range []string{ + "not_authed", "account_inactive", "token_revoked", "token_expired", "user_removed_from_team", + "team_disabled", "not_allowed_token_type", "missing_scope", + } { + t.Run(code, func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + srv.Respond("apps.connections.open", map[string]any{"ok": false, "error": code}) + errs := make(chan error, 10) + opts := fastListen() + opts.OnError = sendTo(errs) + l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + requireReturns(t, srv, l, errs, code) + }) + } + + t.Run("link_disabled", func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) + errs := make(chan error, 10) + opts := fastListen() + opts.OnError = sendTo(errs) + l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + sm.WaitLive(t, socketWait, 1) + + sm.Disconnect("link_disabled") + requireReturns(t, srv, l, errs, "link_disabled") + if _, ok := errors.AsType[*slackapi.DisconnectError](l.err); !ok { + t.Errorf("Listen: err = %T, want *DisconnectError", l.err) + } + requireCloseFrames(t, sm) + }) +} + +// slackCodeOf returns the code of an *APIError or the reason of a +// *DisconnectError. +func slackCodeOf(err error) string { + if e, ok := errors.AsType[*slackapi.APIError](err); ok { + return e.Code + } + if e, ok := errors.AsType[*slackapi.DisconnectError](err); ok { + return e.Reason + } + return "" +} + +func TestListenTooManyWebsockets(t *testing.T) { + t.Parallel() + + const maxBackoff = 200 * time.Millisecond + type stamped struct { + err error + at time.Time + } + stampErrors := func(ch chan stamped) func(error) { + send := sendTo(ch) + return func(err error) { send(stamped{err: err, at: time.Now()}) } + } + + t.Run("disconnect", func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, MaxConnections: 1}) + opens := handleOpens(srv, func(_ int, call slackapitest.Call) any { return sm.Open(call) }) + api := socketClient(srv, slackapi.RetryPolicy{}) + hellos := make(chan slackapi.Hello, 1) + holderOpts := fastListen() + holderOpts.OnConnect = sendTo(hellos) + holder := startListen(t, api, ackAll, holderOpts) + recv(t, hellos) + + errs := make(chan stamped, 10) + extra := startListen(t, api, ackAll, slackapi.ListenOptions{ + MinBackoff: time.Millisecond, + MaxBackoff: maxBackoff, + OnError: stampErrors(errs), + }) + first := recv(t, errs) + if got := slackCodeOf(first.err); got != "too_many_websockets" { + t.Fatalf("OnError(%v), want too_many_websockets", first.err) + } + if rejected := sm.WaitClosed(t, socketWait, 2); !rejected.Rejected { + t.Errorf("connection 2 = %+v, want a rejected connection", rejected) + } + // Wait for the rejection of the retry, so that no dial runs when + // Listen stops. + if got := slackCodeOf(recv(t, errs).err); got != "too_many_websockets" { + t.Fatalf("second OnError code = %q, want too_many_websockets", got) + } + if wait := opens()[2].Sub(first.at); wait < maxBackoff/2 { + t.Errorf("retry after %s, want at least %s", wait, maxBackoff/2) + } + + for _, l := range []*listening{extra, holder} { + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + } + requireCloseFrames(t, sm) + }) + + t.Run("api error", func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) + opens := handleOpens(srv, func(n int, call slackapitest.Call) any { + if n == 1 { + return map[string]any{"ok": false, "error": "too_many_websockets"} + } + return sm.Open(call) + }) + errs := make(chan stamped, 10) + hellos := make(chan slackapi.Hello, 1) + l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, slackapi.ListenOptions{ + MinBackoff: time.Millisecond, + MaxBackoff: maxBackoff, + OnConnect: sendTo(hellos), + OnError: stampErrors(errs), + }) + recv(t, hellos) + if got := slackCodeOf(recv(t, errs).err); got != "too_many_websockets" { + t.Errorf("OnError code = %q, want too_many_websockets", got) + } + if times := opens(); times[1].Sub(times[0]) < maxBackoff/2 { + t.Errorf("retry after %s, want at least %s", times[1].Sub(times[0]), maxBackoff/2) + } + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) + }) +} + +func TestListenHonorsRetryAfter(t *testing.T) { + t.Parallel() + + const retryAfter = 300 * time.Millisecond + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) + opens := handleOpens(srv, func(n int, call slackapitest.Call) any { + if n == 1 { + return slackapitest.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": {"0.3"}}, + } + } + return sm.Open(call) + }) + errs := make(chan error, 10) + hellos := make(chan slackapi.Hello, 1) + opts := fastListen() + opts.OnConnect = sendTo(hellos) + opts.OnError = sendTo(errs) + l := startListen(t, socketClient(srv, slackapi.RetryPolicy{MaxAttempts: 1}), ackAll, opts) + recv(t, hellos) + + if rl, ok := errors.AsType[*slackapi.RateLimited](recv(t, errs)); !ok || rl.RetryAfter != retryAfter { + t.Errorf("OnError(%v), want *RateLimited with RetryAfter %s", rl, retryAfter) + } + if times := opens(); times[1].Sub(times[0]) < retryAfter { + t.Errorf("retry after %s, want at least the Retry-After of %s", times[1].Sub(times[0]), retryAfter) + } + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) +} + +func TestListenReconnectsAfterDrop(t *testing.T) { + t.Parallel() + + const minBackoff = 100 * time.Millisecond + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) + opens := handleOpens(srv, func(_ int, call slackapitest.Call) any { return sm.Open(call) }) + errs := make(chan error, 10) + hellos := make(chan slackapi.Hello, 10) + l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, slackapi.ListenOptions{ + OnConnect: sendTo(hellos), + OnError: sendTo(errs), + MinBackoff: minBackoff, + MaxBackoff: time.Minute, + }) + recv(t, hellos) + + dropped := time.Now() + sm.Drop(1) + if err := recv(t, errs); err == nil { + t.Error("OnError(nil), want the error of the dropped connection") + } + if h := recv(t, hellos); h.Host != "applink-2" { + t.Errorf("OnConnect(%+v), want connection 2", h) + } + // Connection 1 was up for less than MaxBackoff, so its drop counts as a + // failure and the reconnect backs off. + if wait := opens()[1].Sub(dropped); wait < minBackoff/2 { + t.Errorf("reconnect after %s, want at least %s", wait, minBackoff/2) + } + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) +} + +func TestListenStopDrainsHandlers(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, ResendAfter: -1}) + started := make(chan context.Context, 1) + release := make(chan struct{}) + ctxErrs := make(chan error, 1) + h := func(ctx context.Context, d *slackapi.Delivery) (any, error) { + if string(d.Payload) == `{"n":"slow"}` { + started <- ctx + <-release + ctxErrs <- ctx.Err() + } + return nil, nil + } + l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), h, fastListen()) + sm.WaitLive(t, socketWait, 1) + slow := sm.Send("events_api", json.RawMessage(`{"n":"slow"}`)) + handlerCtx := recv(t, started) + + l.cancel() + fast := sm.Send("events_api", json.RawMessage(`{"n":"fast"}`)) + if env := sm.WaitAcked(t, socketWait, fast); env.Acks[0].Conn != 1 { + t.Errorf("envelope during the drain acked on connection %d, want 1", env.Acks[0].Conn) + } + if c := sm.Conns()[0]; c.CloseSeq != 0 { + t.Fatalf("connection 1 = %+v, want it open while a handler runs", c) + } + + close(release) + if err := l.wait(t); err != nil { + t.Fatalf("Listen: %v", err) + } + if err := recv(t, ctxErrs); err != nil { + t.Errorf("handler context error = %v before the close, want nil", err) + } + if handlerCtx.Err() == nil { + t.Error("handler context is not cancelled after Listen returned") + } + closed := sm.WaitClosed(t, socketWait, 1) + if env := sm.Envelope(slow); len(env.Acks) != 1 || env.Acks[0].Seq > closed.CloseSeq { + t.Errorf("slow envelope acks = %+v, close seq %d: want an ack before the close", env.Acks, closed.CloseSeq) + } + requireCloseFrames(t, sm) +} + +func TestListenUsesTheProxyOfTheHTTPClient(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) + var mu sync.Mutex + var proxied []string + proxy := httptest.NewServer(&httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + mu.Lock() + proxied = append(proxied, pr.In.URL.Path+" "+pr.In.Header.Get("Upgrade")) + mu.Unlock() + pr.Out.Host = pr.In.Host + }, + }) + t.Cleanup(proxy.Close) + proxyURL, err := url.Parse(proxy.URL) + if err != nil { + t.Fatal(err) + } + transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)} + t.Cleanup(transport.CloseIdleConnections) + api := slackapi.New(slackapi.Options{ + Token: socketAppToken, + BaseURL: srv.URL(), + HTTPClient: &http.Client{Transport: transport}, + }) + hellos := make(chan slackapi.Hello, 1) + opts := fastListen() + opts.OnConnect = sendTo(hellos) + l := startListen(t, api, ackAll, opts) + recv(t, hellos) + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) + + mu.Lock() + defer mu.Unlock() + for _, want := range []string{"/api/apps.connections.open ", "/link/ websocket"} { + if !slices.Contains(proxied, want) { + t.Errorf("proxied requests = %q, want %q", proxied, want) + } + } +} diff --git a/examples/slack-nats-state/go.mod b/examples/slack-nats-state/go.mod index 1ac159a..682c1c4 100644 --- a/examples/slack-nats-state/go.mod +++ b/examples/slack-nats-state/go.mod @@ -9,6 +9,7 @@ require ( ) require ( + github.com/coder/websocket v1.8.15 // indirect github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/examples/slack-postgres-state/go.mod b/examples/slack-postgres-state/go.mod index fbed8d6..3bf78dd 100644 --- a/examples/slack-postgres-state/go.mod +++ b/examples/slack-postgres-state/go.mod @@ -9,6 +9,7 @@ require ( ) require ( + github.com/coder/websocket v1.8.15 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/examples/slack-redis-state/go.mod b/examples/slack-redis-state/go.mod index e348d55..d9e0426 100644 --- a/examples/slack-redis-state/go.mod +++ b/examples/slack-redis-state/go.mod @@ -10,6 +10,7 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.15 // indirect go.uber.org/atomic v1.12.0 // indirect golang.org/x/sys v0.48.0 // indirect ) From c0a927a0d3ba7a8e0ae9d639707936b2581e22fb Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 04:10:59 +0000 Subject: [PATCH 08/33] fix(adapters/slack/slackapi): back off Socket Mode reconnects as the contract says - Reset the failure count at most once for each connection: when it drops, gets a disconnect message, or starts its replacement, and only when it was up for at least MaxBackoff. Before, every failed connect of a replacement reset the count, so the backoff never grew past MinBackoff. - A drop of a connection that is being replaced adds no failure, because the replacement already runs. - Call apps.connections.open with no retry of the Client. The default retry policy caps a wait at 1 second, so it retried before Retry-After. - Say in the ListenOptions docs that each wait has equal jitter. - Test the growing backoff during a replacement, the reconnect at once after a long-lived connection drops, too_many_websockets after hello, and Retry-After with the default retry policy. --- adapters/slack/slackapi/socketmode.go | 67 ++++-- adapters/slack/slackapi/socketmode_test.go | 226 ++++++++++++++++----- 2 files changed, 223 insertions(+), 70 deletions(-) diff --git a/adapters/slack/slackapi/socketmode.go b/adapters/slack/slackapi/socketmode.go index a16b2f5..4650ad5 100644 --- a/adapters/slack/slackapi/socketmode.go +++ b/adapters/slack/slackapi/socketmode.go @@ -52,12 +52,16 @@ type ListenOptions struct { // replaces a connection. Listen starts to check it after the first ping. // 0 or less uses 30 seconds. PingTimeout time.Duration - // MinBackoff is the wait before the first retry. The wait doubles with - // each failure in a row, up to MaxBackoff. 0 or less uses 1 second. + // MinBackoff is the backoff before the first retry. The backoff doubles + // with each failure in a row, up to MaxBackoff. Each wait is a random + // value from half the backoff to the full backoff. 0 or less uses 1 + // second. MinBackoff time.Duration - // MaxBackoff is the longest wait before a retry, and the wait after - // too_many_websockets. A connection that drops after it was up for at - // least MaxBackoff reconnects at once. 0 or less uses 1 minute. + // MaxBackoff is the largest backoff, and the backoff after + // too_many_websockets. Each wait is a random value from half the backoff + // to the full backoff. A connection that was up for at least MaxBackoff + // starts the backoff again from MinBackoff, and reconnects at once when + // it drops. 0 or less uses 1 minute. MaxBackoff time.Duration } @@ -183,16 +187,22 @@ func (c *Client) OpenConnection(ctx context.Context) (string, error) { // is done. // // Listen gives every other error to ListenOptions.OnError, and retries with -// backoff (see ListenOptions). It returns the error at once, and does not -// retry, when Slack rejects the token (an *APIError with a code such as -// invalid_auth or token_revoked) or turns Socket Mode off (a *DisconnectError -// with the reason link_disabled). When ctx is done, it returns nil. Before it -// returns, Listen closes every connection and waits for every handler. +// backoff (see ListenOptions). A rate limit waits at least its Retry-After. +// Listen does not use the retry policy of c: its own backoff retries +// apps.connections.open. It returns the error at once, and does not retry, +// when Slack rejects the token (an *APIError with a code such as invalid_auth +// or token_revoked) or turns Socket Mode off (a *DisconnectError with the +// reason link_disabled). When ctx is done, it returns nil. Before it returns, +// Listen closes every connection and waits for every handler. // // Listen logs connects, refreshes, and retries to the logger of c. func (c *Client) Listen(ctx context.Context, h Handler, opts ListenOptions) error { + // The retry policy caps a wait at MaxDelay, so it can retry before the + // Retry-After of a rate limit. The backoff of Listen does not. + api := *c + api.retryPolicy = RetryPolicy{MaxAttempts: 1}.withDefaults() l := &listener{ - api: c, + api: &api, handler: h, opts: opts.withDefaults(), events: make(chan connEvent), @@ -262,7 +272,7 @@ func (l *listener) run(ctx context.Context) error { case <-silent: l.api.logger.Warn("slack socket mode ping timeout", "adapter", adapterName, "host", l.active.host) l.opts.OnError(ErrPingTimeout) - l.replacing = true + l.replace() case ev := <-l.events: if ctx.Err() == nil { err = l.handle(ev) @@ -299,9 +309,11 @@ func (l *listener) handle(ev connEvent) error { if ev.disconnect != nil { return l.disconnected(ev.disconnect) } - healthy := l.settle() + // No backoff when a replacement already runs, or when the connection + // was healthy. + again := l.replacing || l.settle() l.drop() - if !healthy { + if !again { return l.failed(ev.err) } l.api.logger.Info("slack socket mode reconnect", "adapter", adapterName, "error", ev.err) @@ -313,7 +325,6 @@ func (l *listener) handle(ev connEvent) error { // promote makes the pending connection the active one, and retires the old // active connection. func (l *listener) promote(h Hello) { - l.settle() c := l.pending c.cancelConnect() c.host = h.Host @@ -330,14 +341,22 @@ func (l *listener) promote(h Hello) { // disconnected handles a disconnect message on the active connection. func (l *listener) disconnected(err *DisconnectError) error { if fatal(err) || err.Reason == reasonTooManyWebsockets { + l.settle() l.drop() return l.failed(err) } l.api.logger.Info("slack socket mode refresh", "adapter", adapterName, "reason", err.Reason, "host", l.active.host) - l.replacing = true + l.replace() return nil } +// replace starts a replacement of the active connection. The active +// connection keeps reading and acking until the replacement is up. +func (l *listener) replace() { + l.settle() + l.replacing = true +} + func (l *listener) connectFailed(err error) error { l.retire(l.pending) l.pending = nil @@ -350,7 +369,6 @@ func (l *listener) failed(err error) error { if fatal(err) { return err } - l.settle() l.failures++ delay := l.opts.backoff(l.failures, err) l.api.logger.Warn("slack socket mode retry", @@ -360,11 +378,14 @@ func (l *listener) failed(err error) error { return nil } -// settle resets the failure count when the active connection has been up for -// at least MaxBackoff, so that only a connection that flaps backs off. It -// reports whether it did. +// settle resets the failure count when the active connection was up for at +// least MaxBackoff, so that only a connection that flaps backs off. It runs +// once for each active connection, when the connection stops being good: it +// drops, Slack disconnects it, or a replacement starts. During a replacement +// it does nothing, so the failures of the replacement add up. It reports +// whether it reset the count. func (l *listener) settle() bool { - if l.active == nil || time.Since(l.activeSince) < l.opts.MaxBackoff { + if l.active == nil || l.replacing || time.Since(l.activeSince) < l.opts.MaxBackoff { return false } l.failures = 0 @@ -443,6 +464,10 @@ func (c *socketConn) dial() (*websocket.Conn, error) { if err != nil { return nil, err } + // When Listen stops during the handshake, the cancel closes the TCP + // connection with no close frame. Listen accepts this short window. A + // handshake that it cannot cancel could delay its return by up to + // connectTimeout. ws, _, err := websocket.Dial(c.connectCtx, wsURL, &websocket.DialOptions{ HTTPClient: api.httpClient, OnPingReceived: c.onPing, diff --git a/adapters/slack/slackapi/socketmode_test.go b/adapters/slack/slackapi/socketmode_test.go index a7ae98a..df1a51d 100644 --- a/adapters/slack/slackapi/socketmode_test.go +++ b/adapters/slack/slackapi/socketmode_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "net/http/httputil" "net/url" + "reflect" "slices" "strings" "sync" @@ -26,9 +27,10 @@ const ( socketWait = 5 * time.Second ) -// socketClient returns a client for srv that sends the app-level token. -func socketClient(srv *slackapitest.Server, policy slackapi.RetryPolicy) *slackapi.Client { - return coreNewClient(srv, slackapi.Options{Token: socketAppToken, RetryPolicy: policy}) +// socketClient returns a client for srv that sends the app-level token, with +// the default retry policy. +func socketClient(srv *slackapitest.Server) *slackapi.Client { + return coreNewClient(srv, slackapi.Options{Token: socketAppToken}) } // fastListen returns ListenOptions with short backoffs for tests. @@ -55,7 +57,7 @@ func recv[T any](t *testing.T, ch <-chan T) T { case v := <-ch: return v case <-time.After(socketWait): - t.Fatalf("timed out waiting for a %T", *new(T)) + t.Fatalf("timed out waiting for a %v", reflect.TypeFor[T]()) panic("unreachable") } } @@ -163,7 +165,7 @@ func TestOpenConnection(t *testing.T) { srv := slackapitest.NewServer(t) srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) - api := socketClient(srv, slackapi.RetryPolicy{}) + api := socketClient(srv) wsURL, err := api.OpenConnection(t.Context()) if err != nil { @@ -194,7 +196,7 @@ func TestOpenConnectionNoURL(t *testing.T) { srv := slackapitest.NewServer(t) srv.Respond("apps.connections.open", map[string]any{"ok": true}) - if _, err := socketClient(srv, slackapi.RetryPolicy{}).OpenConnection(t.Context()); err == nil { + if _, err := socketClient(srv).OpenConnection(t.Context()); err == nil { t.Fatal("OpenConnection: err = nil, want an error for a response with no url") } } @@ -207,7 +209,7 @@ func TestListenConnects(t *testing.T) { hellos := make(chan slackapi.Hello, 1) opts := fastListen() opts.OnConnect = sendTo(hellos) - l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + l := startListen(t, socketClient(srv), ackAll, opts) want := slackapi.Hello{NumConnections: 1, AppID: "A42", Host: "applink-1"} if got := recv(t, hellos); got != want { @@ -232,7 +234,7 @@ func TestListenAcksOnTheSameConnection(t *testing.T) { } return nil, nil } - api := socketClient(srv, slackapi.RetryPolicy{}) + api := socketClient(srv) hellos := make(chan slackapi.Hello, 2) opts := fastListen() opts.OnConnect = sendTo(hellos) @@ -303,7 +305,7 @@ func TestListenHandlerErrorSendsNoAck(t *testing.T) { retries <- *d return nil, nil } - l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), h, fastListen()) + l := startListen(t, socketClient(srv), h, fastListen()) sm.WaitLive(t, socketWait, 1) var ids []string @@ -356,7 +358,7 @@ func TestListenRefresh(t *testing.T) { errs := make(chan error, 10) opts := fastListen() opts.OnError = sendTo(errs) - l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + l := startListen(t, socketClient(srv), ackAll, opts) sm.WaitLive(t, socketWait, 1) sm.DisconnectConn(1, reason) @@ -395,7 +397,7 @@ func TestListenPingTimeout(t *testing.T) { opts := fastListen() opts.PingTimeout = 100 * time.Millisecond opts.OnError = sendTo(errs) - l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + l := startListen(t, socketClient(srv), ackAll, opts) sm.WaitLive(t, socketWait, 1) waitFor(t, srv, "a ping on connection 1", func() bool { return sm.Conns()[0].Pings > 0 }) @@ -436,7 +438,7 @@ func TestListenFatalErrors(t *testing.T) { errs := make(chan error, 10) opts := fastListen() opts.OnError = sendTo(errs) - l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + l := startListen(t, socketClient(srv), ackAll, opts) requireReturns(t, srv, l, errs, "invalid_auth") }) @@ -451,7 +453,7 @@ func TestListenFatalErrors(t *testing.T) { errs := make(chan error, 10) opts := fastListen() opts.OnError = sendTo(errs) - l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + l := startListen(t, socketClient(srv), ackAll, opts) requireReturns(t, srv, l, errs, code) }) } @@ -463,7 +465,7 @@ func TestListenFatalErrors(t *testing.T) { errs := make(chan error, 10) opts := fastListen() opts.OnError = sendTo(errs) - l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, opts) + l := startListen(t, socketClient(srv), ackAll, opts) sm.WaitLive(t, socketWait, 1) sm.Disconnect("link_disabled") @@ -505,7 +507,7 @@ func TestListenTooManyWebsockets(t *testing.T) { srv := slackapitest.NewServer(t) sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, MaxConnections: 1}) opens := handleOpens(srv, func(_ int, call slackapitest.Call) any { return sm.Open(call) }) - api := socketClient(srv, slackapi.RetryPolicy{}) + api := socketClient(srv) hellos := make(chan slackapi.Hello, 1) holderOpts := fastListen() holderOpts.OnConnect = sendTo(hellos) @@ -542,6 +544,44 @@ func TestListenTooManyWebsockets(t *testing.T) { requireCloseFrames(t, sm) }) + t.Run("disconnect after hello", func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) + opens := handleOpens(srv, func(_ int, call slackapitest.Call) any { return sm.Open(call) }) + errs := make(chan stamped, 10) + hellos := make(chan slackapi.Hello, 2) + l := startListen(t, socketClient(srv), ackAll, slackapi.ListenOptions{ + MinBackoff: time.Millisecond, + MaxBackoff: maxBackoff, + OnConnect: sendTo(hellos), + OnError: stampErrors(errs), + }) + recv(t, hellos) + + sm.DisconnectConn(1, "too_many_websockets") + first := recv(t, errs) + if got := slackCodeOf(first.err); got != "too_many_websockets" { + t.Fatalf("OnError(%v), want too_many_websockets", first.err) + } + if h := recv(t, hellos); h.Host != "applink-2" { + t.Fatalf("OnConnect(%+v), want connection 2", h) + } + if wait := opens()[1].Sub(first.at); wait < maxBackoff/2 { + t.Errorf("reconnect after %s, want at least %s", wait, maxBackoff/2) + } + // Listen closes connection 1 at once, and does not keep it until + // connection 2 is up. + if conns := sm.Conns(); conns[0].CloseSeq == 0 || conns[0].CloseSeq > conns[1].HelloSeq { + t.Errorf("connections = %+v, want connection 1 closed before connection 2 is up", conns) + } + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) + }) + t.Run("api error", func(t *testing.T) { t.Parallel() srv := slackapitest.NewServer(t) @@ -554,7 +594,7 @@ func TestListenTooManyWebsockets(t *testing.T) { }) errs := make(chan stamped, 10) hellos := make(chan slackapi.Hello, 1) - l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, slackapi.ListenOptions{ + l := startListen(t, socketClient(srv), ackAll, slackapi.ListenOptions{ MinBackoff: time.Millisecond, MaxBackoff: maxBackoff, OnConnect: sendTo(hellos), @@ -578,14 +618,16 @@ func TestListenTooManyWebsockets(t *testing.T) { func TestListenHonorsRetryAfter(t *testing.T) { t.Parallel() - const retryAfter = 300 * time.Millisecond + // The Retry-After is longer than the MaxDelay of the default retry policy + // (1 second), so a retry of the Client would come too early. + const retryAfter = 1500 * time.Millisecond srv := slackapitest.NewServer(t) sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) opens := handleOpens(srv, func(n int, call slackapitest.Call) any { if n == 1 { return slackapitest.Response{ StatusCode: http.StatusTooManyRequests, - Header: http.Header{"Retry-After": {"0.3"}}, + Header: http.Header{"Retry-After": {"1.5"}}, } } return sm.Open(call) @@ -595,11 +637,11 @@ func TestListenHonorsRetryAfter(t *testing.T) { opts := fastListen() opts.OnConnect = sendTo(hellos) opts.OnError = sendTo(errs) - l := startListen(t, socketClient(srv, slackapi.RetryPolicy{MaxAttempts: 1}), ackAll, opts) + l := startListen(t, socketClient(srv), ackAll, opts) recv(t, hellos) - if rl, ok := errors.AsType[*slackapi.RateLimited](recv(t, errs)); !ok || rl.RetryAfter != retryAfter { - t.Errorf("OnError(%v), want *RateLimited with RetryAfter %s", rl, retryAfter) + if rl, ok := errors.AsType[*slackapi.RateLimited](recv(t, errs)); !ok || rl.RetryAfter != retryAfter || rl.Attempts != 1 { + t.Errorf("OnError(%v), want *RateLimited after 1 attempt with RetryAfter %s", rl, retryAfter) } if times := opens(); times[1].Sub(times[0]) < retryAfter { t.Errorf("retry after %s, want at least the Retry-After of %s", times[1].Sub(times[0]), retryAfter) @@ -614,38 +656,124 @@ func TestListenHonorsRetryAfter(t *testing.T) { func TestListenReconnectsAfterDrop(t *testing.T) { t.Parallel() - const minBackoff = 100 * time.Millisecond - srv := slackapitest.NewServer(t) - sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) - opens := handleOpens(srv, func(_ int, call slackapitest.Call) any { return sm.Open(call) }) - errs := make(chan error, 10) - hellos := make(chan slackapi.Hello, 10) - l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), ackAll, slackapi.ListenOptions{ - OnConnect: sendTo(hellos), - OnError: sendTo(errs), - MinBackoff: minBackoff, - MaxBackoff: time.Minute, - }) - recv(t, hellos) - - dropped := time.Now() - sm.Drop(1) - if err := recv(t, errs); err == nil { - t.Error("OnError(nil), want the error of the dropped connection") + // MinBackoff and MaxBackoff are the same, so a backoff waits at least + // half of it. + const backoff = 300 * time.Millisecond + tests := []struct { + name string + // up is the time that connection 1 is up before the drop. + up time.Duration + // backsOff reports whether the reconnect waits for the backoff. + backsOff bool + }{ + // A connection that was up for less than MaxBackoff counts as a + // failure, so that a connection that flaps backs off. + {name: "short-lived connection", up: 0, backsOff: true}, + {name: "long-lived connection", up: backoff, backsOff: false}, } - if h := recv(t, hellos); h.Host != "applink-2" { - t.Errorf("OnConnect(%+v), want connection 2", h) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) + opens := handleOpens(srv, func(_ int, call slackapitest.Call) any { return sm.Open(call) }) + errs := make(chan error, 10) + hellos := make(chan slackapi.Hello, 10) + l := startListen(t, socketClient(srv), ackAll, slackapi.ListenOptions{ + OnConnect: sendTo(hellos), + OnError: sendTo(errs), + MinBackoff: backoff, + MaxBackoff: backoff, + }) + recv(t, hellos) + time.Sleep(tt.up) + + dropped := time.Now() + sm.Drop(1) + if err := recv(t, errs); err == nil { + t.Error("OnError(nil), want the error of the dropped connection") + } + if h := recv(t, hellos); h.Host != "applink-2" { + t.Errorf("OnConnect(%+v), want connection 2", h) + } + wait := opens()[1].Sub(dropped) + if tt.backsOff && wait < backoff/2 { + t.Errorf("reconnect after %s, want at least %s", wait, backoff/2) + } + if !tt.backsOff && wait >= backoff/2 { + t.Errorf("reconnect after %s, want at once (less than %s)", wait, backoff/2) + } + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) + }) } - // Connection 1 was up for less than MaxBackoff, so its drop counts as a - // failure and the reconnect backs off. - if wait := opens()[1].Sub(dropped); wait < minBackoff/2 { - t.Errorf("reconnect after %s, want at least %s", wait, minBackoff/2) +} + +func TestListenBacksOffDuringAReplacement(t *testing.T) { + t.Parallel() + + const ( + minBackoff = 20 * time.Millisecond + maxBackoff = 300 * time.Millisecond + // failing is the number of apps.connections.open calls that fail + // after the replacement starts. + failing = 4 + ) + tests := []struct { + name string + pingTimeout time.Duration + start func(sm *slackapitest.SocketMode) + }{ + {name: "refresh", start: func(sm *slackapitest.SocketMode) { sm.DisconnectConn(1, "refresh_requested") }}, + {name: "ping timeout", pingTimeout: 100 * time.Millisecond, start: func(sm *slackapitest.SocketMode) { sm.StopPings(1) }}, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, PingInterval: 10 * time.Millisecond}) + opens := handleOpens(srv, func(n int, call slackapitest.Call) any { + if n > 1 && n <= 1+failing { + return map[string]any{"ok": false, "error": "internal_error"} + } + return sm.Open(call) + }) + hellos := make(chan slackapi.Hello, 2) + l := startListen(t, socketClient(srv), ackAll, slackapi.ListenOptions{ + OnConnect: sendTo(hellos), + PingTimeout: tt.pingTimeout, + MinBackoff: minBackoff, + MaxBackoff: maxBackoff, + }) + recv(t, hellos) + // Connection 1 is up for MaxBackoff, so it counts as healthy and + // resets the failure count. The reset happens once, when the + // replacement starts, and not at each failure. + time.Sleep(maxBackoff) + + tt.start(sm) + if h := recv(t, hellos); h.Host != "applink-2" { + t.Fatalf("OnConnect(%+v), want connection 2", h) + } + // The old connection stays active during the replacement, but + // each failure in a row doubles the wait. + times := opens() + for n := 1; n <= failing; n++ { + wait := times[n+1].Sub(times[n]) + if want := (minBackoff << (n - 1)) / 2; wait < want { + t.Errorf("wait after failure %d = %s, want at least %s", n, wait, want) + } + } - if err := l.stop(t); err != nil { - t.Fatalf("Listen: %v", err) + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) + }) } - requireCloseFrames(t, sm) } func TestListenStopDrainsHandlers(t *testing.T) { @@ -664,7 +792,7 @@ func TestListenStopDrainsHandlers(t *testing.T) { } return nil, nil } - l := startListen(t, socketClient(srv, slackapi.RetryPolicy{}), h, fastListen()) + l := startListen(t, socketClient(srv), h, fastListen()) sm.WaitLive(t, socketWait, 1) slow := sm.Send("events_api", json.RawMessage(`{"n":"slow"}`)) handlerCtx := recv(t, started) From 5785dba149a1ba8d5a00f2118bff5abca2131b64 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 04:10:59 +0000 Subject: [PATCH 09/33] docs(adr): say that Listen retries apps.connections.open with its own backoff --- docs/adr/0017-slack-socket-mode.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0017-slack-socket-mode.md b/docs/adr/0017-slack-socket-mode.md index 4f0b66d..6ec91dd 100644 --- a/docs/adr/0017-slack-socket-mode.md +++ b/docs/adr/0017-slack-socket-mode.md @@ -29,7 +29,7 @@ Related decisions, not redefined here: deferred dispatch is ADR 0002; rate-limit 2. **Two thin transports in `slackapi`.** Each transport adds only its own first step (receive and authenticate) and its own last step (answer Slack). - `Webhook` is the HTTP transport, an `http.Handler`. It accepts only POST, limits the body to 1 MiB, and calls `VerifyRequest` before any parse. It answers `url_verification` itself (the **Platform Handshake**). It re-encodes a slash command form as a JSON object of strings, the shape that Socket Mode sends. It reads the retry headers into the delivery. - - `(*Client).Listen(ctx, handler, opts)` is the Socket Mode transport. The caller passes a Client with the app-level token, for example `api.WithToken(appToken).Listen(...)`. Listen uses the Client's HTTP client, base URL, retry policy, and logger. `(*Client).OpenConnection` calls `apps.connections.open`. + - `(*Client).Listen(ctx, handler, opts)` is the Socket Mode transport. The caller passes a Client with the app-level token, for example `api.WithToken(appToken).Listen(...)`. Listen uses the Client's HTTP client, base URL, and logger. `(*Client).OpenConnection` calls `apps.connections.open`. Listen does not use the Client's retry policy for this call: the policy caps a wait at `MaxDelay`, so it can retry before `Retry-After`. The backoff of Listen retries the call instead. 3. **One adapter pipeline.** The Slack adapter has one `Handler` for both transports. It parses the payload by delivery type, resolves the install by team, normalizes the payload, and dispatches it. Parse and validation failures wrap `ErrInvalidPayload`. A workspace with no install gives an ack and an **Ignored Event**. When the runtime rejects a delivery at admission, a slash command acks with the busy reply as JSON, and events and interactions return `ErrBusy`. `Options.SigningSecret` enables `Webhook`, and `Options.AppToken` enables `Listen`. The adapter needs at least one of them, and accepts both. Multi-tenant installs work the same over both transports. @@ -57,7 +57,7 @@ Related decisions, not redefined here: deferred dispatch is ADR 0002; rate-limit - **Ping timeout:** Slack sends a websocket ping about every 10 seconds. A timer restarts on each ping, and starts only after the first ping, as in node-slack-sdk. When `ListenOptions.PingTimeout` (30 seconds by default) passes with no ping, Listen replaces the connection and reports `ErrPingTimeout` to `OnError`. The timer does not depend on the read loop, so a stuck read cannot block it. - **Fatal errors:** Listen stops and returns the error. These errors are fatal: an `*APIError` from `apps.connections.open` with the code `not_authed`, `invalid_auth`, `account_inactive`, `token_revoked`, `token_expired`, `user_removed_from_team`, `team_disabled`, `not_allowed_token_type`, or `missing_scope`, and a `*DisconnectError` with the reason `link_disabled`. - **`too_many_websockets`:** a `disconnect` message or an API error code with this reason closes that connection at once. Listen reports it to `OnError`, and waits `MaxBackoff` with jitter before the next connect. - - **Other errors:** connect failures and dropped connections go to `OnError`, and Listen retries with an exponential backoff with equal jitter, from `MinBackoff` (1 second by default) to `MaxBackoff` (1 minute by default). A `*RateLimited` error with `RetryAfter` waits at least `RetryAfter`. A dropped connection that was up for at least `MaxBackoff` reconnects at once. Else the drop counts as a failure, so a connection that drops again and again backs off. + - **Other errors:** connect failures and dropped connections go to `OnError`, and Listen retries with an exponential backoff with equal jitter, from `MinBackoff` (1 second by default) to `MaxBackoff` (1 minute by default). A `*RateLimited` error with `RetryAfter` waits at least `RetryAfter`. A dropped connection that was up for at least `MaxBackoff` reconnects at once. Else the drop counts as a failure, so a connection that drops again and again backs off. Failed connects during a replacement back off in the same way: a connection that was up for at least `MaxBackoff` resets the failure count once, when it drops, gets a `disconnect` message, or starts its replacement, and not at each failed connect. - **Stop:** when ctx is done, Listen closes every connection in the close order, waits for every handler goroutine, and returns nil. 7. **The first dependency: `github.com/coder/websocket`.** Socket Mode needs a websocket client, so the module now depends on `github.com/coder/websocket`. This ends the zero-dependency rule of ADR 0016. The reasons for this module: From 07e3869e59648c61aff82a73b74700c77e64fddb Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 04:29:29 +0000 Subject: [PATCH 10/33] fix(adapters/slack/slackapi/slackapitest): wake Server.Wait when a Handle function returns Server.Wait woke only when the Server recorded a call, before the Handle function ran. A cond that read state that the Handle function changed could miss its only wake-up. The Server now notifies again when a Handle or HandleFile function returns, and the docs say that Wait does not see a change that a Handle function makes while it blocks. --- adapters/slack/slackapi/slackapitest/doc.go | 9 +++-- adapters/slack/slackapi/slackapitest/files.go | 4 ++- .../slack/slackapi/slackapitest/server.go | 15 +++++--- .../slackapi/slackapitest/socketmode_test.go | 36 +++++++++++++++++++ 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/adapters/slack/slackapi/slackapitest/doc.go b/adapters/slack/slackapi/slackapitest/doc.go index d0baaad..433a5b1 100644 --- a/adapters/slack/slackapi/slackapitest/doc.go +++ b/adapters/slack/slackapi/slackapitest/doc.go @@ -28,9 +28,12 @@ // close. // // Server.Wait waits until a condition is true. It checks the condition again -// after each Web API call, file request, and Socket Mode change, so a test does -// not need to sleep. SocketMode.WaitLive, SocketMode.WaitClosed, and -// SocketMode.WaitAcked wait for common conditions with a timeout. +// after each Web API call, file request, and Socket Mode change, and when a +// Handle or HandleFile function returns, so a test does not need to sleep. It +// does not see a change that a Handle function makes before it returns, so a +// test that waits for a call while its Handle function blocks checks Calls. +// SocketMode.WaitLive, SocketMode.WaitClosed, and SocketMode.WaitAcked wait for +// common conditions with a timeout. // // SignRequest sets the Slack signature headers of a request so that // slackapi.VerifyRequest accepts it. diff --git a/adapters/slack/slackapi/slackapitest/files.go b/adapters/slack/slackapi/slackapitest/files.go index 6ac0e29..07b01f6 100644 --- a/adapters/slack/slackapi/slackapitest/files.go +++ b/adapters/slack/slackapi/slackapitest/files.go @@ -97,5 +97,7 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - s.writeAny(w, req.Path, fn(req)) + resp := fn(req) + s.changes.notify() + s.writeAny(w, req.Path, resp) } diff --git a/adapters/slack/slackapi/slackapitest/server.go b/adapters/slack/slackapi/slackapitest/server.go index d4d6f33..f5afe11 100644 --- a/adapters/slack/slackapi/slackapitest/server.go +++ b/adapters/slack/slackapi/slackapitest/server.go @@ -112,9 +112,14 @@ func (s *Server) Calls(method string) []Call { } // Wait waits until cond returns true. It calls cond at the start and again -// after each change in the Server: a Web API call, a file request, or a Socket -// Mode change. It returns nil when cond returns true, and the error of ctx when -// ctx is done first. cond must not block. +// after each change in the Server: a Web API call or a file request when the +// Server records it and again when its Handle or HandleFile function returns, +// or a Socket Mode change. It returns nil when cond returns true, and the error +// of ctx when ctx is done first. cond must not block. +// +// Wait does not see a change that a Handle or HandleFile function makes while +// it runs, until the function returns. To wait for a call while its Handle +// function blocks, check Calls or FileRequests in cond. func (s *Server) Wait(ctx context.Context, cond func() bool) error { for { changed := s.changes.next() @@ -173,7 +178,9 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { s.write(w, method, Response{Body: json.RawMessage(unknownMethodBody)}) return } - s.writeAny(w, method, fn(call)) + resp := fn(call) + s.changes.notify() + s.writeAny(w, method, resp) } // writeAny writes resp with the rules of Respond. diff --git a/adapters/slack/slackapi/slackapitest/socketmode_test.go b/adapters/slack/slackapi/slackapitest/socketmode_test.go index b6f107e..059a61b 100644 --- a/adapters/slack/slackapi/slackapitest/socketmode_test.go +++ b/adapters/slack/slackapi/slackapitest/socketmode_test.go @@ -719,3 +719,39 @@ func TestServerWait(t *testing.T) { t.Fatalf("Wait = %v, want context.DeadlineExceeded", err) } } + +func TestServerWaitSeesHandleChanges(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + checks := make(chan struct{}, 10) + var handled atomic.Bool + srv.Handle("auth.test", func(slackapitest.Call) any { + // Change the state only after Wait checked cond for the recorded + // call, so that only the end of this function can wake Wait. + select { + case <-checks: + case <-time.After(waitTimeout): + } + handled.Store(true) + return json.RawMessage(`{"ok":true}`) + }) + + done := make(chan error, 1) + go func() { + ctx, cancel := context.WithTimeout(t.Context(), waitTimeout) + defer cancel() + done <- srv.Wait(ctx, func() bool { + select { + case checks <- struct{}{}: + default: + } + return handled.Load() + }) + }() + <-checks + corePost(t, srv, "auth.test", "application/json", `{}`) + if err := <-done; err != nil { + t.Fatalf("Wait = %v, want nil after the Handle function changed the state", err) + } +} From 646ca523ae33694857ff5ff2ce42b0cec17e13a5 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 04:34:09 +0000 Subject: [PATCH 11/33] test(adapters/slack/slackapi): ack on the delivering connection after a refresh, and more Listen rules TestListenAcksOnTheSameConnection now holds a handler on connection 1 until Listen makes connection 2 the active connection, and checks that the ack goes on connection 1 before it closes. The old test, now TestListenAcks, keeps the ack payload and unknown type checks. TestListenRefresh waits for the recorded apps.connections.open call, because the Handle function of that call blocks. New tests: the ping timer starts only after the first ping, and a stop closes a connection after the 3 second drain timeout when a handler is stuck. --- adapters/slack/slackapi/socketmode_test.go | 136 ++++++++++++++++++--- 1 file changed, 119 insertions(+), 17 deletions(-) diff --git a/adapters/slack/slackapi/socketmode_test.go b/adapters/slack/slackapi/socketmode_test.go index df1a51d..479282c 100644 --- a/adapters/slack/slackapi/socketmode_test.go +++ b/adapters/slack/slackapi/socketmode_test.go @@ -221,7 +221,7 @@ func TestListenConnects(t *testing.T) { requireCloseFrames(t, sm) } -func TestListenAcksOnTheSameConnection(t *testing.T) { +func TestListenAcks(t *testing.T) { t.Parallel() srv := slackapitest.NewServer(t) @@ -234,14 +234,8 @@ func TestListenAcksOnTheSameConnection(t *testing.T) { } return nil, nil } - api := socketClient(srv) - hellos := make(chan slackapi.Hello, 2) - opts := fastListen() - opts.OnConnect = sendTo(hellos) - first := startListen(t, api, h, opts) - second := startListen(t, api, h, opts) - recv(t, hellos) - recv(t, hellos) + l := startListen(t, socketClient(srv), h, fastListen()) + sm.WaitLive(t, socketWait, 1) tests := []struct { typ string @@ -265,18 +259,62 @@ func TestListenAcksOnTheSameConnection(t *testing.T) { if len(env.Acks) != 1 { t.Fatalf("%s: acks = %+v, want 1", id, env.Acks) } - if ack, delivery := env.Acks[0], env.Deliveries[0]; ack.Conn != delivery.Conn { - t.Errorf("%s: ack on connection %d, delivery on connection %d", id, ack.Conn, delivery.Conn) - } if got := string(env.Acks[0].Payload); got != tt.wantAck { t.Errorf("%s: ack payload = %s, want %q", id, got, tt.wantAck) } } - for _, l := range []*listening{first, second} { - if err := l.stop(t); err != nil { - t.Fatalf("Listen: %v", err) + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) +} + +func TestListenAcksOnTheSameConnection(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, ResendAfter: -1}) + started := make(chan struct{}, 1) + release := make(chan struct{}) + h := func(ctx context.Context, _ *slackapi.Delivery) (any, error) { + started <- struct{}{} + select { + case <-release: + case <-ctx.Done(): } + return nil, nil + } + hellos := make(chan slackapi.Hello, 2) + opts := fastListen() + opts.OnConnect = sendTo(hellos) + l := startListen(t, socketClient(srv), h, opts) + recv(t, hellos) + id := sm.Send("events_api", map[string]string{"n": "slow"}) + recv(t, started) + + // Listen calls OnConnect after it makes connection 2 the active + // connection. The ack must still go on connection 1. + sm.DisconnectConn(1, "refresh_requested") + if h := recv(t, hellos); h.Host != "applink-2" { + t.Fatalf("OnConnect(%+v), want connection 2", h) + } + if c := sm.Conns()[0]; c.CloseSeq != 0 { + t.Fatalf("connection 1 = %+v, want it open while its handler runs", c) + } + close(release) + + ack := sm.WaitAcked(t, socketWait, id).Acks[0] + old := sm.WaitClosed(t, socketWait, 1) + if ack.Conn != 1 || ack.Seq > old.CloseSeq { + t.Errorf("ack = %+v, connection 1 closed at seq %d: want the ack on connection 1 before it closed", ack, old.CloseSeq) + } + if conns := sm.Conns(); conns[1].HelloSeq == 0 || conns[1].HelloSeq > old.CloseSeq { + t.Errorf("connections = %+v, want connection 2 up before connection 1 closed", conns) + } + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) } requireCloseFrames(t, sm) } @@ -349,7 +387,7 @@ func TestListenRefresh(t *testing.T) { gate := make(chan struct{}) release := sync.OnceFunc(func() { close(gate) }) t.Cleanup(release) - opens := handleOpens(srv, func(n int, call slackapitest.Call) any { + handleOpens(srv, func(n int, call slackapitest.Call) any { if n == 2 { <-gate } @@ -362,7 +400,9 @@ func TestListenRefresh(t *testing.T) { sm.WaitLive(t, socketWait, 1) sm.DisconnectConn(1, reason) - waitFor(t, srv, "the replacement connect", func() bool { return len(opens()) == 2 }) + // The Handle function of the call blocks, so wait for the call + // that the Server records. + waitFor(t, srv, "the replacement connect", func() bool { return len(srv.Calls("apps.connections.open")) == 2 }) during := sm.Send("events_api", map[string]string{"n": "during"}) if env := sm.WaitAcked(t, socketWait, during); env.Acks[0].Conn != 1 { t.Errorf("envelope during the refresh acked on connection %d, want 1", env.Acks[0].Conn) @@ -416,6 +456,31 @@ func TestListenPingTimeout(t *testing.T) { requireCloseFrames(t, sm) } +func TestListenPingTimeoutStartsAfterTheFirstPing(t *testing.T) { + t.Parallel() + + const pingTimeout = 50 * time.Millisecond + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, PingInterval: -1}) + errs := make(chan error, 10) + opts := fastListen() + opts.PingTimeout = pingTimeout + opts.OnError = sendTo(errs) + l := startListen(t, socketClient(srv), ackAll, opts) + sm.WaitLive(t, socketWait, 1) + + time.Sleep(4 * pingTimeout) + if conns := sm.Conns(); len(conns) != 1 || !conns[0].Live() { + t.Errorf("connections = %+v, want only connection 1, and live", conns) + } + requireNoError(t, errs) + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) +} + func TestListenFatalErrors(t *testing.T) { t.Parallel() @@ -823,6 +888,43 @@ func TestListenStopDrainsHandlers(t *testing.T) { requireCloseFrames(t, sm) } +func TestListenStopDrainTimeout(t *testing.T) { + if testing.Short() { + t.Skip("waits for the drain timeout of 3 seconds") + } + t.Parallel() + + const drainTimeout = 3 * time.Second + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, ResendAfter: -1}) + started := make(chan struct{}, 1) + h := func(ctx context.Context, _ *slackapi.Delivery) (any, error) { + started <- struct{}{} + <-ctx.Done() + return nil, ctx.Err() + } + l := startListen(t, socketClient(srv), h, fastListen()) + sm.WaitLive(t, socketWait, 1) + stuck := sm.Send("events_api", map[string]string{"n": "stuck"}) + recv(t, started) + + stopped := time.Now() + l.cancel() + sm.WaitClosed(t, socketWait, 1) + if wait := time.Since(stopped); wait < drainTimeout || wait > drainTimeout+time.Second { + t.Errorf("connection 1 closed %s after the stop, want after the drain timeout of %s", wait, drainTimeout) + } + // The handler returns only when its context is cancelled, and Listen + // waits for it. + if err := l.wait(t); err != nil { + t.Fatalf("Listen: %v", err) + } + if env := sm.Envelope(stuck); len(env.Acks) != 0 { + t.Errorf("stuck envelope acks = %+v, want none", env.Acks) + } + requireCloseFrames(t, sm) +} + func TestListenUsesTheProxyOfTheHTTPClient(t *testing.T) { t.Parallel() From 855c83955fa298933b36dd652aab2b6d96e04ec1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 04:34:19 +0000 Subject: [PATCH 12/33] fix(adapters/slack/slackapi): close a connection with a close frame when Listen stops during its handshake A stop cancelled the websocket handshake. When Slack had accepted the upgrade but the client had not read the response yet, the cancel closed the TCP connection without a close frame. Now a stop cancels only apps.connections.open. The handshake runs until it ends or the 30 second connect limit passes, and the close then sends the close frame. The Listen doc now says that a close waits for Slack to close the connection, and ADR 0017 records the handshake rule. --- adapters/slack/slackapi/socketmode.go | 58 +++++++++++++--------- adapters/slack/slackapi/socketmode_test.go | 53 +++++++++++++++++++- docs/adr/0017-slack-socket-mode.md | 2 +- 3 files changed, 86 insertions(+), 27 deletions(-) diff --git a/adapters/slack/slackapi/socketmode.go b/adapters/slack/slackapi/socketmode.go index 4650ad5..8cc552b 100644 --- a/adapters/slack/slackapi/socketmode.go +++ b/adapters/slack/slackapi/socketmode.go @@ -182,9 +182,9 @@ func (c *Client) OpenConnection(ctx context.Context) (string, error) { // Listen opens a new connection, and closes the old one only when the new one // is up. The old connection keeps acking until it closes. A connection closes // in this order: it waits at most 3 seconds for its running handlers, sends a -// close frame, and waits for its last message. Then the context of its -// handlers is cancelled. The context of a handler is not cancelled when ctx -// is done. +// close frame, and waits for Slack to close the connection. Then the context +// of its handlers is cancelled. The context of a handler is not cancelled when +// ctx is done. // // Listen gives every other error to ListenOptions.OnError, and retries with // backoff (see ListenOptions). A rate limit waits at least its Retry-After. @@ -193,7 +193,10 @@ func (c *Client) OpenConnection(ctx context.Context) (string, error) { // when Slack rejects the token (an *APIError with a code such as invalid_auth // or token_revoked) or turns Socket Mode off (a *DisconnectError with the // reason link_disabled). When ctx is done, it returns nil. Before it returns, -// Listen closes every connection and waits for every handler. +// Listen closes every connection and waits for every handler. A stop does not +// cancel a websocket handshake that runs: Listen waits for it, at most until +// the 30 second connect limit, and closes that connection with a close frame +// too. // // Listen logs connects, refreshes, and retries to the logger of c. func (c *Client) Listen(ctx context.Context, h Handler, opts ListenOptions) error { @@ -406,9 +409,12 @@ func (l *listener) retire(c *socketConn) { // socketConn is one Socket Mode connection. type socketConn struct { - l *listener - connectCtx context.Context - cancelConnect context.CancelFunc + l *listener + // connectDeadline ends the connect. connectCtx also ends when Listen + // stops. + connectDeadline time.Time + connectCtx context.Context + cancelConnect context.CancelFunc // ctx is the context of the handlers. cancel runs after the close. ctx context.Context cancel context.CancelFunc @@ -432,17 +438,19 @@ type socketConn struct { } func newSocketConn(ctx context.Context, l *listener) *socketConn { - connectCtx, cancelConnect := context.WithTimeoutCause(ctx, connectTimeout, errConnectTimeout) + deadline := time.Now().Add(connectTimeout) + connectCtx, cancelConnect := context.WithDeadlineCause(ctx, deadline, errConnectTimeout) handlerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) return &socketConn{ - l: l, - connectCtx: connectCtx, - cancelConnect: cancelConnect, - ctx: handlerCtx, - cancel: cancel, - dialed: make(chan struct{}), - done: make(chan struct{}), - silent: make(chan struct{}), + l: l, + connectDeadline: deadline, + connectCtx: connectCtx, + cancelConnect: cancelConnect, + ctx: handlerCtx, + cancel: cancel, + dialed: make(chan struct{}), + done: make(chan struct{}), + silent: make(chan struct{}), } } @@ -464,11 +472,13 @@ func (c *socketConn) dial() (*websocket.Conn, error) { if err != nil { return nil, err } - // When Listen stops during the handshake, the cancel closes the TCP - // connection with no close frame. Listen accepts this short window. A - // handshake that it cannot cancel could delay its return by up to - // connectTimeout. - ws, _, err := websocket.Dial(c.connectCtx, wsURL, &websocket.DialOptions{ + // Slack can accept the connection before the handshake returns, and a + // cancelled handshake closes it without a close frame. So a stop does not + // cancel the handshake: close waits for it, and then sends the close frame. + // Only the connect timeout ends it. + dialCtx, cancel := context.WithDeadline(context.WithoutCancel(c.connectCtx), c.connectDeadline) + defer cancel() + ws, _, err := websocket.Dial(dialCtx, wsURL, &websocket.DialOptions{ HTTPClient: api.httpClient, OnPingReceived: c.onPing, }) @@ -607,9 +617,9 @@ func (c *socketConn) end() { } } -// close closes the connection: it waits for the running handlers, sends the -// close frame, waits for the read loop to end, and cancels the context of the -// handlers. +// close closes the connection: it waits for a running websocket handshake and +// the running handlers, sends the close frame, waits for the read loop to end, +// and cancels the context of the handlers. func (c *socketConn) close() { c.cancelConnect() c.pingMu.Lock() diff --git a/adapters/slack/slackapi/socketmode_test.go b/adapters/slack/slackapi/socketmode_test.go index 479282c..2b1424c 100644 --- a/adapters/slack/slackapi/socketmode_test.go +++ b/adapters/slack/slackapi/socketmode_test.go @@ -592,8 +592,6 @@ func TestListenTooManyWebsockets(t *testing.T) { if rejected := sm.WaitClosed(t, socketWait, 2); !rejected.Rejected { t.Errorf("connection 2 = %+v, want a rejected connection", rejected) } - // Wait for the rejection of the retry, so that no dial runs when - // Listen stops. if got := slackCodeOf(recv(t, errs).err); got != "too_many_websockets" { t.Fatalf("second OnError code = %q, want too_many_websockets", got) } @@ -925,6 +923,57 @@ func TestListenStopDrainTimeout(t *testing.T) { requireCloseFrames(t, sm) } +// holdUpgrade is an http.RoundTripper that holds the response to a websocket +// upgrade until release is closed. If the request context is done then, it +// closes the connection, as http.Transport does when the context ends before +// it reads the response. +type holdUpgrade struct { + base http.RoundTripper + held chan struct{} + release chan struct{} +} + +func (h *holdUpgrade) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := h.base.RoundTrip(req) + if err != nil || resp.StatusCode != http.StatusSwitchingProtocols { + return resp, err + } + close(h.held) + <-h.release + if err := req.Context().Err(); err != nil { + _ = resp.Body.Close() + return nil, err + } + return resp, nil +} + +func TestListenStopDuringTheHandshake(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) + hold := &holdUpgrade{base: srv.Client().Transport, held: make(chan struct{}), release: make(chan struct{})} + api := slackapi.New(slackapi.Options{ + Token: socketAppToken, + BaseURL: srv.URL(), + HTTPClient: &http.Client{Transport: hold}, + }) + l := startListen(t, api, ackAll, fastListen()) + recv(t, hold.held) + + // Slack accepted the connection, but the client has not read the + // response yet. + l.cancel() + close(hold.release) + if err := l.wait(t); err != nil { + t.Fatalf("Listen: %v", err) + } + if conns := sm.Conns(); len(conns) != 1 { + t.Fatalf("connections = %+v, want 1", conns) + } + requireCloseFrames(t, sm) +} + func TestListenUsesTheProxyOfTheHTTPClient(t *testing.T) { t.Parallel() diff --git a/docs/adr/0017-slack-socket-mode.md b/docs/adr/0017-slack-socket-mode.md index 6ec91dd..10f0c8b 100644 --- a/docs/adr/0017-slack-socket-mode.md +++ b/docs/adr/0017-slack-socket-mode.md @@ -53,7 +53,7 @@ Related decisions, not redefined here: deferred dispatch is ADR 0002; rate-limit - **Connect:** call `OpenConnection`, dial the websocket with the Client's HTTP client, and wait for `hello`. The whole connect has a 30 second limit. The HTTP client gives the handshake its proxy settings. The read limit is 1 MiB, the same as the `Webhook` body limit. `ListenOptions.OnConnect` gets each hello, with `num_connections`, the app ID, and the host. - **Envelopes:** each envelope runs the handler in its own goroutine. The ack goes on the connection that the envelope came on. The handler context ends when that connection closes, not when the Listen context ends. An envelope of an unknown type still goes to the handler, with its type. - **Planned replacement with no gap:** a `disconnect` message with the reason `warning`, `refresh_requested`, or an unknown reason starts a replacement connection, and so does a ping timeout. The old connection keeps reading and acknowledging until the new connection has its hello. Then the old connection closes. - - **Close order:** a connection closes in four steps. First, it waits for its in-flight handlers, at most 3 seconds (Slack's ack window). Envelopes that arrive during this wait still get handled and acknowledged. Second, `Close(StatusNormalClosure)` sends the close frame. Third, it waits for the read loop to end. Fourth, it cancels the handler context. The read loop never uses a context that can be cancelled, because a cancelled read context closes the connection without a close frame. Only `Close` ends the read loop, so every close sends a close frame. + - **Close order:** a connection closes in four steps. First, it waits for its in-flight handlers, at most 3 seconds (Slack's ack window). Envelopes that arrive during this wait still get handled and acknowledged. Second, `Close(StatusNormalClosure)` sends the close frame. Third, it waits for the read loop to end. Fourth, it cancels the handler context. The read loop never uses a context that can be cancelled, because a cancelled read context closes the connection without a close frame. Only `Close` ends the read loop, so every close sends a close frame. For the same reason, a stop does not cancel a websocket handshake that runs: Slack can accept the connection before the handshake returns. The close waits for the handshake, which the 30 second connect limit bounds, and then sends the close frame. - **Ping timeout:** Slack sends a websocket ping about every 10 seconds. A timer restarts on each ping, and starts only after the first ping, as in node-slack-sdk. When `ListenOptions.PingTimeout` (30 seconds by default) passes with no ping, Listen replaces the connection and reports `ErrPingTimeout` to `OnError`. The timer does not depend on the read loop, so a stuck read cannot block it. - **Fatal errors:** Listen stops and returns the error. These errors are fatal: an `*APIError` from `apps.connections.open` with the code `not_authed`, `invalid_auth`, `account_inactive`, `token_revoked`, `token_expired`, `user_removed_from_team`, `team_disabled`, `not_allowed_token_type`, or `missing_scope`, and a `*DisconnectError` with the reason `link_disabled`. - **`too_many_websockets`:** a `disconnect` message or an API error code with this reason closes that connection at once. Listen reports it to `OnError`, and waits `MaxBackoff` with jitter before the next connect. From 16c38ef21b2338f1b20466c9a91ad5f1a7d50636 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 04:53:45 +0000 Subject: [PATCH 13/33] feat(adapters/slack): run Socket Mode with Options.AppToken and Listen --- adapters/slack/multitenant_test.go | 30 ++ adapters/slack/slack.go | 74 +++-- adapters/slack/transport_test.go | 506 +++++++++++++++++++++++++++++ 3 files changed, 590 insertions(+), 20 deletions(-) create mode 100644 adapters/slack/transport_test.go diff --git a/adapters/slack/multitenant_test.go b/adapters/slack/multitenant_test.go index 91eef5a..bfa7198 100644 --- a/adapters/slack/multitenant_test.go +++ b/adapters/slack/multitenant_test.go @@ -83,6 +83,36 @@ func TestSlackConstructionModeSelection(t *testing.T) { } } +// New needs a transport: a signing secret for Webhook, an app token for +// Listen, or both. +func TestSlackConstructionTransportSelection(t *testing.T) { + t.Parallel() + ctx := t.Context() + store := newFakeInstallStore() + + valid := map[string]slack.Options{ + "signing secret": {SigningSecret: "secret", BotToken: "xoxb-test"}, + "app token": {AppToken: "xapp-test", BotToken: "xoxb-test"}, + "both": {SigningSecret: "secret", AppToken: "xapp-test", BotToken: "xoxb-test"}, + "app token with an install store": {AppToken: "xapp-test", InstallStore: store}, + } + for name, opts := range valid { + if _, err := slack.New(ctx, opts); err != nil { + t.Errorf("%s: New: %v", name, err) + } + } + + for name, opts := range map[string]slack.Options{ + "single install": {BotToken: "xoxb-test"}, + "multi-tenant": {InstallStore: store}, + } { + _, err := slack.New(ctx, opts) + if err == nil || err.Error() != "slack: signing secret or app token is required" { + t.Errorf("%s with no signing secret and no app token: New error = %v, want the transport error", name, err) + } + } +} + func newMultiTenantSlackRuntime(t *testing.T, api *slackAPIServer, store chat.InstallStore, now time.Time) *chat.Chat { t.Helper() adapter, err := slack.New(context.Background(), slack.Options{ diff --git a/adapters/slack/slack.go b/adapters/slack/slack.go index 2ba1235..6d40d5d 100644 --- a/adapters/slack/slack.go +++ b/adapters/slack/slack.go @@ -19,16 +19,24 @@ import ( const adapterName = "slack" type Options struct { + // SigningSecret is the signing secret of the Slack app. It enables + // Webhook, which checks the signature of each request with it. At least + // one of SigningSecret and AppToken is required, and both are allowed. SigningSecret string - BotToken string - TeamID string - BotUserID string - BotID string + // AppToken is the app-level token (xapp-) of the Slack app, with the + // connections:write scope. It enables Listen, which receives deliveries + // over Socket Mode. + AppToken string + BotToken string + TeamID string + BotUserID string + BotID string // InstallStore selects Multi-Tenant Adapter mode: per-workspace bot tokens are - // resolved per webhook (and per Thread Handle reconstruction) from the store + // resolved per delivery (and per Thread Handle reconstruction) from the store // instead of a static BotToken. Supplying both BotToken and InstallStore, or - // neither, is a Runtime Construction error. SigningSecret stays required in both - // modes: Slack signs with one app-level signing secret, never per-workspace. + // neither, is a Runtime Construction error. SigningSecret and AppToken work + // the same in both modes: Slack has one of each for each app, never one for + // each workspace. InstallStore chat.InstallStore APIBaseURL string Client *http.Client @@ -47,9 +55,10 @@ type Options struct { } // SlackInstall is the adapter-specific Install.Credential payload for Slack, a -// Platform Escape Hatch for credentials. Slack verifies webhooks with one -// app-level signing secret (kept in Options.SigningSecret), so only the -// per-workspace bot token rides here; it is used for replies, never verification. +// Platform Escape Hatch for credentials. Slack has one signing secret and one +// app-level token for each app (kept in Options.SigningSecret and +// Options.AppToken), so only the per-workspace bot token rides here; it is used +// for replies, never to receive deliveries. // BotUserID is optional; when set it makes self-filtering tenant-correct for the // workspace, and takes second place behind Install.BotActorID. type SlackInstall struct { @@ -59,6 +68,7 @@ type SlackInstall struct { type Adapter struct { signingSecret string + appToken string botToken string teamID string botUserID string @@ -70,7 +80,7 @@ type Adapter struct { disableNativeEphemeral bool } -// slackInstall is the resolved per-event credential the webhook and posting paths +// slackInstall is the resolved per-event credential the delivery and posting paths // share: the bot token used for replies and the bot user id used for tenant-correct // self-filtering. type slackInstall struct { @@ -82,12 +92,12 @@ func New(ctx context.Context, opts Options) (*Adapter, error) { if err := ctx.Err(); err != nil { return nil, err } - if opts.SigningSecret == "" { - return nil, errors.New("slack: signing secret is required") + if opts.SigningSecret == "" && opts.AppToken == "" { + return nil, errors.New("slack: signing secret or app token is required") } // Mode selection by capability, validated fail-fast: exactly one of a static bot - // token or an install store. Slack's shared signing secret stays in Options for - // both modes. + // token or an install store. Slack's app-level signing secret and app token stay + // in Options for both modes. if opts.BotToken != "" && opts.InstallStore != nil { return nil, errors.New("slack: set either bot token or install store, not both") } @@ -115,6 +125,7 @@ func New(ctx context.Context, opts Options) (*Adapter, error) { }) return &Adapter{ signingSecret: opts.SigningSecret, + appToken: opts.AppToken, botToken: opts.BotToken, teamID: opts.TeamID, botUserID: opts.BotUserID, @@ -138,7 +149,7 @@ func (a *Adapter) Name() string { } func (a *Adapter) Init(ctx context.Context) error { - // Multi-tenant identity is per-install, resolved per webhook / from each install + // Multi-tenant identity is per-install, resolved per delivery / from each install // record, so there is no single auth.test identity to discover at construction. if a.multiTenant() { return nil @@ -186,8 +197,12 @@ func (a *Adapter) Shutdown(context.Context) error { // Webhook returns the HTTP transport for the request URLs of the Slack app: the // Events API, slash command, and interactivity URLs. It uses the same delivery -// handler as Listen. +// handler as Listen. With no Options.SigningSecret, it returns a handler that +// answers 404 to each request. func (a *Adapter) Webhook(dispatch chat.DispatchFunc) http.Handler { + if a.signingSecret == "" { + return http.NotFoundHandler() + } return &slackapi.Webhook{ SigningSecret: a.signingSecret, Handler: a.deliveryHandler(dispatch), @@ -196,6 +211,25 @@ func (a *Adapter) Webhook(dispatch chat.DispatchFunc) http.Handler { } } +var _ chat.Listener = (*Adapter)(nil) + +// Listen is the Socket Mode transport. It implements the Listener Optional +// Capability, which Chat.Listen runs. It connects to Slack with +// Options.AppToken, and gives each delivery to the same delivery handler as +// Webhook. It blocks until ctx is done. Then it closes its connections and +// returns nil. +// +// Listen returns an error at once when the adapter has no Options.AppToken. It +// also returns an error when Slack rejects the app token or turns Socket Mode +// off (see slackapi.Client.Listen). It logs the errors that it recovers from +// to Options.Logger. +func (a *Adapter) Listen(ctx context.Context, dispatch chat.DispatchFunc) error { + if a.appToken == "" { + return errors.New("slack: app token is required to listen") + } + return a.api.WithToken(a.appToken).Listen(ctx, a.deliveryHandler(dispatch), slackapi.ListenOptions{}) +} + // deliveryHandler returns the one inbound pipeline of the adapter, for both // transports. It normalizes a delivery to an Event and dispatches it. An Ignored // Event is acknowledged without dispatch. @@ -355,12 +389,12 @@ func (a *Adapter) BotActor() chat.Actor { } } -// resolveInstall returns the per-tenant credential for an inbound webhook. In +// resolveInstall returns the per-tenant credential for an inbound delivery. In // single-install mode it returns the static token and discovered bot identity. In // multi-tenant mode it calls the InstallStore: ErrInstallNotFound (or an empty // tenant) signals an Ignored Event (found=false, nil error, caller acks without // dispatch); any other store error is a transport failure (found=false, non-nil -// error, caller returns 5xx). The same resolver serves Thread Handle posting via +// error, caller does not ack, so Slack can redeliver). The same resolver serves Thread Handle posting via // postToken, keyed by the Platform Tenant decoded from the Thread ID. func (a *Adapter) resolveInstall(ctx context.Context, tenant string) (slackInstall, bool, error) { if !a.multiTenant() { @@ -389,7 +423,7 @@ func (a *Adapter) resolveInstall(ctx context.Context, tenant string) (slackInsta }, true, nil } -// postToken resolves the per-tenant bot token for an out-of-webhook post (Thread +// postToken resolves the per-tenant bot token for an out-of-delivery post (Thread // Handle reconstruction). ErrInstallNotFound surfaces as a clean error here rather // than an Ignored Event: there is no platform request to acknowledge. func (a *Adapter) postToken(ctx context.Context, tenant string) (string, error) { diff --git a/adapters/slack/transport_test.go b/adapters/slack/transport_test.go new file mode 100644 index 0000000..f334113 --- /dev/null +++ b/adapters/slack/transport_test.go @@ -0,0 +1,506 @@ +package slack_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/coder/chat" + "github.com/coder/chat/adapters/slack" + "github.com/coder/chat/adapters/slack/slackapi" + "github.com/coder/chat/adapters/slack/slackapi/slackapitest" + "github.com/coder/chat/state/memory" +) + +const ( + transportSecret = "secret" + transportAppToken = "xapp-test" + transportWait = 5 * time.Second +) + +var transportNow = time.Unix(1_700_000_000, 0) + +// Payloads in the Socket Mode shape. serveDelivery sends each one over HTTP in +// the shape that Slack uses there. +const ( + mentionPayload = `{ + "type":"event_callback", + "team_id":"T1", + "event_id":"EvMention", + "event":{"type":"app_mention","channel":"C1","user":"U1","text":"<@UBOT> hi","ts":"111.000"} + }` + commandPayload = `{ + "command":"/deploy", + "text":"prod now", + "team_id":"T1", + "channel_id":"C1", + "channel_name":"general", + "user_id":"U1", + "trigger_id":"trigger-1", + "response_url":"https://hooks.slack.com/commands/T1/1", + "api_app_id":"A1" + }` + blockActionsPayload = `{ + "type":"block_actions", + "team":{"id":"T1"}, + "user":{"id":"U1"}, + "channel":{"id":"C1"}, + "container":{"channel_id":"C1","message_ts":"333.000"}, + "trigger_id":"trigger-2", + "response_url":"https://hooks.slack.com/actions/T1/2", + "actions":[{"action_id":"approve","block_id":"b1","value":"yes","type":"button","action_ts":"333.100"}] + }` +) + +// newTransportAdapter returns an adapter with both transports, on srv. A +// single-install adapter serves team T1. A multi-tenant adapter has an install +// only for team T2. +func newTransportAdapter(t *testing.T, srv *slackapitest.Server, multiTenant bool) *slack.Adapter { + t.Helper() + opts := slack.Options{ + SigningSecret: transportSecret, + AppToken: transportAppToken, + APIBaseURL: srv.URL(), + Client: srv.Client(), + Now: func() time.Time { return transportNow }, + } + if multiTenant { + store := newFakeInstallStore() + store.set("T2", chat.Install{Tenant: "T2", Credential: slack.SlackInstall{BotToken: "xoxb-T2", BotUserID: "UBOT2"}}) + opts.InstallStore = store + } else { + opts.BotToken = "xoxb-test" + opts.TeamID = "T1" + opts.BotUserID = "UBOT" + opts.BotID = "BBOT" + } + adapter, err := slack.New(t.Context(), opts) + if err != nil { + t.Fatalf("new slack adapter: %v", err) + } + return adapter +} + +// socketPayload returns payload as the fake Socket Mode sends it. The fake +// encodes each envelope with encoding/json, which compacts the payload and +// escapes HTML characters such as <. The tests send the same bytes over HTTP, +// so that the Raw fields of the Events are equal. +func socketPayload(t *testing.T, payload string) json.RawMessage { + t.Helper() + b, err := json.Marshal(json.RawMessage(payload)) + if err != nil { + t.Fatalf("encode payload: %v", err) + } + return b +} + +// serveDelivery sends payload to h as Slack sends it over HTTP: a JSON body for +// an Events API payload, and a form for a slash command or an interaction. A +// retry attempt above 0 sets the retry headers. +func serveDelivery(t *testing.T, h http.Handler, typ slackapi.DeliveryType, payload []byte, attempt int, reason string) *httptest.ResponseRecorder { + t.Helper() + body, contentType := payload, "application/json" + switch typ { + case slackapi.DeliveryEventsAPI: + case slackapi.DeliveryInteractive: + body, contentType = []byte(url.Values{"payload": {string(payload)}}.Encode()), "application/x-www-form-urlencoded" + case slackapi.DeliverySlashCommands: + var fields map[string]string + if err := json.Unmarshal(payload, &fields); err != nil { + t.Fatalf("decode command payload: %v", err) + } + form := url.Values{} + for key, value := range fields { + form.Set(key, value) + } + body, contentType = []byte(form.Encode()), "application/x-www-form-urlencoded" + default: + t.Fatalf("no HTTP shape for delivery type %q", typ) + } + req := httptest.NewRequest(http.MethodPost, "/slack", bytes.NewReader(body)) + req.Header.Set("Content-Type", contentType) + if attempt > 0 { + req.Header.Set("X-Slack-Retry-Num", strconv.Itoa(attempt)) + req.Header.Set("X-Slack-Retry-Reason", reason) + } + slackapitest.SignRequest(req, body, transportSecret, transportNow) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +// recorder is a chat.DispatchFunc that records each Event and returns err. +type recorder struct { + events chan *chat.Event + err error +} + +func newRecorder(err error) *recorder { + return &recorder{events: make(chan *chat.Event, 16), err: err} +} + +func (r *recorder) dispatch(_ context.Context, ev *chat.Event) error { + r.events <- ev + return r.err +} + +// wait returns the next n Events. +func (r *recorder) wait(t *testing.T, n int) []*chat.Event { + t.Helper() + events := make([]*chat.Event, 0, n) + for range n { + select { + case ev := <-r.events: + events = append(events, ev) + case <-time.After(transportWait): + t.Fatalf("got %d dispatched events, want %d", len(events), n) + } + } + return events +} + +// drain returns the Events that wait has not returned. +func (r *recorder) drain() []*chat.Event { + var events []*chat.Event + for { + select { + case ev := <-r.events: + events = append(events, ev) + default: + return events + } + } +} + +// listen runs l.Listen in a goroutine. The returned function cancels it, waits +// for it to return, and fails the test when it returns an error. +func listen(t *testing.T, l chat.Listener, dispatch chat.DispatchFunc) (stop func()) { + t.Helper() + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- l.Listen(ctx, dispatch) }() + end := sync.OnceValue(func() error { + cancel() + select { + case err := <-done: + return err + case <-time.After(transportWait): + return errors.New("Listen did not return after its context ended") + } + }) + t.Cleanup(func() { _ = end() }) + return func() { + t.Helper() + if err := end(); err != nil { + t.Fatalf("Listen: %v", err) + } + } +} + +func requireEqualEvents(t *testing.T, overHTTP, overSocket []*chat.Event) { + t.Helper() + if len(overHTTP) != len(overSocket) { + t.Fatalf("Webhook dispatched %d events, Listen dispatched %d", len(overHTTP), len(overSocket)) + } + for i := range overHTTP { + if !reflect.DeepEqual(overHTTP[i], overSocket[i]) { + t.Errorf("event %d:\nWebhook: %#v\nListen: %#v", i, overHTTP[i], overSocket[i]) + } + } +} + +// One payload gives the same Event and the same ack over Webhook and over +// Listen. +func TestTransportParity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + typ slackapi.DeliveryType + payload string + multiTenant bool + // wantTenant is the Tenant of the dispatched Event. It is empty when + // the adapter dispatches no Event. + wantTenant string + }{ + {name: "app mention", typ: slackapi.DeliveryEventsAPI, payload: mentionPayload, wantTenant: "T1"}, + { + name: "direct message", + typ: slackapi.DeliveryEventsAPI, + payload: `{ + "type":"event_callback", + "team_id":"T1", + "event_id":"EvDM", + "event":{"type":"message","channel":"D1","channel_type":"im","user":"U1","text":"hello","ts":"222.000"} + }`, + wantTenant: "T1", + }, + {name: "slash command", typ: slackapi.DeliverySlashCommands, payload: commandPayload, wantTenant: "T1"}, + {name: "block_actions interaction", typ: slackapi.DeliveryInteractive, payload: blockActionsPayload, wantTenant: "T1"}, + { + name: "ignored event", + typ: slackapi.DeliveryEventsAPI, + payload: `{ + "type":"event_callback", + "team_id":"T1", + "event_id":"EvReaction", + "event":{"type":"reaction_added","user":"U1","reaction":"thumbsup","item":{"type":"message","channel":"C1","ts":"111.000"}} + }`, + }, + { + name: "ignored interaction", + typ: slackapi.DeliveryInteractive, + payload: `{"type":"view_submission","team":{"id":"T1"},"user":{"id":"U1"},"view":{"id":"V1"}}`, + }, + { + name: "workspace with no install", + typ: slackapi.DeliveryEventsAPI, + payload: strings.ReplaceAll(mentionPayload, `"T1"`, `"TX"`), + multiTenant: true, + }, + { + name: "multi-tenant install", + typ: slackapi.DeliveryEventsAPI, + payload: `{ + "type":"event_callback", + "team_id":"T2", + "event_id":"EvT2", + "event":{"type":"app_mention","channel":"C2","user":"U2","text":"<@UBOT2> hi","ts":"444.000"} + }`, + multiTenant: true, + wantTenant: "T2", + }, + { + name: "multi-tenant slash command", + typ: slackapi.DeliverySlashCommands, + payload: strings.ReplaceAll(commandPayload, `"T1"`, `"T2"`), + multiTenant: true, + wantTenant: "T2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: transportAppToken, ResendAfter: -1}) + adapter := newTransportAdapter(t, srv, tt.multiTenant) + payload := socketPayload(t, tt.payload) + + overHTTP := newRecorder(nil) + rec := serveDelivery(t, adapter.Webhook(overHTTP.dispatch), tt.typ, payload, 0, "") + if rec.Code != http.StatusOK || rec.Body.Len() != 0 { + t.Errorf("Webhook answered %d %q, want 200 with no body", rec.Code, rec.Body.String()) + } + + overSocket := newRecorder(nil) + stop := listen(t, adapter, overSocket.dispatch) + sm.WaitLive(t, transportWait, 1) + env := sm.WaitAcked(t, transportWait, sm.Send(string(tt.typ), payload)) + stop() + if len(env.Acks) != 1 || env.Acks[0].Payload != nil { + t.Errorf("Listen acks = %+v, want one ack with no payload", env.Acks) + } + + events := overHTTP.drain() + requireEqualEvents(t, events, overSocket.drain()) + switch { + case tt.wantTenant == "" && len(events) != 0: + t.Errorf("dispatched %d events, want an Ignored Event", len(events)) + case tt.wantTenant != "" && (len(events) != 1 || events[0].Tenant != tt.wantTenant): + t.Errorf("dispatched %#v, want one event of tenant %s", events, tt.wantTenant) + } + }) + } +} + +// When the runtime rejects a delivery at admission, Webhook and Listen give +// the same answer: no ack for an event or an interaction, so Slack can deliver +// it again, and the busy reply for a slash command. Each redelivery gives the +// same Event over both transports. +func TestTransportParityAdmissionRejected(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + typ slackapi.DeliveryType + payload string + // wantBusyReply is true when the delivery gets the busy reply, and + // false when it gets no ack. + wantBusyReply bool + }{ + {name: "app mention", typ: slackapi.DeliveryEventsAPI, payload: mentionPayload}, + {name: "block_actions interaction", typ: slackapi.DeliveryInteractive, payload: blockActionsPayload}, + {name: "slash command", typ: slackapi.DeliverySlashCommands, payload: commandPayload, wantBusyReply: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + // The fake sends an envelope at most 4 times. A short resend time + // lets the test see every send of an envelope that gets no ack. + resendAfter := 20 * time.Millisecond + deliveries := 4 + if tt.wantBusyReply { + resendAfter, deliveries = -1, 1 + } + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: transportAppToken, ResendAfter: resendAfter}) + adapter := newTransportAdapter(t, srv, false) + payload := socketPayload(t, tt.payload) + + overSocket := newRecorder(chat.ErrAdmissionRejected) + stop := listen(t, adapter, overSocket.dispatch) + sm.WaitLive(t, transportWait, 1) + id := sm.Send(string(tt.typ), payload) + socketEvents := overSocket.wait(t, deliveries) + // Listen waits for its handlers and sends their acks before it + // closes the connection, so the fake has every ack after the close. + stop() + sm.WaitClosed(t, transportWait, 1) + env := sm.Envelope(id) + if len(env.Deliveries) != deliveries { + t.Fatalf("Listen got %d sends of the envelope, want %d", len(env.Deliveries), deliveries) + } + + overHTTP := newRecorder(chat.ErrAdmissionRejected) + h := adapter.Webhook(overHTTP.dispatch) + var rec *httptest.ResponseRecorder + for _, d := range env.Deliveries { + rec = serveDelivery(t, h, tt.typ, payload, d.RetryAttempt, d.RetryReason) + if !tt.wantBusyReply && rec.Code != http.StatusServiceUnavailable { + t.Errorf("attempt %d: Webhook status = %d, want 503", d.RetryAttempt, rec.Code) + } + } + requireEqualEvents(t, overHTTP.drain(), socketEvents) + + if !tt.wantBusyReply { + if len(env.Acks) != 0 { + t.Errorf("Listen acks = %+v, want no ack", env.Acks) + } + return + } + if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/json" { + t.Fatalf("Webhook answered %d with Content-Type %q, want 200 with a JSON body", rec.Code, rec.Header().Get("Content-Type")) + } + if len(env.Acks) != 1 || string(env.Acks[0].Payload) != rec.Body.String() { + t.Fatalf("Listen acks = %+v, want one ack with the Webhook body %s as payload", env.Acks, rec.Body.String()) + } + var reply struct { + Text string `json:"text"` + } + if err := json.Unmarshal(env.Acks[0].Payload, &reply); err != nil || !strings.Contains(reply.Text, "at capacity") { + t.Errorf("busy reply = %s, want a JSON object with the busy text", env.Acks[0].Payload) + } + }) + } +} + +func TestWebhookNeedsSigningSecret(t *testing.T) { + t.Parallel() + + adapter, err := slack.New(t.Context(), slack.Options{AppToken: transportAppToken, BotToken: "xoxb-test", TeamID: "T1", BotUserID: "UBOT", BotID: "BBOT"}) + if err != nil { + t.Fatalf("new slack adapter: %v", err) + } + overHTTP := newRecorder(nil) + rec := serveDelivery(t, adapter.Webhook(overHTTP.dispatch), slackapi.DeliveryEventsAPI, []byte(mentionPayload), 0, "") + if rec.Code != http.StatusNotFound { + t.Errorf("Webhook with no signing secret answered %d, want 404", rec.Code) + } + if events := overHTTP.drain(); len(events) != 0 { + t.Errorf("Webhook with no signing secret dispatched %d events", len(events)) + } +} + +func TestListenNeedsAppToken(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + adapter, err := slack.New(t.Context(), slack.Options{ + SigningSecret: transportSecret, + BotToken: "xoxb-test", + TeamID: "T1", + BotUserID: "UBOT", + BotID: "BBOT", + APIBaseURL: srv.URL(), + Client: srv.Client(), + }) + if err != nil { + t.Fatalf("new slack adapter: %v", err) + } + if err := adapter.Listen(t.Context(), newRecorder(nil).dispatch); err == nil || !strings.Contains(err.Error(), "app token") { + t.Errorf("Adapter.Listen with no app token: err = %v, want an app token error", err) + } + + bot, err := chat.New(t.Context(), chat.WithState(memory.New()), chat.WithAdapter(adapter)) + if err != nil { + t.Fatalf("new chat runtime: %v", err) + } + if err := bot.Listen(t.Context(), "slack"); err == nil || !strings.Contains(err.Error(), "app token") { + t.Errorf("Chat.Listen with no app token: err = %v, want an app token error", err) + } + if calls := srv.Calls("apps.connections.open"); len(calls) != 0 { + t.Errorf("apps.connections.open calls = %d, want 0", len(calls)) + } +} + +// Chat.Listen runs the Slack adapter over Socket Mode: a delivery reaches its +// handler and gets its ack, and Runtime Shutdown stops Listen, which closes +// the connection with a close frame. +func TestChatListenRunsSocketMode(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: transportAppToken, ResendAfter: -1}) + bot, err := chat.New(t.Context(), chat.WithState(memory.New()), chat.WithAdapter(newTransportAdapter(t, srv, false))) + if err != nil { + t.Fatalf("new chat runtime: %v", err) + } + mentions := make(chan *chat.MessageEvent, 1) + bot.OnNewMention(func(_ context.Context, ev *chat.MessageEvent) error { + mentions <- ev + return nil + }) + done := make(chan error, 1) + // The Listen context never ends, so only Shutdown can stop Listen. + go func() { done <- bot.Listen(context.Background(), "slack") }() + + sm.WaitLive(t, transportWait, 1) + env := sm.WaitAcked(t, transportWait, sm.Send(string(slackapi.DeliveryEventsAPI), socketPayload(t, mentionPayload))) + select { + case ev := <-mentions: + if ev.Event.ID != "EvMention" || ev.Message.Text != "<@UBOT> hi" { + t.Errorf("mention = %+v, want event EvMention", ev.Event) + } + default: + t.Errorf("envelope %s got an ack, but no mention reached the handler", env.ID) + } + + if err := bot.Shutdown(t.Context()); err != nil { + t.Fatalf("Shutdown: %v", err) + } + select { + case err := <-done: + if err != nil { + t.Errorf("Chat.Listen: %v", err) + } + case <-time.After(transportWait): + t.Fatal("Chat.Listen did not return after Shutdown") + } + if c := sm.WaitClosed(t, transportWait, 1); !c.CloseFrame || c.CloseCode != websocket.StatusNormalClosure { + t.Errorf("connection = %+v, want a close frame with %v", c, websocket.StatusNormalClosure) + } +} From 111655e236997664b1308f2d25f183e03216b7ea Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:03:11 +0000 Subject: [PATCH 14/33] docs: rename Webhook Event to Delivery and add the Listener and Socket Mode terms --- CONTEXT.md | 72 ++++++++++++++++----------- docs/adr/0007-teams-adapter.md | 3 ++ docs/adr/0012-concurrency-strategy.md | 2 +- 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index fd95bbb..d38e663 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -17,7 +17,7 @@ A single-install OAuth credential pair that lets a platform app act as itself ra _Avoid_: Personal API key, user OAuth token, marketplace installation **Runtime Shutdown**: -The idempotent cleanup step that asks adapters and runtime state to release resources. +The idempotent cleanup step that closes dispatch admission, drains deferred work, stops running **Listeners**, and then asks adapters and runtime state to release resources. _Avoid_: Best-effort silent cleanup **Handler Registration**: @@ -93,9 +93,17 @@ An optional observe-only metrics and tracing surface beyond structured-log **Run _Avoid_: Metrics framework, middleware, bundled exporter **Webhook Handler**: -An `http.Handler` entrypoint exposed by the runtime for one **Platform Adapter**. +An `http.Handler` entrypoint exposed by the runtime for one **Platform Adapter**; it receives **Deliveries** that the platform sends over HTTP. _Avoid_: Built-in server, router integration +**Listener**: +An **Optional Capability** of a **Platform Adapter** that opens an outbound connection to the platform and receives **Deliveries** over it until the context ends; the application runs it with `Chat.Listen`. +_Avoid_: Server, poller, background goroutine the runtime starts on its own + +**Socket Mode**: +Slack's websocket transport: the app opens the connection with an app-level token, and Slack sends **Deliveries** over it, so the app needs no public URL. +_Avoid_: RTM API, websocket server + **Webhook Mount**: The application step that asks the runtime for a **Webhook Handler** for a named adapter. _Avoid_: Runtime 404 for configuration typo @@ -105,7 +113,7 @@ A platform-specific webhook setup request that an adapter must answer without ru _Avoid_: Application event, user handler **Runtime State**: -The required persistent state used by the runtime for thread subscriptions, webhook dedupe, locks, and runtime cache. +The required persistent state used by the runtime for thread subscriptions, **Delivery** dedupe, locks, and runtime cache. _Avoid_: Optional state, default memory **Thread Application State**: @@ -113,7 +121,7 @@ Product-specific state that an application associates with a **Thread** outside _Avoid_: Runtime state, thread.state **Message History**: -Past messages in a **Thread** fetched from the platform after their original webhook delivery. +Past messages in a **Thread** fetched from the platform after their original **Delivery**. _Avoid_: Current event, application context store **History Reader**: @@ -138,16 +146,16 @@ A production **Runtime State** implementation for deployments standardized on NATS JetStream as their coordination store. _Avoid_: Core NATS pub/sub, message bus, per-key TTL -**Webhook Event**: -A single inbound delivery from a platform adapter before runtime dedupe and handler routing. -_Avoid_: Message, request +**Delivery**: +One inbound payload that a platform sends to an adapter, over a **Webhook Handler** or a **Listener**, before runtime dedupe and handler routing. +_Avoid_: Message, request, Webhook Event **Event Identity**: The stable adapter-scoped identifier used to deduplicate a platform event across deliveries. _Avoid_: Retry number, request id **Retry Metadata**: -Platform delivery metadata that explains a repeated webhook attempt without changing the event's identity. +Platform delivery metadata that explains a repeated **Delivery** attempt without changing the event's identity. _Avoid_: Dedupe key **Runtime Options**: @@ -171,11 +179,11 @@ A token-owned **Thread Lock** record that can only be released or extended by it _Avoid_: Untokened lock, delete-only lock **Lock Conflict**: -A runtime condition where a **Webhook Event** is accepted but not handled because its **Thread** is already locked. +A runtime condition where a **Delivery** is accepted but not handled because its **Thread** is already locked. _Avoid_: Duplicate, handler failure **Runtime Dispatch**: -The runtime-owned step that routes a normalized **Webhook Event** to the appropriate application handler. +The runtime-owned step that routes a normalized **Delivery** to the appropriate application handler. _Avoid_: Adapter dispatch, job processing **Dispatch Context**: @@ -191,7 +199,7 @@ The deferred-dispatch contract where the adapter acknowledges within the platfor _Avoid_: Synchronous handler, fire-and-forget goroutine **Detached Work Context**: -A runtime-derived context that outlives the inbound request so a deferred handler keeps working after acknowledgement; bounded by a detach timeout and cancelled by **Runtime Shutdown**. +A runtime-derived context that outlives the inbound request or **Delivery** so a deferred handler keeps working after acknowledgement; bounded by a detach timeout and cancelled by **Runtime Shutdown**. _Avoid_: Request context, context.Background() **Admission Bound**: @@ -207,7 +215,7 @@ A valid platform event that the adapter acknowledges without handing to runtime _Avoid_: Rejected webhook, accepted event **Platform Adapter**: -The platform-specific boundary that verifies inbound webhooks, normalizes events, and renders outbound posts for one chat platform. +The platform-specific boundary that receives and verifies inbound **Deliveries** over a **Webhook Handler** or a **Listener**, normalizes events, and renders outbound posts for one chat platform. _Avoid_: Runtime router, app handler **Adapter Initialization**: @@ -235,7 +243,7 @@ An explicit typed helper path for retrieving a registered **Platform Adapter** w _Avoid_: Unchecked type assertion in examples **Slack API Package**: -The low-level `slackapi` package of Slack Web API calls, request verification, Events API parsing, and Slack data formats on raw Slack IDs, for applications that own their runtime; the Slack **Platform Adapter** uses it, and it has no runtime, state, dispatch, or policy. +The low-level `slackapi` package of Slack Web API calls, request verification, Events API parsing, both inbound transports (an HTTP webhook handler and a **Socket Mode** client), and Slack data formats on raw Slack IDs, for applications that own their runtime; the Slack **Platform Adapter** uses it, and it has no runtime, state, dispatch, or policy. _Avoid_: Slack adapter, portable surface, complete Slack SDK **Agent Activity Thought**: @@ -343,7 +351,7 @@ A **Platform Adapter** configured for one **Platform Tenant** without runtime OA _Avoid_: Multi-workspace adapter **Multi-Tenant Adapter**: -A **Platform Adapter** that resolves per-**Platform Tenant** credentials at webhook time via an app-owned **Install Store**, instead of the **Single-Install Adapter** default. +A **Platform Adapter** that resolves per-**Platform Tenant** credentials for each **Delivery** via an app-owned **Install Store**, instead of the **Single-Install Adapter** default. _Avoid_: Default adapter mode, runtime-owned install database **Install Store**: @@ -351,7 +359,7 @@ An app-owned **Optional Capability** the runtime defines and the application imp _Avoid_: Runtime credential database, account linking, OAuth web flow **Supported Platform Shape**: -The subset of a platform webhook payload that an adapter explicitly understands and normalizes. +The subset of a platform **Delivery** payload that an adapter explicitly understands and normalizes. _Avoid_: Full platform schema, strict external SDK model ## Relationships @@ -363,8 +371,9 @@ _Avoid_: Full platform schema, strict external SDK model - **App-Actor Client Credentials** access tokens are cached in the Linear adapter process for MVP; **Runtime State** is not expanded to store adapter credential caches. - **App-Actor Client Credentials** tokens are refreshed lazily before Linear API calls when near expiry; the MVP does not run a background token refresher. - **App-Actor Client Credentials** are resolved during **Adapter Initialization**; invalid credentials or missing app actor identity fail startup before webhooks are served. -- **Runtime Construction** returns configuration and initialization errors instead of deferring them to the first webhook. -- **Runtime Shutdown** attempts all adapter cleanup hooks before state cleanup and returns joined cleanup errors. +- **Runtime Construction** returns configuration and initialization errors instead of deferring them to the first **Delivery**. +- **Runtime Shutdown** closes dispatch admission, drains deferred work, and stops running **Listeners**, in that order; then it attempts all adapter cleanup hooks before state cleanup and returns joined cleanup errors. +- **Runtime Shutdown** stops **Listeners** only after it closes admission, so a **Delivery** that a **Listener** receives during shutdown gets the same overload outcome as one that arrives at a **Webhook Handler**. - **Runtime Construction** does not require application handlers; missing **Handler Registration** is a no-op for that route. - **Handler Registration** may happen through methods on the constructed runtime. - **Handler Registration** installs or atomically replaces one **Routing Hook** handler; it does not append to a handler chain. @@ -411,6 +420,9 @@ _Avoid_: Full platform schema, strict external SDK model - **Runtime Observation** defaults to structured logging, not middleware or a metrics framework; an optional **Observation Hook** adds an observe-only metrics and tracing seam with no hard telemetry dependency in the core. - The runtime exposes **Webhook Handlers** and does not own HTTP server, router, TLS, or graceful-shutdown concerns. - A **Webhook Mount** fails immediately when the named adapter is not registered. +- The runtime runs a **Listener** only when the application calls `Chat.Listen`; `Chat.Listen` fails immediately when the named adapter is not registered or does not implement **Listener**. +- A **Platform Adapter** sends each **Delivery** through one inbound pipeline, so a **Webhook Handler** and a **Listener** produce the same **Event** for the same payload. +- The Slack **Platform Adapter** implements **Listener** with **Socket Mode** when it has an app-level token; it can serve a **Webhook Handler** and a **Listener** at the same time. - A **Platform Adapter** handles its own **Platform Handshake** requests before runtime dispatch. - A **Go Chat Runtime** requires **Runtime State**; it must not silently create **Memory State** for production-facing construction. - **Runtime State** is coordination state, not **Thread Application State**. @@ -421,7 +433,7 @@ _Avoid_: Full platform schema, strict external SDK model - The **Linear App-Actor Slice** includes one memory-backed hello-world example for local dogfooding; production deployments should choose Redis or Postgres **Runtime State** separately. - **Memory State** is suitable for tests and local development, not horizontally scaled deployment. - The MVP includes **Redis State** so the **Slack-First Slice** can run across multiple instances. -- The runtime deduplicates **Webhook Events** and serializes handler execution with a **Thread Lock**. +- The runtime deduplicates **Deliveries** and serializes handler execution with a **Thread Lock**. - In the **Linear App-Actor Slice**, **Event Identity** follows the Linear source comment identity for the logical user message rather than the webhook delivery identity. - **Event Identity** is the dedupe key; **Retry Metadata** is recorded for observation only. - Dedupe TTL and lock TTL belong in **Runtime Options**. @@ -432,14 +444,14 @@ _Avoid_: Full platform schema, strict external SDK model - Debounce coalesces on a configured quiet period and requires deferred **Dispatch Mode**; concurrent takes no **Thread Lock** and is bounded by a configured maximum; skipped (superseded) events are always observable, never silent. - Burst batches routed events per lock scope during a fixed collection window (anchored at the first member, optionally sealed early by a batch cap) and dispatches the batch in join order under one **Thread Lock** hold, each member with its own detach budget; batch shaping is delivery-preserving (an accepted member is never dropped), parked members count against the **Admission Bound**, batches for one scope dispatch in seal order, and batching is per runtime instance. - A deferred handler whose **Lock Lease** is lost mid-run is cancelled rather than left running unserialized. -- A **Thread Lock** coordinates processing of distinct **Webhook Events** for the same **Thread**; it never deduplicates them, and what happens to a conflicting event is decided by the **Concurrency Strategy** (drop acknowledges and drops it; queue coalesces waiters per process and runs the most recent after the lock releases). +- A **Thread Lock** coordinates processing of distinct **Deliveries** for the same **Thread**; it never deduplicates them, and what happens to a conflicting event is decided by the **Concurrency Strategy** (drop acknowledges and drops it; queue coalesces waiters per process and runs the most recent after the lock releases). - A **Thread Lock** is represented as a **Lock Lease** with an ownership token. - Releasing or extending a **Lock Lease** must verify the ownership token so an expired holder cannot affect a newer holder. - A **Lock Conflict** is acknowledged to the platform by default and recorded as unhandled runtime contention. -- An adapter accepts and normalizes a **Webhook Event**, but **Runtime Dispatch** decides which handler runs. +- An adapter accepts and normalizes a **Delivery**, but **Runtime Dispatch** decides which handler runs. - The **Linear App-Actor Slice** preserves synchronous **Runtime Dispatch** for MVP; long-running Linear agent work should post an early **Agent Activity Thought** and enqueue follow-up work in application code. - **Runtime Dispatch** defaults to synchronous (**Dispatch Mode** sync); a deferred **Dispatch Mode** (**Ack-Then-Work**) is available opt-in and runs the handler on the **Detached Work Context** after acknowledgement. -- The synchronous **Dispatch Context** comes from the inbound webhook request; under deferred **Dispatch Mode** the **Thread Lock** is held across the detached work and refreshed before its lease expires. +- The synchronous **Dispatch Context** comes from the inbound webhook request, or from the handler context of a **Delivery** that a **Listener** receives; under deferred **Dispatch Mode** the **Thread Lock** is held across the detached work and refreshed before its lease expires. - Runtime locks must be released when **Dispatch Context** is cancelled or handler execution exits. - **Runtime State** mutations performed during synchronous dispatch respect the caller's **Dispatch Context** and fail when it is cancelled. - Outbound rate-limit retry is **Platform Adapter**-owned via a per-adapter **Retry Policy**: bounded by an attempt cap and cumulative ceiling, never sleeping past the caller's deadline, surfacing exhaustion as a typed **Rate Limited** error, visible through **Runtime Observation** and the **Observation Hook**; long throttles move to **Ack-Then-Work** rather than longer synchronous sleeps. @@ -447,11 +459,11 @@ _Avoid_: Full platform schema, strict external SDK model - Invalid verification or invalid platform payloads are not **Accepted Events**. - An **Ignored Event** is acknowledged to the platform without runtime handler dispatch. - Invalid signatures and malformed requests are rejected rather than treated as **Ignored Events**. -- A **Platform Adapter** owns platform webhook verification and message rendering, but **Runtime Dispatch** owns handler routing. +- A **Platform Adapter** owns its platform transports (webhook verification and any **Listener** connection) and message rendering, but **Runtime Dispatch** owns handler routing. - A **Platform Adapter** should not expose normal app paths that bypass runtime dedupe, locking, or subscription checks. -- **Adapter Initialization** must fail before serving webhooks when required credentials or bot identity are missing. +- **Adapter Initialization** must fail before the adapter receives **Deliveries** when required credentials or bot identity are missing. - Tests may inject **Adapter Initialization** facts that production discovers from the platform. -- A **Webhook Event** is adapter input; an **Event** is the normalized runtime input derived from it. +- A **Delivery** is adapter input; an **Event** is the normalized runtime input derived from it. - A **Message** may be part of an **Event**, but not every **Event** is a **Message**. - Message **Routing Hooks** receive a **Message Event** so handlers can use both event-level context and message-specific fields. - A **Platform Escape Hatch** supports platform-specific needs, but common flows should use normalized **Event**, **Message**, and **Thread** fields. @@ -559,6 +571,9 @@ _Avoid_: Full platform schema, strict external SDK model > **Dev:** "Should the SDK start its own HTTP server?" > **Domain expert:** "No, expose a **Webhook Handler** so applications can mount it in their existing server or router." +> **Dev:** "Does a local Slack bot need a public URL or a tunnel?" +> **Domain expert:** "No, give the Slack adapter an app-level token and call `Chat.Listen`; **Socket Mode** opens an outbound connection, and its **Deliveries** go through the same pipeline as the **Webhook Handler**." + > **Dev:** "If the app asks for the Slack webhook with a misspelled adapter name, should production requests discover that?" > **Domain expert:** "No, the **Webhook Mount** should fail immediately." @@ -578,7 +593,7 @@ _Avoid_: Full platform schema, strict external SDK model > **Domain expert:** "No, the MVP needs **Redis State** for cross-instance subscriptions, dedupe, and locks." > **Dev:** "Two different Slack messages arrive in the same **Thread**. Are they duplicates?" -> **Domain expert:** "No, they are separate **Webhook Events**; dedupe keeps both, while the **Thread Lock** serializes their handlers." +> **Domain expert:** "No, they are separate **Deliveries**; dedupe keeps both, while the **Thread Lock** serializes their handlers." > **Dev:** "Should Slack retry number be part of the dedupe key?" > **Domain expert:** "No, use **Event Identity** for dedupe and record retry headers as **Retry Metadata**." @@ -599,7 +614,7 @@ _Avoid_: Full platform schema, strict external SDK model > **Domain expert:** "No, a **Lock Lease** is token-owned; release and extension only affect the current owner's token." > **Dev:** "Should the Slack adapter call user handlers directly?" -> **Domain expert:** "No, it should normalize the **Webhook Event** and hand it to **Runtime Dispatch**." +> **Domain expert:** "No, it should normalize the **Delivery** and hand it to **Runtime Dispatch**." > **Dev:** "Should long-running work keep using the webhook request's **Dispatch Context** after the platform times out?" > **Domain expert:** "No, long-running work needs an explicit app-owned detach or queue path; synchronous dispatch uses the request context." @@ -703,6 +718,7 @@ _Avoid_: Full platform schema, strict external SDK model - "Middleware" could mean observability or user-controlled dispatch mutation; resolved: **Middleware** is dispatch mutation and is deferred. - "Observability" could mean middleware, metrics, or logs; resolved: **Runtime Observation** starts as structured logging. - "HTTP integration" could mean a built-in server or a mountable handler; resolved: the runtime exposes **Webhook Handlers** only. +- "Transport" could mean an HTTP server the runtime owns or a way for **Deliveries** to reach an adapter; resolved: a transport is a **Webhook Handler** that the application mounts, or a **Listener** that the application runs with `Chat.Listen`. - "Webhook lookup" could mean dynamic runtime routing or startup mount validation; resolved: **Webhook Mount** is fallible. - "Platform handshake" could mean a user event or adapter setup request; resolved: **Platform Handshake** is adapter-owned and does not dispatch. - "State" could mean optional app convenience storage or required runtime coordination; resolved: **Runtime State** is required, while **Memory State** is explicitly dev/test only. @@ -712,11 +728,11 @@ _Avoid_: Full platform schema, strict external SDK model - "Event identity" could mean delivery attempt or platform event; resolved: **Event Identity** excludes **Retry Metadata**. - "Runtime options" could mean many unrelated one-off settings; resolved: **Runtime Options** groups coordination timing such as dedupe and lock TTLs. - "Concurrency" could mean a full queueing system or a lock-conflict policy; resolved: **Concurrency Strategy** starts with drop only. -- "Locking" could mean event dedupe, adapter-wide exclusion, or handler coordination; resolved: dedupe applies to **Webhook Events**, while **Thread Lock** applies per **Thread**. +- "Locking" could mean event dedupe, adapter-wide exclusion, or handler coordination; resolved: dedupe applies to **Deliveries**, while **Thread Lock** applies per **Thread**. - "Lock release" could mean deleting a key by thread id or releasing an owned lease; resolved: **Lock Lease** release and extension are token-checked. - "Lock conflict" could mean a duplicate event or handler failure; resolved: **Lock Conflict** is accepted-but-unhandled thread contention. - "Dispatch" could mean adapter-specific webhook handling or runtime handler routing; resolved: **Runtime Dispatch** is owned by the **Go Chat Runtime**. -- "Context" could mean webhook request lifetime or background job lifetime; resolved: **Dispatch Context** is the synchronous request context. +- "Context" could mean webhook request lifetime or background job lifetime; resolved: **Dispatch Context** is the synchronous request context, or the handler context of a **Delivery** from a **Listener**. - "Accepted event" could mean successful handler execution; resolved: **Accepted Event** means verified and normalized event ownership by the runtime. - "Ignored event" could mean invalid input or irrelevant valid input; resolved: **Ignored Event** is valid platform input acknowledged without dispatch. - "Adapter" could mean a full mini-framework or a platform boundary; resolved: **Platform Adapter** does platform conversion and transport, not application routing. diff --git a/docs/adr/0007-teams-adapter.md b/docs/adr/0007-teams-adapter.md index 7c78cc4..bb91254 100644 --- a/docs/adr/0007-teams-adapter.md +++ b/docs/adr/0007-teams-adapter.md @@ -135,6 +135,9 @@ A code spike of the adapter exists on the `spike/msteams-adapter` branch `golang-jwt`/`jwx` proved unnecessary. The Decision above records this stdlib-only choice, which preserves the repo's zero-dependency, stdlib-direct stance (Slack/Linear precedent). + [ADR 0017](0017-slack-socket-mode.md) later ended the zero-dependency rule: the module + now depends on `github.com/coder/websocket` for Slack Socket Mode. The stdlib-only JWT + choice stands. Q9's remaining verification steps are superseded rather than resolved: with `msbotbuilder-go` rejected, exercising *its* auth against a real Teams-issued token is diff --git a/docs/adr/0012-concurrency-strategy.md b/docs/adr/0012-concurrency-strategy.md index 25da4a2..8b8e8f7 100644 --- a/docs/adr/0012-concurrency-strategy.md +++ b/docs/adr/0012-concurrency-strategy.md @@ -26,7 +26,7 @@ This ADR reopens one documented non-goal, surfaced explicitly per `docs/agents/d Expand **Concurrency Strategy** to the upstream-aligned set, keeping `drop` the default and unchanged: -- **`drop`** (default): on a **Lock Conflict** the new **Webhook Event** is acknowledged and recorded as unhandled contention. Today's behavior. +- **`drop`** (default): on a **Lock Conflict** the new **Delivery** is acknowledged and recorded as unhandled contention. Today's behavior. - **`queue`**: the new event waits for the in-flight handler to finish, then dispatches within the same lock scope. Only the most recent queued event for a scope is dispatched; superseded events are surfaced as skipped through **Runtime Observation**. This is the companion to `DispatchDeferred`. - **`debounce`**: each new event resets a timer (`debounceMs`); only the final event in a quiet-period dispatches. For rapid edits/typing. - **`burst`**: waits `debounceMs` on an idle scope, then dispatches the collected batch. A batch-oriented sibling of `queue`/`debounce`. From 390b6626c7aa048bdd708cf437549c1683294f05 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:04:06 +0000 Subject: [PATCH 15/33] docs(adapters/slack): describe both transports, AppToken, Listen, and Chat.Listen --- adapters/slack/doc.go | 39 ++++++++++++++++++++++++++++------ adapters/slack/slackapi/doc.go | 7 +++++- go.work.sum | 9 ++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/adapters/slack/doc.go b/adapters/slack/doc.go index 9ae7afa..64490ff 100644 --- a/adapters/slack/doc.go +++ b/adapters/slack/doc.go @@ -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 @@ -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 diff --git a/adapters/slack/slackapi/doc.go b/adapters/slack/slackapi/doc.go index 5a4b598..c82dbee 100644 --- a/adapters/slack/slackapi/doc.go +++ b/adapters/slack/slackapi/doc.go @@ -4,7 +4,7 @@ // portable chat.Adapter surface. It holds Slack protocol and data formats only. // It has no runtime, state, dispatch, or policy; the application brings those. // The Slack adapter (package slack) uses it for its Web API calls, rate-limit -// retry, signature check, and history reads. See ADR 0016. +// retry, history reads, and both inbound transports. See ADR 0016 and ADR 0017. // // The package is experimental. Its exported API may change before promotion. // RetryPolicy and RateLimited keep the Slack adapter's supported tier because @@ -76,6 +76,11 @@ // invalid_auth or link_disabled. A connection waits at most 3 seconds for its // running handlers, and then closes with a close frame. // +// The app-level token (xapp-) needs the connections:write scope, and Socket +// Mode must be on in the app settings. Webhook and Listen take the same +// Handler, so an app can use one transport or both. The Slack adapter runs +// Listen when the app calls Chat.Listen. +// // # Formats // // MarkdownBlock, ActionsBlock with ButtonElement, and ImageBlock are Block Kit diff --git a/go.work.sum b/go.work.sum index 95d07a3..f2b3bf9 100644 --- a/go.work.sum +++ b/go.work.sum @@ -3,6 +3,7 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -12,8 +13,10 @@ github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= github.com/moby/sys/mount v0.3.5/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= @@ -22,6 +25,7 @@ github.com/nats-io/nats.go v1.39.1/go.mod h1:MgRb8oOdigA6cYpEPhXJuRVH6UE/V4jblJ2 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -30,8 +34,10 @@ golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtC golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -52,8 +58,11 @@ golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0 golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 7edbc749634ec4ca46fd9646ef246d500939a038 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:06:36 +0000 Subject: [PATCH 16/33] docs: document Socket Mode, the Listener capability, Chat.Listen, and the Shutdown order --- docs/explanation.md | 22 ++++-- docs/reference.md | 189 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 180 insertions(+), 31 deletions(-) diff --git a/docs/explanation.md b/docs/explanation.md index 61fc687..c4b4a23 100644 --- a/docs/explanation.md +++ b/docs/explanation.md @@ -10,17 +10,18 @@ The full record is in [`CONTEXT.md`](../CONTEXT.md) and the ## The Short Version **The runtime coordinates conversations; it does not own your product.** -`chat.Chat` verifies webhooks through adapters, normalizes platform payloads -into events, dedupes them, serializes work per thread with token-owned lock +`chat.Chat` receives deliveries through adapters, over webhooks or over a +listener such as Slack Socket Mode. It normalizes platform payloads into +events, dedupes them, serializes work per thread with token-owned lock leases, and routes to your single-slot handlers. Everything your product stores — transcripts, user records, workflow state — lives in your database, keyed by the opaque `ThreadID`. -**Adapters own the platform boundary.** Signature verification, payload -normalization, outbound rendering, rate-limit retries, and platform quirks -live inside the adapter. Platform-specific power is reached deliberately via -typed adapter access (`chat.AdapterAs`), never by making raw platform structs -the normal API. +**Adapters own the platform boundary.** Transports, signature verification, +payload normalization, outbound rendering, rate-limit retries, and platform +quirks live inside the adapter. Platform-specific power is reached +deliberately via typed adapter access (`chat.AdapterAs`), never by making raw +platform structs the normal API. **State is required and small.** Subscriptions, dedupe marks, and locks — that is all. Memory for development; Redis, Postgres, or NATS for production. @@ -39,6 +40,10 @@ diverges and documents the divergence. - Go-native API built around `context.Context`, `net/http`, small interfaces, and explicit errors. - Slack-first vertical slice before claiming multi-platform portability. +- One inbound pipeline for each adapter, whatever the transport: a Slack + payload gives the same event over HTTP and over Socket Mode, and Socket + Mode lets a bot run with no public URL + ([ADR 0017](adr/0017-slack-socket-mode.md)). - Required runtime state for subscriptions, dedupe, and locks: memory for tests and local development; Redis, Postgres, or NATS JetStream for horizontally scaled production deployments. @@ -171,7 +176,8 @@ Operations: - no built-in OAuth web flow: authorize/callback/token-exchange routes and install storage are application-owned ([ADR 0006](adr/0006-multi-tenant-install.md)) -- no built-in HTTP server or router integrations +- no built-in HTTP server or router integrations: the application mounts + each webhook handler, and runs each listener with `Chat.Listen` - no bundled metrics framework, exporters, or scrape endpoint (an optional no-op `Observer` seam is provided; OpenTelemetry stays out of the core import graph) diff --git a/docs/reference.md b/docs/reference.md index be9143f..c80572f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -22,6 +22,9 @@ supports, and the runnable examples. Redis, Postgres, and NATS state live in separate Go modules so applications that only use core, Slack, or memory state do not pull their dependencies. +The core module has one dependency, `github.com/coder/websocket`, for the +Slack Socket Mode client ([ADR 0017](adr/0017-slack-socket-mode.md)). Only +builds that import `slackapi` or `slackapitest` compile it. The repository uses `go.work` for local development across the root module, the state modules, and the example modules. @@ -37,27 +40,32 @@ go doc github.com/coder/chat/adapters/slack - **Runtime construction, hooks, dispatch, runtime options**: package `chat` (`chat.New`, `Chat.OnNewMention`, `Chat.OnSubscribedMessage`, `Chat.OnCommand`, `Chat.OnInteraction`, `RuntimeOptions`). +- **Receiving deliveries**: package `chat` (`Chat.Webhook`, `Chat.Listen`, + the `Listener` interface); see [Receiving Deliveries](#receiving-deliveries). - **Event and message model**: package `chat` (`Event`, `Message`, `MessageEvent`, `CommandEvent`, `InteractionEvent`, `Thread`, `ThreadID`, `Actor`). - **Optional capabilities**: package `chat` (`NativeContentPoster`, - `HistoryReader`, `EphemeralPoster` interfaces) plus the adapter packages + `HistoryReader`, `EphemeralPoster`, `Listener` interfaces) plus the adapter packages for what each adapter actually implements. - **Multi-tenant installs**: package `chat` (`InstallStore`, `Install`, `ErrInstallNotFound`) plus `slack.SlackInstall` / `linear.LinearInstall`. - **State contract**: package `chat` (`State`) with implementations in the four `state/*` packages. - **Slack without the runtime**: package `slackapi` (`slackapi.New`, - `VerifyRequest`, `ParseEnvelope`) and its test helper `slackapitest`; see + `Webhook`, `Client.Listen`, `VerifyRequest`, `ParseEnvelope`) and its test + helper `slackapitest`; see [Slack API Package](#slack-api-package). ## Core Model `Chat` is the runtime. It owns adapter registration, runtime state, handler -registration, webhook mounting, dispatch, dedupe, locking, and shutdown. +registration, webhook mounting, listener runs, dispatch, dedupe, locking, and +shutdown. -A `Platform Adapter` is a platform boundary. It verifies inbound webhooks, -normalizes platform payloads, renders outbound messages, and exposes +A `Platform Adapter` is a platform boundary. It receives inbound deliveries +over a webhook handler or a listener, verifies them, normalizes platform +payloads, renders outbound messages, and exposes platform-specific APIs through typed adapter access. It does not own application routing. @@ -79,7 +87,7 @@ workspaces, channels, and platforms. Application code may store and pass it around, but must not build it manually. Thread handles can be reconstructed from a stored `ThreadID` for -out-of-webhook work such as reminders or proactive follow-ups: +work outside a delivery, such as reminders or proactive follow-ups: ```go thread, err := bot.Thread(ctx, threadID) @@ -116,7 +124,7 @@ defer func() { `chat.New` requires a `State` and at least one adapter, validates the runtime options, and initializes every adapter (which is where the Slack adapter -discovers its own bot identity) before any webhook is served. A failing +discovers its own bot identity) before any delivery is received. A failing adapter initialization shuts down the adapters registered so far and returns the joined error. This is an intentional difference from Vercel Chat SDK, which initializes lazily on first use. @@ -132,11 +140,28 @@ Options: (it does not merge), so start from `chat.DefaultRuntimeOptions()`. See [Dedupe, Locks, And Concurrency](#dedupe-locks-and-concurrency). -`Shutdown(ctx)` is idempotent. It cancels detached work, runs every -adapter's cleanup hook before state cleanup, and returns joined errors if -cleanup fails. +`Shutdown(ctx)` stops the runtime in this order: -## Webhooks +1. It closes dispatch admission. Every new delivery, in both dispatch modes, + gets `chat.ErrAdmissionRejected`. +2. It cancels deferred work and waits for it to finish. +3. It cancels every `Listen` call and waits for it to return. +4. It runs every adapter's cleanup hook, then state cleanup. + +`ctx` bounds the waits in steps 2 and 3. `Shutdown` returns the joined errors +of the steps. It is idempotent: a later call waits for the first call to +finish, bounded by its own `ctx`, and then returns nil. + +## Receiving Deliveries + +A delivery is one inbound payload that a platform sends to an adapter. An +adapter receives deliveries over a webhook handler, over a listener, or over +both. Both transports use the same adapter pipeline, so a payload becomes the +same `Event` over each of them. The runtime starts no HTTP server and no +listener on its own: the application mounts the handler and runs the +listener. + +### Webhooks The runtime exposes `net/http` handlers and does not own the HTTP server: @@ -156,6 +181,37 @@ Adapters own platform handshakes. For Slack, the `url_verification` challenge is answered inside the Slack webhook handler and never reaches application handlers. +### Listeners + +A listener is an Optional Capability (`chat.Listener`) of an adapter that +opens an outbound connection to the platform, so the application needs no +public URL. The Slack adapter implements it with Socket Mode +([ADR 0017](adr/0017-slack-socket-mode.md)). Run it with `Chat.Listen`: + +```go +ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) +defer stop() + +if err := bot.Listen(ctx, "slack"); err != nil { + return err +} +``` + +`Listen` blocks until `ctx` is done or `Shutdown` stops listeners, and then +returns nil. It returns the adapter's error when the adapter cannot continue +to listen, for example when Slack rejects the app token. It returns an error +at once when the named adapter is not registered, or when it does not +implement `Listener` (`chat.ErrUnsupportedCapability`). After `Shutdown` has +started, it returns nil at once. Concurrent calls are allowed, and an +application can serve the webhook handler of the same adapter at the same +time. + +`Shutdown` stops listeners only after it closes admission and drains deferred +work. So a delivery that arrives while `Shutdown` runs gets +`chat.ErrAdmissionRejected`, and the adapter gives the platform the same +overload answer as at the `MaxDetached` bound. For example, the Slack adapter +does not ack an Events API delivery, so Slack can deliver it again. + ## Routing The runtime has two message routing hooks: @@ -211,7 +267,8 @@ to their own single-slot hooks: Both hooks are single-slot and no-op when unset, like the message hooks; an unset handler is still acknowledged. The platform acknowledgement is -adapter-owned: the Slack adapter returns an empty 2xx and preserves +adapter-owned: the Slack adapter sends an empty acknowledgement (an empty +2xx over HTTP) and preserves `response_url` and `trigger_id` on the `Raw` platform escape hatch. Under the default synchronous dispatch the handler runs before that acknowledgement, so long command or interaction work needs @@ -240,7 +297,8 @@ Slack configuration and handler patterns. ## Dispatch And Acknowledgement The default dispatch mode is synchronous (`DispatchSync`): handlers run on the -inbound webhook request context before the platform acknowledgement. For +inbound webhook request context, or on the delivery context of a listener, +before the platform acknowledgement. For long-running work, opt in to `DispatchDeferred` (ack-then-work, [ADR 0002](adr/0002-async-dispatch.md)): the dedupe and lock prelude runs before the acknowledgement, then the handler runs on a detached work context @@ -248,11 +306,13 @@ with automatic lock lease renewal, bounded by `DetachTimeout` (which must be positive under deferred dispatch). Under deferred dispatch `MaxDetached` bounds admitted-but-incomplete deliveries; a delivery arriving at the bound is rejected with `chat.ErrAdmissionRejected` before acknowledgement and before -dedupe marking ([ADR 0015](adr/0015-runtime-coordination.md)). +dedupe marking ([ADR 0015](adr/0015-runtime-coordination.md)). After +`Shutdown` starts, every new delivery gets the same error, in both dispatch +modes. The [deferred dispatch guide](how-to/deferred-dispatch.md) covers enabling it and writing handlers for the detached context. -Once a webhook is verified and normalized into an accepted event, handler +Once a delivery is verified and normalized into an accepted event, handler errors are logged and observed but the event is still acknowledged to the platform. This avoids platform retry storms after partial side effects such as posting a message. @@ -494,7 +554,8 @@ if !ok { `chat.AdapterAs[T](bot, name)` returns `(T, bool)`; prefer it over unchecked type assertions. Optional capabilities (`NativeContentPoster`, -`HistoryReader`, `EphemeralPoster`) are reached the same way. +`HistoryReader`, `EphemeralPoster`) are reached the same way. The runtime +runs the `Listener` capability itself, through `Chat.Listen`. ## Adapter Capability Status @@ -505,6 +566,8 @@ platform-specific surfaces differ: | Capability | Slack | Linear | | --- | --- | --- | | Message events (mentions, subscribed threads, DMs) | Yes | Yes (agent sessions and issue comments) | +| Webhook handler (`Chat.Webhook`) | Yes (with `SigningSecret`) | Yes | +| Listener (`Chat.Listen`) | Yes (Socket Mode, with `AppToken`) | No (unsupported capability) | | Ephemeral messages with explicit DM fallback (`Thread.PostEphemeral`) | Yes | No (unsupported capability); Linear-specific ephemeral *thoughts* via `PostThought` on agent-session threads | | Slash commands (`OnCommand`) | Yes | No (no platform equivalent) | | Interactive components (`OnInteraction`) | Yes (message `block_actions`; modal-view actions are not normalized) | No | @@ -524,6 +587,11 @@ The Slack adapter (`adapters/slack`) is the `supported` adapter. It covers: - single-install configuration, and multi-tenant installs via an application-implemented `InstallStore` ([ADR 0006](adr/0006-multi-tenant-install.md)) +- two transports, alone or together: the HTTP webhook handler with + `Options.SigningSecret`, and Socket Mode through `Chat.Listen` with + `Options.AppToken`, an app-level token (`xapp-`) with the + `connections:write` scope ([ADR 0017](adr/0017-slack-socket-mode.md)); + at least one is required - signing secret verification and URL verification - bot identity discovery during adapter initialization - supported-shape decoding with unknown-field tolerance @@ -547,7 +615,7 @@ The adapter uses local structs for the Slack payload shapes it supports, preserves raw payload data as an escape hatch, and validates required fields for supported event types. It is not a complete Slack product surface; see [Intentional Gaps](explanation.md#intentional-gaps). Its Web API calls, -rate-limit retry, and signature check go through the +rate-limit retry, and both transports go through the [`slackapi`](#slack-api-package) package. ### Linear Adapter @@ -596,8 +664,9 @@ yet wrapped is in [linear-agent-capabilities.md](linear-agent-capabilities.md). own their runtime (their own state, dispatch, and routing) and so do not use `chat.New`. It works on raw Slack IDs and holds Slack protocol and data formats only: it has no runtime, state, dispatch, or policy. The Slack -adapter uses it for its Web API calls, retry, signature check, and history -reads ([ADR 0016](adr/0016-slack-api-package.md)). +adapter uses it for its Web API calls, retry, history reads, and both inbound +transports ([ADR 0016](adr/0016-slack-api-package.md), +[ADR 0017](adr/0017-slack-socket-mode.md)). `slackapi` and `slackapitest` are `experimental`: their exported API may change before they are promoted to supported. `RetryPolicy` and @@ -682,8 +751,30 @@ Errors: - `UploadFile` names the failed step and wraps its error, so `errors.As` still finds `*APIError` and `*RateLimited`. -Inbound requests: - +Inbound deliveries: + +- A `Delivery` is one inbound payload: its `Type` (`DeliveryEventsAPI`, + `DeliverySlashCommands`, or `DeliveryInteractive`), the JSON `Payload`, and + `RetryAttempt` and `RetryReason` for a redelivery. It is the same for both + transports. A slash command payload is a JSON object of strings over both. +- A `Handler` handles a `Delivery` and gives the acknowledgement. A nil error + acknowledges the delivery, and a non-nil response is the acknowledgement + body, encoded as JSON. An error does not acknowledge it: + + | Handler error | `Webhook` answer | `Listen` answer | + | --- | --- | --- | + | nil | 200, with the response as a JSON body | ack, with the response as the payload | + | `ErrBusy` | 503 | no ack | + | `ErrInvalidPayload` | 400 | no ack | + | another error | 500 | no ack | + + Slack delivers an Events API payload again when it gets no ack. Slack + expects the ack within 3 seconds. +- `Webhook` is the HTTP transport, an `http.Handler` for the Events API, + slash command, and interactivity request URLs. It accepts only POST (else + 405) and a body of at most 1 MiB (else 413), checks the signature with + `VerifyRequest` (else 401), answers `url_verification` itself, and makes a + `Delivery` from each other request. - `VerifyRequest(header, body, secret, now, tolerance)` checks the Slack v0 signature. It rejects an empty secret. A tolerance of zero or less uses `DefaultSignatureTolerance` (5 minutes). @@ -695,6 +786,46 @@ Inbound requests: `message_changed` and `message_deleted`, `NewMessage` and `PreviousMessage` hold the changed message. +Socket Mode: + +- `(*Client).Listen(ctx, handler, ListenOptions)` is the Socket Mode + transport. Call it on a client with the app-level token (`xapp-`), for + example `client.WithToken(appToken).Listen(ctx, handler, opts)`. The token + needs the `connections:write` scope, and Socket Mode must be on in the app + settings. `Listen` opens the websocket with the client's HTTP client, so its + proxy settings apply. It gives each envelope to the handler as a `Delivery` + in its own goroutine, and acks the envelope on the connection that it came + on. +- `Listen` keeps one connection up. When Slack asks for a refresh, or sends + no ping for `PingTimeout`, `Listen` opens a new connection and closes the + old one only when the new one is up. A connection closes in this order: it + waits at most 3 seconds for its running handlers, sends a close frame, and + waits for Slack to close the connection. +- `Listen` retries other errors with backoff from `MinBackoff` to + `MaxBackoff`, waits at least the `Retry-After` of a rate limit, and gives + each error that it recovers from to `OnError`. It does not use the client's + `RetryPolicy`. When ctx is done, it closes every connection, waits for every + handler, and returns nil. +- `Listen` returns at once, and does not retry, when Slack rejects the token + (an `*APIError` with the code `not_authed`, `invalid_auth`, + `account_inactive`, `token_revoked`, `token_expired`, + `user_removed_from_team`, `team_disabled`, `not_allowed_token_type`, or + `missing_scope`) or turns Socket Mode off (a `*DisconnectError` with the + reason `link_disabled`). +- `OpenConnection(ctx)` calls `apps.connections.open` and returns the + websocket URL of a new connection. `Listen` calls it for each connection. + +| `ListenOptions` field | Empty value | +| --- | --- | +| `OnConnect` | No-op. Called with the `Hello` (`NumConnections`, `AppID`, `Host`) of each connection when it is up. | +| `OnError` | No-op. Called with each error that `Listen` recovers from, for example `ErrPingTimeout`, or a `*DisconnectError` with the reason `too_many_websockets`. | +| `PingTimeout` | 30 seconds. `Listen` starts to check it after the first ping. | +| `MinBackoff` | 1 second. | +| `MaxBackoff` | 1 minute. Also the wait after `too_many_websockets`. | + +`OnConnect` and `OnError` run on the goroutine that called `Listen`, so they +must return quickly. + Formats: - Block Kit: the `Block` interface, `MarkdownBlock`, `ActionsBlock` with @@ -725,13 +856,25 @@ For files, add `Server.FileOrigin()` to `Options.FileOrigins`, return with `ServeFile` or `HandleFile`, and read the requests with `FileRequests`. `SignRequest` signs a request so that `VerifyRequest` accepts it. +For Socket Mode, `Server.SocketMode(SocketModeOptions)` starts a fake Socket +Mode: `apps.connections.open` returns a `ws://` URL on the fake server, and +each connection gets hello and websocket pings. `Send` sends an envelope to a +random live connection, and the fake resends an envelope that gets no ack, as +Slack does. `Disconnect`, `DisconnectConn`, `StopPings`, and `Drop` make +Slack's refresh and failure cases. `Conns`, `Envelope`, `Envelopes`, and +`Closes` show which connection got each envelope and sent each ack, and +whether each close had a close frame. `WaitLive`, `WaitClosed`, and +`WaitAcked` wait for a state, with a timeout. + ## Examples Runnable, documented examples live in [`examples/`](../examples/). Pick one: - [`slack-hello-world`](../examples/slack-hello-world/README.md) — start here. - Memory state, no infrastructure; the [tutorial](tutorials/slack-bot.md) - walks through it end to end. + Memory state, no infrastructure. It uses Socket Mode when `SLACK_APP_TOKEN` + is set, so it needs no public URL, and serves HTTP when + `SLACK_SIGNING_SECRET` is set. The [tutorial](tutorials/slack-bot.md) walks + through it end to end. - [`slack-redis-state`](../examples/slack-redis-state/README.md), [`slack-postgres-state`](../examples/slack-postgres-state/README.md), [`slack-nats-state`](../examples/slack-nats-state/README.md) — the same bot From 943b0c1f3a14e6d16aaa7019e79386a5539507e4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:10:47 +0000 Subject: [PATCH 17/33] feat(examples/slack-hello-world): use Socket Mode when SLACK_APP_TOKEN is set --- .gitignore | 5 +- examples/slack-hello-world/README.md | 48 +++++++-- examples/slack-hello-world/main.go | 106 ++++++++++++++----- examples/slack-hello-world/main_test.go | 130 ++++++++++++++++++++++++ 4 files changed, 257 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 81180b9..1aece2f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/examples/slack-hello-world/README.md b/examples/slack-hello-world/README.md index 154c2ae..41fae96 100644 --- a/examples/slack-hello-world/README.md +++ b/examples/slack-hello-world/README.md @@ -8,26 +8,52 @@ This example requires `CHAT_DEMO_IN_MEMORY_STATE=1` because state is lost on restart. Use `examples/slack-redis-state`, `examples/slack-postgres-state`, or `examples/slack-nats-state` for durable Slack apps. +## Transports + +The bot receives Slack events over Socket Mode, over HTTP, or over both. Set +the environment variable of each transport that you want: + +| Variable | Transport | +| --- | --- | +| `SLACK_APP_TOKEN` | Socket Mode. The bot opens a websocket to Slack with `Chat.Listen`, so it needs no public URL. | +| `SLACK_SIGNING_SECRET` | HTTP. The bot serves the Slack webhook on `http://localhost:$PORT/webhooks/slack`, and Slack must reach it over public HTTPS. | + +Set at least one of them. With both, the bot runs both transports, and it +stops when one of them fails. + ## Slack App Setup -Set up a Slack app as in the [tutorial](../../docs/tutorials/slack-bot.md): -Steps 2–3 create the app and collect credentials, Step 5 exposes your -machine, and Step 6 subscribes to events. +Set up a Slack app as in the [tutorial](../../docs/tutorials/slack-bot.md). +Steps 2 and 3 create the app from a manifest with Socket Mode on, and collect +the credentials. The section +[Use HTTP Instead Of Socket Mode](../../docs/tutorials/slack-bot.md#use-http-instead-of-socket-mode) +sets up the HTTP transport. | Setting | Value | | --- | --- | | Bot token scopes | `chat:write`, `app_mentions:read` (add `im:history` for direct messages) | | Bot events | `app_mention` (add `message.im` for direct messages) | -| Request URL | `https://YOUR_PUBLIC_HOST/webhooks/slack` | | `SLACK_BOT_TOKEN` | **OAuth & Permissions** → Bot User OAuth Token (`xoxb-…`) | -| `SLACK_SIGNING_SECRET` | **Basic Information** → App Credentials → Signing Secret | +| `SLACK_APP_TOKEN` | **Basic Information** → App-Level Tokens, a token with the `connections:write` scope (`xapp-…`). Socket Mode must be on in **Socket Mode**. | +| `SLACK_SIGNING_SECRET` | **Basic Information** → App Credentials → Signing Secret. Only for HTTP. | +| Request URL | `https://YOUR_PUBLIC_HOST/webhooks/slack`. Only for HTTP. | Reinstall the app from **OAuth & Permissions** after changing scopes or events. ## Run -From the repository root: +From the repository root, with Socket Mode: + +```sh +export SLACK_APP_TOKEN="xapp-..." +export SLACK_BOT_TOKEN="xoxb-..." +export CHAT_DEMO_IN_MEMORY_STATE=1 + +go run ./examples/slack-hello-world +``` + +Or with HTTP: ```sh export SLACK_SIGNING_SECRET="..." @@ -52,11 +78,17 @@ The bot should reply in the same thread with portable Markdown: If you enabled `message.im`, you can also send a direct message to the bot. +Press `Ctrl-C` to stop the bot. It closes its Socket Mode connection and its +HTTP server, and then shuts down the runtime. + ## Notes - State is in memory, so subscriptions and dedupe data are lost when the process exits. This example is intended for local demos. -- Slack URL verification is handled by the Slack adapter at `/webhooks/slack`. -- Slack request signatures are verified with `SLACK_SIGNING_SECRET`. +- Both transports give each Slack payload to the same adapter pipeline, so the + bot behaves the same over each of them. +- Over HTTP, the Slack adapter answers Slack URL verification at + `/webhooks/slack`, and verifies request signatures with + `SLACK_SIGNING_SECRET`. - The bot token is used for `auth.test` during adapter startup and `chat.postMessage` when replying. diff --git a/examples/slack-hello-world/main.go b/examples/slack-hello-world/main.go index 76c9cf8..a445ba5 100644 --- a/examples/slack-hello-world/main.go +++ b/examples/slack-hello-world/main.go @@ -2,9 +2,12 @@ package main import ( "context" + "errors" "log/slog" "net/http" "os" + "os/signal" + "syscall" "time" "github.com/coder/chat" @@ -13,52 +16,109 @@ import ( ) func main() { - ctx := context.Background() - slogLogger := slog.Default() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() mustAllowDemoMemoryState() - slackAdapter, err := slack.New(ctx, slack.Options{ - SigningSecret: mustEnv("SLACK_SIGNING_SECRET"), + opts := slack.Options{ + AppToken: os.Getenv("SLACK_APP_TOKEN"), + SigningSecret: os.Getenv("SLACK_SIGNING_SECRET"), BotToken: mustEnv("SLACK_BOT_TOKEN"), - Logger: slogLogger, - }) + Logger: slog.Default(), + } + bot, err := newBot(ctx, opts) if err != nil { panic(err) } + defer func() { + if err := bot.Shutdown(context.Background()); err != nil { + slog.Error("chat shutdown failed", "error", err) + } + }() + + addr := ":" + os.Getenv("PORT") + if addr == ":" { + addr = ":8080" + } + if err := serve(ctx, bot, opts, addr); err != nil { + panic(err) + } +} + +func newBot(ctx context.Context, opts slack.Options) (*chat.Chat, error) { + slackAdapter, err := slack.New(ctx, opts) + if err != nil { + return nil, err + } bot, err := chat.New( ctx, chat.WithState(memory.New()), chat.WithAdapter(slackAdapter), - chat.WithLogger(slogLogger), + chat.WithLogger(opts.Logger), ) if err != nil { - panic(err) + return nil, err } - defer func() { - if err := bot.Shutdown(context.Background()); err != nil { - slog.Error("chat shutdown failed", "error", err) - } - }() bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error { _, err := ev.Thread.Post(ctx, chat.Markdown("**hello** _world_")) return err }) + return bot, nil +} - slackWebhook, err := bot.Webhook("slack") - if err != nil { - panic(err) +// serve receives Slack events until ctx is done or a transport fails. It runs +// Socket Mode when opts has an app token, and the webhook server on addr when +// opts has a signing secret. When one transport stops, serve stops the other. +func serve(ctx context.Context, bot *chat.Chat, opts slack.Options, addr string) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var transports []func() error + if opts.AppToken != "" { + transports = append(transports, func() error { + return bot.Listen(ctx, "slack") + }) + } + if opts.SigningSecret != "" { + slackWebhook, err := bot.Webhook("slack") + if err != nil { + return err + } + server := newWebhookServer(addr, slackWebhook) + transports = append(transports, func() error { + return runWebhookServer(ctx, server) + }) } - addr := ":" + os.Getenv("PORT") - if addr == ":" { - addr = ":8080" + errs := make(chan error, len(transports)) + for _, transport := range transports { + go func() { + defer cancel() + errs <- transport() + }() } - slog.Info("listening", "addr", addr) - server := newWebhookServer(addr, slackWebhook) - if err := server.ListenAndServe(); err != nil { - panic(err) + var err error + for range transports { + err = errors.Join(err, <-errs) + } + return err +} + +// runWebhookServer serves until ctx is done, and then shuts the server down. +func runWebhookServer(ctx context.Context, server *http.Server) error { + slog.Info("serving Slack webhooks", "addr", server.Addr) + served := make(chan error, 1) + go func() { served <- server.ListenAndServe() }() + + select { + case err := <-served: + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return server.Shutdown(shutdownCtx) } } diff --git a/examples/slack-hello-world/main_test.go b/examples/slack-hello-world/main_test.go index 56441a2..a965ad5 100644 --- a/examples/slack-hello-world/main_test.go +++ b/examples/slack-hello-world/main_test.go @@ -1,12 +1,142 @@ package main import ( + "context" + "encoding/json" + "errors" + "log/slog" "net/http" "net/http/httptest" "testing" "time" + + "github.com/coder/chat/adapters/slack" + "github.com/coder/chat/adapters/slack/slackapi" + "github.com/coder/chat/adapters/slack/slackapi/slackapitest" +) + +const ( + testAppToken = "xapp-test" + testWait = 5 * time.Second ) +// testOptions returns Slack options on srv with a known bot identity, so that +// adapter startup does not call auth.test. +func testOptions(srv *slackapitest.Server) slack.Options { + return slack.Options{ + BotToken: "xoxb-test", + TeamID: "T1", + BotUserID: "UBOT", + BotID: "BBOT", + APIBaseURL: srv.URL(), + Client: srv.Client(), + Logger: slog.New(slog.DiscardHandler), + } +} + +// startServe builds the bot of opts and runs serve in a goroutine. stop +// cancels the context of serve and returns the error of serve. served gets +// the error of serve when serve returns on its own. +func startServe(t *testing.T, opts slack.Options, addr string) (stop func() error, served <-chan error) { + t.Helper() + ctx, cancel := context.WithCancel(t.Context()) + bot, err := newBot(ctx, opts) + if err != nil { + t.Fatalf("new bot: %v", err) + } + t.Cleanup(func() { + if err := bot.Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown: %v", err) + } + }) + + result := make(chan error, 1) + go func() { result <- serve(ctx, bot, opts, addr) }() + stop = func() error { + cancel() + return wait(t, result) + } + return stop, result +} + +func wait(t *testing.T, served <-chan error) error { + t.Helper() + select { + case err := <-served: + return err + case <-time.After(testWait): + t.Fatal("serve did not return") + return nil + } +} + +func TestServeRepliesOverSocketMode(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: testAppToken, ResendAfter: -1}) + srv.Respond("chat.postMessage", map[string]any{"ok": true, "channel": "C1", "ts": "111.001"}) + opts := testOptions(srv) + opts.AppToken = testAppToken + stop, _ := startServe(t, opts, "") + + sm.WaitLive(t, testWait, 1) + sm.WaitAcked(t, testWait, sm.Send(string(slackapi.DeliveryEventsAPI), json.RawMessage(`{ + "type":"event_callback", + "team_id":"T1", + "event_id":"EvMention", + "event":{"type":"app_mention","channel":"C1","user":"U1","text":"<@UBOT> hi","ts":"111.000"} + }`))) + + calls := srv.Calls("chat.postMessage") + if len(calls) != 1 { + t.Fatalf("chat.postMessage calls = %d, want 1", len(calls)) + } + var post slackapi.PostMessageRequest + if err := json.Unmarshal(calls[0].Body, &post); err != nil { + t.Fatalf("decode chat.postMessage body: %v", err) + } + if post.Channel != "C1" || post.ThreadTS != "111.000" || post.MarkdownText != "**hello** _world_" { + t.Errorf("chat.postMessage = %+v, want **hello** _world_ in thread 111.000 of C1", post) + } + + if err := stop(); err != nil { + t.Errorf("serve: %v", err) + } +} + +func TestServeStopsWebhookServerWhenSocketModeFails(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.SocketMode(slackapitest.SocketModeOptions{AppToken: testAppToken}) + opts := testOptions(srv) + opts.AppToken = "xapp-revoked" + opts.SigningSecret = "secret" + _, served := startServe(t, opts, "127.0.0.1:0") + + err := wait(t, served) + if apiErr, ok := errors.AsType[*slackapi.APIError](err); !ok || apiErr.Code != "invalid_auth" { + t.Errorf("serve: err = %v, want the invalid_auth error of Socket Mode", err) + } +} + +func TestServeWithoutAppTokenServesOnlyWebhooks(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + opts := testOptions(srv) + opts.SigningSecret = "secret" + stop, _ := startServe(t, opts, "127.0.0.1:0") + + if err := stop(); err != nil { + t.Errorf("serve: %v", err) + } + if calls := srv.Calls("apps.connections.open"); len(calls) != 0 { + t.Errorf("apps.connections.open calls = %d, want 0", len(calls)) + } +} + func TestNewWebhookServer(t *testing.T) { server := newWebhookServer(":0", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) From 470e0dc7342220c2fa7ec2ec8413a25cebce17ea Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:13:27 +0000 Subject: [PATCH 18/33] docs(tutorials): make Socket Mode the main path of the Slack tutorial --- docs/tutorials/slack-bot.md | 248 +++++++++++++++++++++++------------- documentation_test.go | 44 +++++++ 2 files changed, 203 insertions(+), 89 deletions(-) diff --git a/docs/tutorials/slack-bot.md b/docs/tutorials/slack-bot.md index ea2a0be..faa11ec 100644 --- a/docs/tutorials/slack-bot.md +++ b/docs/tutorials/slack-bot.md @@ -5,15 +5,18 @@ replies when you mention it. You will create a Slack app, run the bundled hello-world example against your workspace, and then make your first change to the bot's behavior. +The bot connects to Slack over Socket Mode: it opens a websocket to Slack, and +Slack sends events over it. So you do not need a public URL or a tunnel. +Slack's own Bolt guides also recommend Socket Mode to start with. The +section [Use HTTP Instead Of Socket Mode](#use-http-instead-of-socket-mode) +shows the other transport, the Events API over HTTP. + Expect the whole tutorial to take under 30 minutes. ## What You Need - Go 1.26.3 or newer (`go version`). - A Slack workspace where you are allowed to create and install apps. -- A way to expose local port 8080 to the public internet over HTTPS, such as - [Tailscale Funnel](https://tailscale.com/kb/1223/funnel), `ngrok`, or - `cloudflared`. Slack delivers events by calling your bot over HTTPS. You do not need Docker, Redis, Postgres, or any other service: this tutorial uses the in-memory state backend. @@ -34,58 +37,93 @@ The example you are about to run lives in [`examples/slack-hello-world/main.go`](../../examples/slack-hello-world/main.go). Its whole job is: -1. Build a Slack adapter from a signing secret and a bot token. +1. Build a Slack adapter from an app-level token and a bot token. 2. Build a `chat.Chat` runtime with in-memory state and that adapter. 3. Register one handler: when the bot is mentioned, reply with `**hello** _world_` in the same thread. -4. Serve the Slack webhook on `http://localhost:8080/webhooks/slack`. +4. Call `Chat.Listen`, which connects to Slack over Socket Mode and receives + events until you stop the bot. ## Step 2: Create A Slack App -Open the Slack app dashboard and create a new app (from scratch) in your -workspace: +Open the Slack app dashboard: -Then configure it: +Click **Create New App**, choose **From a manifest**, and pick your workspace. +In the **JSON** tab, replace the example with this manifest: + +```json +{ + "display_information": { + "name": "Hello Bot" + }, + "features": { + "app_home": { + "home_tab_enabled": false, + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": false + }, + "bot_user": { + "display_name": "Hello Bot", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": ["chat:write", "app_mentions:read", "im:history"] + } + }, + "settings": { + "event_subscriptions": { + "bot_events": ["app_mention", "message.im"] + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false + } +} +``` -1. In **OAuth & Permissions**, under **Bot Token Scopes**, add: +Click **Next**, check the summary, and click **Create**. The manifest sets up +the app like this: - | Scope | Why the bot needs it | - | --- | --- | - | `chat:write` | Post the reply with `chat.postMessage`. | - | `app_mentions:read` | Receive `app_mention` events when the bot is mentioned. | - | `im:history` | Only needed if you also want direct messages to reach the bot. | +| Setting | Why the bot needs it | +| --- | --- | +| `chat:write` scope | Post the reply with `chat.postMessage`. | +| `app_mentions:read` scope, `app_mention` event | Receive an event when the bot is mentioned. | +| `im:history` scope, `message.im` event | Receive direct messages. Mentions in channels work without them. | +| `socket_mode_enabled` | Turn on Socket Mode, so Slack sends events over the bot's websocket and not to a request URL. | +| `messages_tab_enabled`, `messages_tab_read_only_enabled` | Let users send direct messages to the bot from its **Messages** tab. | -2. In **App Home**, under **Show Tabs**, enable the **Messages Tab** and allow - users to send messages from it (depending on Slack's current UI, this is - labeled "Allow users to send Slash commands and messages from the messages - tab" or appears as turning off read-only mode). This matters only for - direct messages; mentions in channels work without it. If Slack later - shows **"Sending messages to this app has been turned off"** in the app's - DM, this setting is still off. +The package `slackapi` has a `Manifest` type that encodes this format, if you +want to make manifests in Go. -3. In **OAuth & Permissions**, click **Install to Workspace** and approve the - app. +Then, in **OAuth & Permissions**, click **Install to Workspace** and approve +the app. ## Step 3: Collect Credentials -You need two secrets. Treat both like passwords. +You need two tokens. Treat both like passwords. 1. In **OAuth & Permissions**, copy the **Bot User OAuth Token**. It starts with `xoxb-`. This becomes `SLACK_BOT_TOKEN`. -2. In **Basic Information**, under **App Credentials**, copy the - **Signing Secret**. This becomes `SLACK_SIGNING_SECRET`. +2. In **Basic Information**, under **App-Level Tokens**, click **Generate Token + and Scopes**. Give the token a name, such as `socket-mode`, add the + `connections:write` scope, and click **Generate**. Copy the token. It + starts with `xapp-`. This becomes `SLACK_APP_TOKEN`. + +The app-level token lets the bot open Socket Mode connections. Check that +**Enable Socket Mode** is on in **Socket Mode**. The manifest turned it on. ## Step 4: Run The Bot From the repository root: ```sh -export SLACK_SIGNING_SECRET="..." +export SLACK_APP_TOKEN="xapp-..." export SLACK_BOT_TOKEN="xoxb-..." export CHAT_DEMO_IN_MEMORY_STATE=1 -export PORT=8080 go run ./examples/slack-hello-world ``` @@ -97,59 +135,18 @@ covers the durable options. On startup the adapter calls Slack's `auth.test` with your bot token to discover the bot's own identity. If the token is wrong you find out now, not -on the first message. When the bot is up you should see a log line like: +on the first message. Then the bot opens a Socket Mode connection with your +app-level token. When the bot is connected you should see a log line like: ```text -2026/09/23 14:00:00 INFO listening addr=:8080 -``` - -## Step 5: Expose The Bot To Slack - -Slack must be able to reach your machine over public HTTPS. In a second -terminal, expose port 8080 with your tunnel of choice. - -With ngrok: - -```sh -ngrok http 8080 -``` - -With Tailscale Funnel (Funnel must be enabled for your tailnet; `tailscale -serve` does not work, because it is visible only inside your tailnet): - -```sh -tailscale funnel --bg --https=443 localhost:8080 -tailscale funnel status +2026/09/23 14:00:00 INFO slack socket mode connected adapter=slack host=applink-1 num_connections=1 app_id=A0123456789 ``` -When you finish the tutorial, run `tailscale funnel reset` to turn the -public endpoint off. - -Either way you end up with a public HTTPS URL such as -`https://your-host.example.com`. Keep the tunnel running. - -## Step 6: Subscribe To Events - -Back in the Slack app dashboard: - -1. In **Event Subscriptions**, enable events. -2. Set the **Request URL** to: - - ```text - https://YOUR_PUBLIC_HOST/webhooks/slack - ``` - - Slack immediately sends a `url_verification` challenge. The Slack adapter - answers it automatically; the dashboard should show **Verified** within a - few seconds. If it does not, check that the bot from Step 4 and the tunnel - from Step 5 are both still running. +If Slack rejects the app-level token, the bot stops with an error such as +`invalid_auth`. Check Step 3. If you turn Socket Mode off while the bot runs, +Slack disconnects it with the reason `link_disabled`, and the bot stops. -3. Under **Subscribe to bot events**, add `app_mention` (and `message.im` if - you added `im:history` in Step 2). -4. Save changes. If Slack prompts you to reinstall the app, do it from - **OAuth & Permissions**. - -## Step 7: Talk To Your Bot +## Step 5: Talk To Your Bot In Slack, invite the bot to a channel and mention it: @@ -159,22 +156,27 @@ In Slack, invite the bot to a channel and mention it: ``` The bot replies in a thread on your message with `**hello** _world_`, -rendered with bold and italics. You have a running Slack bot. +rendered with bold and italics. You have a running Slack bot. You can also +send a direct message to the bot. + +If Slack shows **"Sending messages to this app has been turned off"** in the +app's DM, the **Messages** tab is still read-only. In **App Home**, under +**Show Tabs**, allow users to send messages from the **Messages** tab. -## Step 8: Make It Yours +## Step 6: Make It Yours The bot currently answers every mention but forgets the conversation immediately. Make it stay in the conversation. -First, let Slack deliver unmentioned channel messages to your bot — without -this, only mentions ever reach it: +First, let Slack deliver unmentioned channel messages to your bot. Without +this, only mentions reach it: 1. In **OAuth & Permissions**, add the `channels:history` bot scope. -2. In **Event Subscriptions**, add the `message.channels` bot event. +2. In **Event Subscriptions**, under **Subscribe to bot events**, add the + `message.channels` bot event, and save the changes. 3. Reinstall the app from **OAuth & Permissions**. -(If you subscribed to `message.im` in Step 6, you can skip this and test the -follow-up flow in a direct message instead.) +(You can skip this and test the follow-up flow in a direct message instead.) Then open `examples/slack-hello-world/main.go` and replace the `OnNewMention` handler registration with: @@ -196,16 +198,78 @@ bot.OnSubscribedMessage(func(ctx context.Context, ev *chat.MessageEvent) error { Restart the bot (`Ctrl-C`, then `go run ./examples/slack-hello-world` again) and mention it once more. From then on, follow-up messages in that thread get -echoed back — no mention required. (If you type several messages faster than -the bot replies, some may be skipped: the default concurrency strategy drops -events that arrive while the thread's previous event is still being handled.) -Two things to notice: +echoed back, with no mention required. (If you type several messages faster +than the bot replies, some may be skipped: the default concurrency strategy +drops events that arrive while the thread's previous event is still being +handled.) Two things to notice: - Replying never subscribes a thread. `Thread.Subscribe` is always an explicit decision, and it lasts until you call `Thread.Unsubscribe`. - Subscriptions live in runtime state. Because this example uses in-memory state, restarting the bot forgets them. +## Use HTTP Instead Of Socket Mode + +Over HTTP, Slack sends each event to a public request URL of your bot, through +the Events API. Slack's Bolt guides recommend HTTP for apps that run on a +hosting service, apps in large Slack organizations, and apps for the Slack +Marketplace. Both transports use the same adapter pipeline, so your handlers do +not change. + +The example serves HTTP when `SLACK_SIGNING_SECRET` is set. It can run both +transports at the same time. To use HTTP: + +1. In **Basic Information**, under **App Credentials**, copy the **Signing + Secret**. This becomes `SLACK_SIGNING_SECRET`. +2. Run the bot with the signing secret. Leave out `SLACK_APP_TOKEN` to use only + HTTP: + + ```sh + export SLACK_SIGNING_SECRET="..." + export SLACK_BOT_TOKEN="xoxb-..." + export CHAT_DEMO_IN_MEMORY_STATE=1 + export PORT=8080 + + go run ./examples/slack-hello-world + ``` + + The bot serves the Slack webhook on + `http://localhost:8080/webhooks/slack`, and logs + `INFO serving Slack webhooks addr=:8080`. +3. Expose port 8080 over public HTTPS, so that Slack can reach your machine. + In a second terminal, use a tunnel such as + [Tailscale Funnel](https://tailscale.com/kb/1223/funnel), `ngrok`, or + `cloudflared`. With ngrok: + + ```sh + ngrok http 8080 + ``` + + With Tailscale Funnel (Funnel must be enabled for your tailnet; `tailscale + serve` does not work, because it is visible only inside your tailnet): + + ```sh + tailscale funnel --bg --https=443 localhost:8080 + tailscale funnel status + ``` + + When you finish, run `tailscale funnel reset` to turn the public endpoint + off. Either way you get a public HTTPS URL such as + `https://your-host.example.com`. Keep the tunnel running. +4. In **Socket Mode**, turn **Enable Socket Mode** off. While it is on, Slack + sends events over Socket Mode and not to the request URL. +5. In **Event Subscriptions**, set the **Request URL** to: + + ```text + https://YOUR_PUBLIC_HOST/webhooks/slack + ``` + + Slack immediately sends a `url_verification` challenge. The Slack adapter + answers it automatically; the dashboard should show **Verified** within a + few seconds. If it does not, check that the bot and the tunnel are both + still running. Save the changes. If Slack prompts you to reinstall the app, + do it from **OAuth & Permissions**. + ## Where To Go Next - [Choose a state backend](../how-to/choose-a-state-backend.md) to keep @@ -220,12 +284,18 @@ Two things to notice: Slack's own documentation for the settings used here: - App dashboard: -- Events API request URLs: +- App manifests: +- Socket Mode: +- HTTP and Socket Mode compared: + +- Events API request URLs: + - Events: [`app_mention`](https://docs.slack.dev/reference/events/app_mention/), [`message.channels`](https://docs.slack.dev/reference/events/message.channels), [`message.im`](https://docs.slack.dev/reference/events/message.im) - App Home Messages tab: -- Bot tokens (`xoxb-`): +- Bot tokens (`xoxb-`) and app-level tokens (`xapp-`): + - Scopes: [`chat:write`](https://docs.slack.dev/reference/scopes/chat.write); [`conversations.open`](https://docs.slack.dev/reference/methods/conversations.open/) lists the scopes DM fallback needs diff --git a/documentation_test.go b/documentation_test.go index 92ba36f..56d36c4 100644 --- a/documentation_test.go +++ b/documentation_test.go @@ -1,12 +1,16 @@ package chat_test import ( + "encoding/json" "os" "os/exec" "path/filepath" "regexp" + "slices" "strings" "testing" + + "github.com/coder/chat/adapters/slack/slackapi" ) // TestDocumentationCoversIntentionalVercelDifferences pins each documented @@ -214,6 +218,46 @@ func TestLinearHowToSnippetsAreExtractedFromBuildableSource(t *testing.T) { } } +// TestDocumentationTutorialManifestIsSlackManifest keeps the app manifest of +// the Slack tutorial in the format of slackapi.Manifest, with Socket Mode on, +// no request URL, and the scopes and events that the hello-world bot needs. +func TestDocumentationTutorialManifestIsSlackManifest(t *testing.T) { + t.Parallel() + + doc, err := os.ReadFile("docs/tutorials/slack-bot.md") + if err != nil { + t.Fatalf("read tutorial: %v", err) + } + match := regexp.MustCompile("(?s)```json\n(.*?)```").FindStringSubmatch(string(doc)) + if match == nil { + t.Fatal("tutorial has no JSON manifest") + } + dec := json.NewDecoder(strings.NewReader(match[1])) + dec.DisallowUnknownFields() + var manifest slackapi.Manifest + if err := dec.Decode(&manifest); err != nil { + t.Fatalf("decode tutorial manifest: %v", err) + } + + settings := manifest.Settings + if settings == nil || !settings.SocketModeEnabled { + t.Fatal("tutorial manifest does not turn on Socket Mode") + } + if settings.EventSubscriptions == nil || settings.EventSubscriptions.RequestURL != "" { + t.Errorf("tutorial manifest event subscriptions = %+v, want bot events and no request URL", settings.EventSubscriptions) + } else if !slices.Contains(settings.EventSubscriptions.BotEvents, "app_mention") { + t.Errorf("tutorial manifest bot events = %v, want app_mention", settings.EventSubscriptions.BotEvents) + } + if manifest.OAuthConfig == nil || manifest.OAuthConfig.Scopes == nil { + t.Fatal("tutorial manifest has no bot scopes") + } + for _, scope := range []string{"chat:write", "app_mentions:read"} { + if !slices.Contains(manifest.OAuthConfig.Scopes.Bot, scope) { + t.Errorf("tutorial manifest bot scopes = %v, want %s", manifest.OAuthConfig.Scopes.Bot, scope) + } + } +} + // TestREADMEMarkedSnippetsBuild keeps the README's hello-world honest: every // fenced Go block annotated with a `` marker must be a complete // program that compiles against the current module. From 10402c21046f8ff4f20e4e3ebe19c2956439bd7e Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:18:21 +0000 Subject: [PATCH 19/33] docs: lead the README with Socket Mode and cover both transports in the how-to guides --- README.md | 41 ++++++++++++++++----------- docs/README.md | 10 +++---- docs/how-to/deferred-dispatch.md | 37 +++++++++++++----------- docs/how-to/interactive-components.md | 15 ++++++---- docs/how-to/multi-tenant-install.md | 11 +++---- docs/how-to/slash-commands.md | 12 +++++--- docs/reference.md | 3 +- 7 files changed, 75 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 2fbf227..c8674d3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -27,7 +28,6 @@ package main import ( "context" "log" - "net/http" "os" "github.com/coder/chat" @@ -39,8 +39,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) @@ -59,26 +59,28 @@ func main() { return err }) - webhook, err := bot.Webhook("slack") - if err != nil { + if err := bot.Listen(ctx, "slack"); 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 @@ -101,8 +103,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 — diff --git a/docs/README.md b/docs/README.md index fb60d3b..62aab2b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ explanation to understand the design. Start here if you are new to the SDK. - [Your first Slack bot](tutorials/slack-bot.md) — zero to a running Slack bot - in under 30 minutes. + in under 30 minutes, over Socket Mode with no public URL. ## How-To Guides @@ -18,7 +18,7 @@ For people who already have a bot running. - [Choose a state backend](how-to/choose-a-state-backend.md) — memory, Redis, Postgres, or NATS JetStream. - [Defer long-running work (ack-then-work)](how-to/deferred-dispatch.md) — - acknowledge webhooks fast and run handlers on a detached context. + acknowledge deliveries fast and run handlers on a detached context. - [Handle slash commands](how-to/slash-commands.md) — route Slack slash commands through `OnCommand`. - [Handle interactive components](how-to/interactive-components.md) — buttons, @@ -31,9 +31,9 @@ For people who already have a bot running. ## Reference - [Reference](reference.md) — modules and packages, how the runtime behaves - (routing, dispatch, state, concurrency, messages, history), what each - adapter supports, and the runnable examples. The API itself is in the - [GoDoc](https://pkg.go.dev/github.com/coder/chat). + (deliveries, routing, dispatch, state, concurrency, messages, history), + what each adapter supports, and the runnable examples. The API itself is + in the [GoDoc](https://pkg.go.dev/github.com/coder/chat). - [Linear agent capabilities](linear-agent-capabilities.md) — what the Linear adapter supports today and what it does not wrap yet. diff --git a/docs/how-to/deferred-dispatch.md b/docs/how-to/deferred-dispatch.md index dc0cfa9..8440627 100644 --- a/docs/how-to/deferred-dispatch.md +++ b/docs/how-to/deferred-dispatch.md @@ -1,9 +1,9 @@ # How To Defer Long-Running Work (Ack-Then-Work) -Slack retries a webhook that is not acknowledged within 3 seconds. Linear -expects a first agent activity within about 10 seconds. By default the runtime -runs your handler *before* it acknowledges the webhook, so a handler that -calls an LLM can easily miss those deadlines. +Slack retries an event that is not acknowledged within 3 seconds, over HTTP +and over Socket Mode. Linear expects a first agent activity within about 10 +seconds. By default the runtime runs your handler *before* it acknowledges the +delivery, so a handler that calls an LLM can easily miss those deadlines. Deferred dispatch fixes this. The acknowledgement no longer waits for your handler, which runs on a detached context @@ -31,7 +31,7 @@ Start from `chat.DefaultRuntimeOptions()` so you keep the required | Option | Default | What it does | | --- | --- | --- | | `Dispatch` | `DispatchSync` | `DispatchDeferred` turns on ack-then-work. | -| `DetachTimeout` | `0` | How long a handler may run after the webhook request ends. Required under deferred dispatch: `chat.New` fails while it is zero. | +| `DetachTimeout` | `0` | How long a handler may run after the acknowledgement. Required under deferred dispatch: `chat.New` fails while it is zero. | | `Concurrency` | `ConcurrencyDrop` | What happens to an event that arrives while a handler holds the thread lock. See [Pick a concurrency strategy](#pick-a-concurrency-strategy). | | `MaxDetached` | `1024` | The cap on deferred work in flight. Must be positive under deferred dispatch. See [Handle overload](#handle-overload). | @@ -39,12 +39,14 @@ Start from `chat.DefaultRuntimeOptions()` so you keep the required Dispatch runs in two parts: -1. **Before the acknowledgement**, on the request context: signature - verification, normalization, dedupe marking, and thread lock acquisition. +1. **Before the acknowledgement**, on the context of the delivery: signature + verification (over HTTP), normalization, dedupe marking, and thread lock + acquisition. 2. **Launched at acknowledgement time**, on a runtime-managed detached - context: your handler. It runs concurrently with the webhook response and - may start just before the 2xx is written. While it runs, the runtime renews the - thread lock lease in the background. + context: your handler. It runs concurrently with the acknowledgement and + may start just before the adapter sends it (the 2xx over HTTP, the ack + over Socket Mode). While it runs, the runtime renews the thread lock lease + in the background. If the lease is lost — the state backend fails to extend it, it expires, or another runtime instance releases it — the runtime cancels the handler's @@ -56,7 +58,7 @@ that ignores its context keeps running. ## Write Handlers For The Detached Context Handlers keep the same signature. Under deferred dispatch the `ctx` they -receive is the detached context, not the HTTP request context: +receive is the detached context, not the context of the delivery: ```go bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error { @@ -115,7 +117,7 @@ Two caveats apply to the strategies that make events wait: shared state lock still serializes handlers, but follow-ups that landed on different replicas each run in turn. The same holds for debounce and burst batching. If a superseded event must never run, route each thread's - webhooks to one replica or make handlers idempotent. + deliveries to one replica or make handlers idempotent. `ConcurrencyQueue` suits most conversational bots: a follow-up sent while the bot is still working waits instead of disappearing. @@ -133,11 +135,12 @@ A delivery that arrives at the cap is rejected with marking, so a later redelivery is not mistaken for a duplicate. The adapter answers the platform: -| Delivery | Response | -| --- | --- | -| Slack Events API callback, Linear webhook | 503, so the platform retries. | -| Slack slash command | 200 with a visible "at capacity" message. | -| Slack `block_actions` click | 503, which Slack shows as a warning on the component. | +| Delivery | Response over HTTP | Response over Socket Mode | +| --- | --- | --- | +| Slack Events API callback | 503, so Slack retries. | No ack, so Slack retries. | +| Linear webhook | 503, so Linear retries. | Not applicable. | +| Slack slash command | 200 with a visible "at capacity" message. | An ack with the same message. | +| Slack `block_actions` click | 503, which Slack shows as a warning on the component. | No ack, which Slack shows as a warning on the component. | Slack does not redeliver slash commands or clicks, so those get an honest busy signal instead of a retry. diff --git a/docs/how-to/interactive-components.md b/docs/how-to/interactive-components.md index de228fa..7e23b3c 100644 --- a/docs/how-to/interactive-components.md +++ b/docs/how-to/interactive-components.md @@ -13,13 +13,17 @@ typed adapter access. ## Configure Slack -In your Slack app dashboard, under **Interactivity & Shortcuts**, enable -interactivity and set the **Request URL** to your existing webhook: +Over HTTP, in your Slack app dashboard, under **Interactivity & +Shortcuts**, enable interactivity and set the **Request URL** to your +existing webhook: ```text https://YOUR_PUBLIC_HOST/webhooks/slack ``` +Over Socket Mode, Slack turns on interactivity for you and sends clicks over +the bot's connection, so you do not set a Request URL. + ## Post Something Clickable Block Kit content is `NativeContent`, posted through the Slack adapter's @@ -115,8 +119,9 @@ mention, post Block Kit content with an `mrkdwn` text element containing ### Acknowledging In Time -Under the default `DispatchSync`, the adapter sends Slack its empty 2xx only -after your handler returns, so a slow handler can miss Slack's 3-second +Under the default `DispatchSync`, the adapter sends Slack its empty 2xx (or +its ack over Socket Mode) only after your handler returns, so a slow handler +can miss Slack's 3-second budget. Enable [deferred dispatch](deferred-dispatch.md), usually with `chat.ConcurrencyQueue`, so the acknowledgement no longer waits for your handler. @@ -161,6 +166,6 @@ err := slackAdapter.RespondURL(ctx, ev.Interaction.Raw, chat.Text("Working on it modal view have no channel or message to anchor a thread. They are not normalized yet; the adapter rejects them before routing. - **Modal submissions are not delivered.** Slack expects the answer to a - `view_submission` in the webhook's HTTP response body, which does not fit + `view_submission` in the acknowledgement body, which does not fit ack-then-work. The adapter acknowledges and drops `view_submission` payloads, so your code cannot read submitted values today. diff --git a/docs/how-to/multi-tenant-install.md b/docs/how-to/multi-tenant-install.md index b070676..da5dd6c 100644 --- a/docs/how-to/multi-tenant-install.md +++ b/docs/how-to/multi-tenant-install.md @@ -2,13 +2,13 @@ By default an adapter serves one Slack workspace or one Linear organization, with credentials passed in its options. Multi-tenant mode lets one deployment -serve many: the adapter looks up each tenant's credentials when a webhook +serve many: the adapter looks up each tenant's credentials when a delivery arrives ([ADR 0006](../adr/0006-multi-tenant-install.md)). The split of work is deliberate: - **The adapter looks up credentials.** You implement a `chat.InstallStore`; - the adapter calls it with the tenant from each webhook (the Slack team ID + the adapter calls it with the tenant from each delivery (the Slack team ID or the Linear organization ID). - **You own the OAuth flow.** The install redirect, the callback route, the token exchange, and the install database are ordinary routes and storage in @@ -97,9 +97,10 @@ linearAdapter, err := linear.New(ctx, linear.Options{ The two adapters verify requests at different times: -- **Slack** has one signing secret for the whole app. The adapter verifies - the signature before it calls your store, so the tenant your store sees - came from a verified request. +- **Slack** has one signing secret and one app-level token for the whole + app. Over HTTP, the adapter verifies the signature before it calls your + store. Over Socket Mode, the deliveries come over a connection that the + app-level token opened. So the tenant your store sees came from Slack. - **Linear** has a webhook secret per install. The adapter must read the organization ID from the **unverified** body and call `Lookup` to get the secret it verifies with. diff --git a/docs/how-to/slash-commands.md b/docs/how-to/slash-commands.md index 880b8c1..8018948 100644 --- a/docs/how-to/slash-commands.md +++ b/docs/how-to/slash-commands.md @@ -8,15 +8,19 @@ Slack is the only adapter with slash commands. ## Configure Slack In your Slack app dashboard, under **Slash Commands**, create the command -(for example `/deploy`) and set its **Request URL** to the same webhook you -already mounted: +(for example `/deploy`). Over HTTP, set its **Request URL** to the same +webhook you already mounted: ```text https://YOUR_PUBLIC_HOST/webhooks/slack ``` -The Slack adapter acknowledges each command with an empty 2xx. Under the -default synchronous dispatch, that happens after your handler returns; see +Over Socket Mode, Slack sends the command over the bot's connection, so the +command needs no Request URL. + +The Slack adapter acknowledges each command with an empty 2xx over HTTP, or +an empty ack over Socket Mode. Under the default synchronous dispatch, that +happens after your handler returns; see [Long-running commands](#long-running-commands) to stay inside Slack's 3-second budget. diff --git a/docs/reference.md b/docs/reference.md index c80572f..54c40ff 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -24,7 +24,8 @@ Redis, Postgres, and NATS state live in separate Go modules so applications that only use core, Slack, or memory state do not pull their dependencies. The core module has one dependency, `github.com/coder/websocket`, for the Slack Socket Mode client ([ADR 0017](adr/0017-slack-socket-mode.md)). Only -builds that import `slackapi` or `slackapitest` compile it. +builds that import the Slack adapter, `slackapi`, or `slackapitest` compile +it. The repository uses `go.work` for local development across the root module, the state modules, and the example modules. From d38543ec8c7a96b2deb8c34577579eb6557db101 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:19:43 +0000 Subject: [PATCH 20/33] chore: drop the go.work.sum lines that a docs commit added by mistake --- go.work.sum | 9 --------- 1 file changed, 9 deletions(-) diff --git a/go.work.sum b/go.work.sum index f2b3bf9..95d07a3 100644 --- a/go.work.sum +++ b/go.work.sum @@ -3,7 +3,6 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -13,10 +12,8 @@ github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= github.com/moby/sys/mount v0.3.5/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= @@ -25,7 +22,6 @@ github.com/nats-io/nats.go v1.39.1/go.mod h1:MgRb8oOdigA6cYpEPhXJuRVH6UE/V4jblJ2 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -34,10 +30,8 @@ golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtC golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -58,11 +52,8 @@ golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0 golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 796e73552f7ffbdc257ca6493a0518d9605229f6 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:25:19 +0000 Subject: [PATCH 21/33] test(adapters/slack): compare the resends of a rejected delivery in send order --- adapters/slack/transport_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/adapters/slack/transport_test.go b/adapters/slack/transport_test.go index f334113..d7e4e87 100644 --- a/adapters/slack/transport_test.go +++ b/adapters/slack/transport_test.go @@ -2,6 +2,7 @@ package slack_test import ( "bytes" + "cmp" "context" "encoding/json" "errors" @@ -9,6 +10,7 @@ import ( "net/http/httptest" "net/url" "reflect" + "slices" "strconv" "strings" "sync" @@ -366,6 +368,11 @@ func TestTransportParityAdmissionRejected(t *testing.T) { sm.WaitLive(t, transportWait, 1) id := sm.Send(string(tt.typ), payload) socketEvents := overSocket.wait(t, deliveries) + // Listen runs the handlers of the sends at the same time, so + // they can dispatch out of send order. + slices.SortFunc(socketEvents, func(a, b *chat.Event) int { + return cmp.Compare(a.Retry.Num, b.Retry.Num) + }) // Listen waits for its handlers and sends their acks before it // closes the connection, so the fake has every ack after the close. stop() From 83a06cb385f4e9ac1d9ee74952d692b7ab6367c5 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:52:43 +0000 Subject: [PATCH 22/33] fix(examples/slack-hello-world): stop Socket Mode with Runtime Shutdown, after the webhook server --- examples/slack-hello-world/README.md | 6 +- examples/slack-hello-world/main.go | 76 +++++++++++++------------ examples/slack-hello-world/main_test.go | 26 +++++++-- 3 files changed, 64 insertions(+), 44 deletions(-) diff --git a/examples/slack-hello-world/README.md b/examples/slack-hello-world/README.md index 41fae96..0e5b468 100644 --- a/examples/slack-hello-world/README.md +++ b/examples/slack-hello-world/README.md @@ -78,8 +78,10 @@ The bot should reply in the same thread with portable Markdown: If you enabled `message.im`, you can also send a direct message to the bot. -Press `Ctrl-C` to stop the bot. It closes its Socket Mode connection and its -HTTP server, and then shuts down the runtime. +Press `Ctrl-C` to stop the bot. It shuts down its HTTP server first, and +waits for the requests that run. Then it shuts down the runtime with +`Chat.Shutdown`. The runtime stops acking new Socket Mode events, and then +closes the Socket Mode connection. ## Notes diff --git a/examples/slack-hello-world/main.go b/examples/slack-hello-world/main.go index a445ba5..61f7a46 100644 --- a/examples/slack-hello-world/main.go +++ b/examples/slack-hello-world/main.go @@ -30,11 +30,6 @@ func main() { if err != nil { panic(err) } - defer func() { - if err := bot.Shutdown(context.Background()); err != nil { - slog.Error("chat shutdown failed", "error", err) - } - }() addr := ":" + os.Getenv("PORT") if addr == ":" { @@ -70,56 +65,63 @@ func newBot(ctx context.Context, opts slack.Options) (*chat.Chat, error) { // serve receives Slack events until ctx is done or a transport fails. It runs // Socket Mode when opts has an app token, and the webhook server on addr when -// opts has a signing secret. When one transport stops, serve stops the other. +// opts has a signing secret. Then it shuts down the webhook server, and the +// runtime last. The webhook server goes first because Runtime Shutdown does +// not wait for webhook requests in the sync Dispatch Mode. Runtime Shutdown +// stops acking new Socket Mode events before it closes the connection. func serve(ctx context.Context, bot *chat.Chat, opts slack.Options, addr string) error { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - var transports []func() error - if opts.AppToken != "" { - transports = append(transports, func() error { - return bot.Listen(ctx, "slack") - }) - } + var server *http.Server if opts.SigningSecret != "" { slackWebhook, err := bot.Webhook("slack") if err != nil { return err } - server := newWebhookServer(addr, slackWebhook) - transports = append(transports, func() error { - return runWebhookServer(ctx, server) - }) + server = newWebhookServer(addr, slackWebhook) } - errs := make(chan error, len(transports)) - for _, transport := range transports { - go func() { - defer cancel() - errs <- transport() - }() + errs := make(chan error, 2) + running := 0 + if opts.AppToken != "" { + running++ + // Only Shutdown stops Listen, so the connection closes after + // admission closes. + go func() { errs <- bot.Listen(context.Background(), "slack") }() + } + if server != nil { + running++ + go func() { errs <- serveWebhooks(server) }() } + var err error - for range transports { + select { + case <-ctx.Done(): + case err = <-errs: + running-- + } + if server != nil { + err = errors.Join(err, shutdown(server.Shutdown)) + } + err = errors.Join(err, shutdown(bot.Shutdown)) + for range running { err = errors.Join(err, <-errs) } return err } -// runWebhookServer serves until ctx is done, and then shuts the server down. -func runWebhookServer(ctx context.Context, server *http.Server) error { - slog.Info("serving Slack webhooks", "addr", server.Addr) - served := make(chan error, 1) - go func() { served <- server.ListenAndServe() }() +// shutdown runs stop with a context that ends after 10 seconds. +func shutdown(stop func(context.Context) error) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return stop(ctx) +} - select { - case err := <-served: +// serveWebhooks serves until the server shuts down. +func serveWebhooks(server *http.Server) error { + slog.Info("serving Slack webhooks", "addr", server.Addr) + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { return err - case <-ctx.Done(): - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return server.Shutdown(shutdownCtx) } + return nil } func newWebhookServer(addr string, slackWebhook http.Handler) *http.Server { diff --git a/examples/slack-hello-world/main_test.go b/examples/slack-hello-world/main_test.go index a965ad5..99e03d8 100644 --- a/examples/slack-hello-world/main_test.go +++ b/examples/slack-hello-world/main_test.go @@ -10,6 +10,9 @@ import ( "testing" "time" + "github.com/coder/websocket" + + "github.com/coder/chat" "github.com/coder/chat/adapters/slack" "github.com/coder/chat/adapters/slack/slackapi" "github.com/coder/chat/adapters/slack/slackapi/slackapitest" @@ -37,7 +40,7 @@ func testOptions(srv *slackapitest.Server) slack.Options { // startServe builds the bot of opts and runs serve in a goroutine. stop // cancels the context of serve and returns the error of serve. served gets // the error of serve when serve returns on its own. -func startServe(t *testing.T, opts slack.Options, addr string) (stop func() error, served <-chan error) { +func startServe(t *testing.T, opts slack.Options, addr string) (bot *chat.Chat, stop func() error, served <-chan error) { t.Helper() ctx, cancel := context.WithCancel(t.Context()) bot, err := newBot(ctx, opts) @@ -56,7 +59,7 @@ func startServe(t *testing.T, opts slack.Options, addr string) (stop func() erro cancel() return wait(t, result) } - return stop, result + return bot, stop, result } func wait(t *testing.T, served <-chan error) error { @@ -78,7 +81,7 @@ func TestServeRepliesOverSocketMode(t *testing.T) { srv.Respond("chat.postMessage", map[string]any{"ok": true, "channel": "C1", "ts": "111.001"}) opts := testOptions(srv) opts.AppToken = testAppToken - stop, _ := startServe(t, opts, "") + bot, stop, _ := startServe(t, opts, "") sm.WaitLive(t, testWait, 1) sm.WaitAcked(t, testWait, sm.Send(string(slackapi.DeliveryEventsAPI), json.RawMessage(`{ @@ -103,6 +106,19 @@ func TestServeRepliesOverSocketMode(t *testing.T) { if err := stop(); err != nil { t.Errorf("serve: %v", err) } + if c := sm.WaitClosed(t, testWait, 1); !c.CloseFrame || c.CloseCode != websocket.StatusNormalClosure { + t.Errorf("connection = %+v, want a close frame with %v", c, websocket.StatusNormalClosure) + } + // serve stopped Listen with Runtime Shutdown, so Listen now returns at + // once and opens no connection. + ctx, cancel := context.WithTimeout(t.Context(), testWait) + defer cancel() + if err := bot.Listen(ctx, "slack"); err != nil { + t.Errorf("Listen after serve: %v", err) + } + if calls := srv.Calls("apps.connections.open"); len(calls) != 1 { + t.Errorf("apps.connections.open calls = %d, want 1", len(calls)) + } } func TestServeStopsWebhookServerWhenSocketModeFails(t *testing.T) { @@ -113,7 +129,7 @@ func TestServeStopsWebhookServerWhenSocketModeFails(t *testing.T) { opts := testOptions(srv) opts.AppToken = "xapp-revoked" opts.SigningSecret = "secret" - _, served := startServe(t, opts, "127.0.0.1:0") + _, _, served := startServe(t, opts, "127.0.0.1:0") err := wait(t, served) if apiErr, ok := errors.AsType[*slackapi.APIError](err); !ok || apiErr.Code != "invalid_auth" { @@ -127,7 +143,7 @@ func TestServeWithoutAppTokenServesOnlyWebhooks(t *testing.T) { srv := slackapitest.NewServer(t) opts := testOptions(srv) opts.SigningSecret = "secret" - stop, _ := startServe(t, opts, "127.0.0.1:0") + _, stop, _ := startServe(t, opts, "127.0.0.1:0") if err := stop(); err != nil { t.Errorf("serve: %v", err) From 85a32cfa60a3c22d58051c25ed971f23af3cc3b5 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:52:43 +0000 Subject: [PATCH 23/33] docs: stop listeners with Shutdown, not with the Listen context --- README.md | 14 +++++++++++++- docs/reference.md | 32 +++++++++++++++++++++++--------- docs/tutorials/slack-bot.md | 4 +++- runtime.go | 4 +++- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c8674d3..2ebaaa1 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ import ( "context" "log" "os" + "os/signal" "github.com/coder/chat" "github.com/coder/chat/adapters/slack" @@ -59,7 +60,18 @@ func main() { return err }) - if err := bot.Listen(ctx, "slack"); 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) } } diff --git a/docs/reference.md b/docs/reference.md index 54c40ff..fb6a5ec 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -178,6 +178,10 @@ http.Handle("/webhooks/slack", handler) Webhook lookup is fallible: a misspelled adapter name is a startup error, not a production 404. +With `DispatchSync` (the default), `Shutdown` does not wait for the webhook +requests that run. Shut the HTTP server down before you call `Shutdown`, so +that those requests finish before the state shuts down. + Adapters own platform handshakes. For Slack, the `url_verification` challenge is answered inside the Slack webhook handler and never reaches application handlers. @@ -190,11 +194,16 @@ public URL. The Slack adapter implements it with Socket Mode ([ADR 0017](adr/0017-slack-socket-mode.md)). Run it with `Chat.Listen`: ```go -ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) -defer stop() +// Listen runs until Shutdown stops it. +go func() { + if err := bot.Listen(context.Background(), "slack"); err != nil { + slog.Error("slack listener failed", "error", err) + } +}() -if err := bot.Listen(ctx, "slack"); err != nil { - return err +// Later, for example on SIGTERM: +if err := bot.Shutdown(shutdownCtx); err != nil { + slog.Error("chat shutdown failed", "error", err) } ``` @@ -207,11 +216,16 @@ started, it returns nil at once. Concurrent calls are allowed, and an application can serve the webhook handler of the same adapter at the same time. -`Shutdown` stops listeners only after it closes admission and drains deferred -work. So a delivery that arrives while `Shutdown` runs gets -`chat.ErrAdmissionRejected`, and the adapter gives the platform the same -overload answer as at the `MaxDetached` bound. For example, the Slack adapter -does not ack an Events API delivery, so Slack can deliver it again. +Stop a listener with `Shutdown`, not with the `Listen` context. `Shutdown` +stops listeners only after it closes admission and drains deferred work. So a +delivery that arrives while `Shutdown` runs gets `chat.ErrAdmissionRejected`, +and the adapter gives the platform the same overload answer as at the +`MaxDetached` bound. For example, the Slack adapter does not ack an Events API +delivery, so Slack can deliver it again. You get this order only when you call +`Shutdown` while `Listen` runs. When the `Listen` context ends first, the +listener closes its connections while admission is still open. The Slack +listener then still acks the deliveries that arrive while it drains, and with +`DispatchDeferred` a later `Shutdown` cancels their work. ## Routing diff --git a/docs/tutorials/slack-bot.md b/docs/tutorials/slack-bot.md index faa11ec..84b62d0 100644 --- a/docs/tutorials/slack-bot.md +++ b/docs/tutorials/slack-bot.md @@ -42,7 +42,9 @@ Its whole job is: 3. Register one handler: when the bot is mentioned, reply with `**hello** _world_` in the same thread. 4. Call `Chat.Listen`, which connects to Slack over Socket Mode and receives - events until you stop the bot. + events. +5. When you stop the bot, call `Chat.Shutdown`. It stops acking new events, + and then closes the connection. ## Step 2: Create A Slack App diff --git a/runtime.go b/runtime.go index 10e3ff2..70f728f 100644 --- a/runtime.go +++ b/runtime.go @@ -460,7 +460,9 @@ func (c *Chat) Webhook(adapterName string) (http.Handler, error) { // Shutdown stops listeners only after it closes admission and drains deferred // work. So while Shutdown runs, new deliveries from a listener get // ErrAdmissionRejected before the listener closes its connections, and the -// platform can deliver them again. +// platform can deliver them again. To get this order, stop Listen with +// Shutdown. When ctx ends first, the listener closes its connections while +// admission is still open. func (c *Chat) Listen(ctx context.Context, adapterName string) error { assert(c != nil, "Listen called on nil runtime") adapter, ok := c.adapters[adapterName] From a32cfada39e2c9c2f19ee291de449a1cc6bed66d Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:52:43 +0000 Subject: [PATCH 24/33] docs(adapters/slack): say that the busy reply covers every admission rejection --- adapters/slack/interactive.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/adapters/slack/interactive.go b/adapters/slack/interactive.go index 0a775da..2587589 100644 --- a/adapters/slack/interactive.go +++ b/adapters/slack/interactive.go @@ -31,12 +31,13 @@ type commandForm struct { } // 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. +// 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 Admission Bound -// rejects. Slack shows its text to the invoking user. +// 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"` } From 53546a79f9880469622cd466e7b64215aa6cdee4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 05:52:43 +0000 Subject: [PATCH 25/33] refactor(adapters/slack/slackapi/slackapitest): share one encoder for the rules of Respond --- .../slack/slackapi/slackapitest/server.go | 23 ++++++++++++------- .../slack/slackapi/slackapitest/socketmode.go | 14 ++++------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/adapters/slack/slackapi/slackapitest/server.go b/adapters/slack/slackapi/slackapitest/server.go index f5afe11..660205b 100644 --- a/adapters/slack/slackapi/slackapitest/server.go +++ b/adapters/slack/slackapi/slackapitest/server.go @@ -197,14 +197,8 @@ func (s *Server) writeAny(w http.ResponseWriter, name string, resp any) { func (s *Server) write(w http.ResponseWriter, method string, resp Response) { var body []byte - switch b := resp.Body.(type) { - case nil: - case json.RawMessage: - body = b - case []byte: - body = b - default: - encoded, err := json.Marshal(b) + if resp.Body != nil { + encoded, err := encodeBody(resp.Body) if err != nil { s.t.Errorf("slackapitest: encode %s response: %v", method, err) http.Error(w, "encode response", http.StatusInternalServerError) @@ -224,6 +218,19 @@ func (s *Server) write(w http.ResponseWriter, method string, resp Response) { _, _ = w.Write(body) } +// encodeBody encodes v with the rules of Respond: a json.RawMessage or []byte +// is used as is, and any other value is encoded as JSON. +func encodeBody(v any) ([]byte, error) { + switch b := v.(type) { + case json.RawMessage: + return b, nil + case []byte: + return b, nil + default: + return json.Marshal(b) + } +} + func statusOrOK(status int) int { if status == 0 { return http.StatusOK diff --git a/adapters/slack/slackapi/slackapitest/socketmode.go b/adapters/slack/slackapi/slackapitest/socketmode.go index 69b2714..31fd397 100644 --- a/adapters/slack/slackapi/slackapitest/socketmode.go +++ b/adapters/slack/slackapi/slackapitest/socketmode.go @@ -716,16 +716,12 @@ func (env *socketEnvelope) snapshot() SocketEnvelope { return info } -// encodeJSON encodes payload with the rules of Respond. +// encodeJSON encodes payload with the rules of Respond, and checks that the +// result is valid JSON, because it goes inside an envelope. func encodeJSON(payload any) (json.RawMessage, error) { - var raw []byte - switch p := payload.(type) { - case json.RawMessage: - raw = p - case []byte: - raw = p - default: - return json.Marshal(p) + raw, err := encodeBody(payload) + if err != nil { + return nil, err } if !json.Valid(raw) { return nil, fmt.Errorf("payload %q is not valid JSON", raw) From 6817dbc6f66db41df0ce84682e4775e4d43245d4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 06:09:06 +0000 Subject: [PATCH 26/33] fix(adapters/slack/slackapi): end the Socket Mode backoff when a replacement comes up --- adapters/slack/slackapi/socketmode.go | 5 ++- adapters/slack/slackapi/socketmode_test.go | 47 ++++++++++++++++++++++ docs/adr/0017-slack-socket-mode.md | 2 +- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/adapters/slack/slackapi/socketmode.go b/adapters/slack/slackapi/socketmode.go index 8cc552b..db7f99e 100644 --- a/adapters/slack/slackapi/socketmode.go +++ b/adapters/slack/slackapi/socketmode.go @@ -326,7 +326,8 @@ func (l *listener) handle(ev connEvent) error { } // promote makes the pending connection the active one, and retires the old -// active connection. +// active connection. It ends the backoff, because the backoff only delays the +// next connect. func (l *listener) promote(h Hello) { c := l.pending c.cancelConnect() @@ -335,7 +336,7 @@ func (l *listener) promote(h Hello) { if l.active != nil { l.retire(l.active) } - l.active, l.activeSince, l.replacing = c, time.Now(), false + l.active, l.activeSince, l.replacing, l.retry = c, time.Now(), false, nil l.api.logger.Info("slack socket mode connected", "adapter", adapterName, "host", h.Host, "num_connections", h.NumConnections, "app_id", h.AppID) l.opts.OnConnect(h) diff --git a/adapters/slack/slackapi/socketmode_test.go b/adapters/slack/slackapi/socketmode_test.go index 2b1424c..95254b3 100644 --- a/adapters/slack/slackapi/socketmode_test.go +++ b/adapters/slack/slackapi/socketmode_test.go @@ -645,6 +645,53 @@ func TestListenTooManyWebsockets(t *testing.T) { requireCloseFrames(t, sm) }) + t.Run("disconnect during a replacement", func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken}) + gate := make(chan struct{}) + release := sync.OnceFunc(func() { close(gate) }) + t.Cleanup(release) + handleOpens(srv, func(n int, call slackapitest.Call) any { + if n == 2 { + <-gate + } + return sm.Open(call) + }) + errs := make(chan error, 10) + hellos := make(chan slackapi.Hello, 3) + // The backoff after too_many_websockets is longer than the test waits. + l := startListen(t, socketClient(srv), ackAll, slackapi.ListenOptions{ + MinBackoff: time.Millisecond, + MaxBackoff: time.Minute, + OnConnect: sendTo(hellos), + OnError: sendTo(errs), + }) + recv(t, hellos) + + sm.DisconnectConn(1, "warning") + waitFor(t, srv, "the replacement connect", func() bool { return len(srv.Calls("apps.connections.open")) == 2 }) + sm.DisconnectConn(1, "too_many_websockets") + if got := slackCodeOf(recv(t, errs)); got != "too_many_websockets" { + t.Fatalf("OnError code = %q, want too_many_websockets", got) + } + release() + if h := recv(t, hellos); h.Host != "applink-2" { + t.Fatalf("OnConnect(%+v), want connection 2", h) + } + // Connection 2 is up, so the backoff of connection 1 is over, and a + // refresh of connection 2 connects at once. + sm.DisconnectConn(2, "refresh_requested") + if h := recv(t, hellos); h.Host != "applink-3" { + t.Fatalf("OnConnect(%+v), want connection 3", h) + } + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) + }) + t.Run("api error", func(t *testing.T) { t.Parallel() srv := slackapitest.NewServer(t) diff --git a/docs/adr/0017-slack-socket-mode.md b/docs/adr/0017-slack-socket-mode.md index 10f0c8b..cd0e053 100644 --- a/docs/adr/0017-slack-socket-mode.md +++ b/docs/adr/0017-slack-socket-mode.md @@ -56,7 +56,7 @@ Related decisions, not redefined here: deferred dispatch is ADR 0002; rate-limit - **Close order:** a connection closes in four steps. First, it waits for its in-flight handlers, at most 3 seconds (Slack's ack window). Envelopes that arrive during this wait still get handled and acknowledged. Second, `Close(StatusNormalClosure)` sends the close frame. Third, it waits for the read loop to end. Fourth, it cancels the handler context. The read loop never uses a context that can be cancelled, because a cancelled read context closes the connection without a close frame. Only `Close` ends the read loop, so every close sends a close frame. For the same reason, a stop does not cancel a websocket handshake that runs: Slack can accept the connection before the handshake returns. The close waits for the handshake, which the 30 second connect limit bounds, and then sends the close frame. - **Ping timeout:** Slack sends a websocket ping about every 10 seconds. A timer restarts on each ping, and starts only after the first ping, as in node-slack-sdk. When `ListenOptions.PingTimeout` (30 seconds by default) passes with no ping, Listen replaces the connection and reports `ErrPingTimeout` to `OnError`. The timer does not depend on the read loop, so a stuck read cannot block it. - **Fatal errors:** Listen stops and returns the error. These errors are fatal: an `*APIError` from `apps.connections.open` with the code `not_authed`, `invalid_auth`, `account_inactive`, `token_revoked`, `token_expired`, `user_removed_from_team`, `team_disabled`, `not_allowed_token_type`, or `missing_scope`, and a `*DisconnectError` with the reason `link_disabled`. - - **`too_many_websockets`:** a `disconnect` message or an API error code with this reason closes that connection at once. Listen reports it to `OnError`, and waits `MaxBackoff` with jitter before the next connect. + - **`too_many_websockets`:** a `disconnect` message or an API error code with this reason closes that connection at once. Listen reports it to `OnError`, and waits `MaxBackoff` with jitter before the next connect. If a replacement connect is already in progress, it continues. When it comes up, the wait ends, because the wait only delays the next connect. - **Other errors:** connect failures and dropped connections go to `OnError`, and Listen retries with an exponential backoff with equal jitter, from `MinBackoff` (1 second by default) to `MaxBackoff` (1 minute by default). A `*RateLimited` error with `RetryAfter` waits at least `RetryAfter`. A dropped connection that was up for at least `MaxBackoff` reconnects at once. Else the drop counts as a failure, so a connection that drops again and again backs off. Failed connects during a replacement back off in the same way: a connection that was up for at least `MaxBackoff` resets the failure count once, when it drops, gets a `disconnect` message, or starts its replacement, and not at each failed connect. - **Stop:** when ctx is done, Listen closes every connection in the close order, waits for every handler goroutine, and returns nil. From 192cfd67068b299fa226c1bd4c15124429a3d432 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 06:09:06 +0000 Subject: [PATCH 27/33] feat(adapters/slack/slackapi): export ErrConnectTimeout for OnError --- adapters/slack/slackapi/socketmode.go | 13 +++++++++---- docs/adr/0017-slack-socket-mode.md | 2 +- docs/reference.md | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/adapters/slack/slackapi/socketmode.go b/adapters/slack/slackapi/socketmode.go index db7f99e..3ac9c2e 100644 --- a/adapters/slack/slackapi/socketmode.go +++ b/adapters/slack/slackapi/socketmode.go @@ -34,7 +34,11 @@ const reasonTooManyWebsockets = "too_many_websockets" // then replaces the connection. var ErrPingTimeout = errors.New("slack: socket mode ping timeout") -var errConnectTimeout = errors.New("slack: socket mode connect timeout") +// ErrConnectTimeout is the error that Listen gives to ListenOptions.OnError +// when a connect does not finish in 30 seconds. A connect is the call to +// apps.connections.open, the websocket handshake, and the wait for hello. +// Listen then retries with backoff. +var ErrConnectTimeout = errors.New("slack: socket mode connect timeout") // ListenOptions configures Listen. The zero value uses the defaults. // @@ -45,8 +49,9 @@ type ListenOptions struct { // connection is up. Nil is a no-op. OnConnect func(Hello) // OnError is called with each error that Listen recovers from: by a retry, - // or by a replacement of a connection. It is not called with the error - // that Listen returns. Nil is a no-op. + // or by a replacement of a connection. Examples are ErrPingTimeout and + // ErrConnectTimeout. It is not called with the error that Listen returns. + // Nil is a no-op. OnError func(error) // PingTimeout is the longest time without a ping from Slack before Listen // replaces a connection. Listen starts to check it after the first ping. @@ -440,7 +445,7 @@ type socketConn struct { func newSocketConn(ctx context.Context, l *listener) *socketConn { deadline := time.Now().Add(connectTimeout) - connectCtx, cancelConnect := context.WithDeadlineCause(ctx, deadline, errConnectTimeout) + connectCtx, cancelConnect := context.WithDeadlineCause(ctx, deadline, ErrConnectTimeout) handlerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) return &socketConn{ l: l, diff --git a/docs/adr/0017-slack-socket-mode.md b/docs/adr/0017-slack-socket-mode.md index cd0e053..6a00f71 100644 --- a/docs/adr/0017-slack-socket-mode.md +++ b/docs/adr/0017-slack-socket-mode.md @@ -50,7 +50,7 @@ Related decisions, not redefined here: deferred dispatch is ADR 0002; rate-limit 4. Shut down the adapters, then the state. 6. **Socket Mode connection rules.** - - **Connect:** call `OpenConnection`, dial the websocket with the Client's HTTP client, and wait for `hello`. The whole connect has a 30 second limit. The HTTP client gives the handshake its proxy settings. The read limit is 1 MiB, the same as the `Webhook` body limit. `ListenOptions.OnConnect` gets each hello, with `num_connections`, the app ID, and the host. + - **Connect:** call `OpenConnection`, dial the websocket with the Client's HTTP client, and wait for `hello`. The whole connect has a 30 second limit. When a connect passes the limit, Listen reports `ErrConnectTimeout` to `OnError` and retries with backoff. The HTTP client gives the handshake its proxy settings. The read limit is 1 MiB, the same as the `Webhook` body limit. `ListenOptions.OnConnect` gets each hello, with `num_connections`, the app ID, and the host. - **Envelopes:** each envelope runs the handler in its own goroutine. The ack goes on the connection that the envelope came on. The handler context ends when that connection closes, not when the Listen context ends. An envelope of an unknown type still goes to the handler, with its type. - **Planned replacement with no gap:** a `disconnect` message with the reason `warning`, `refresh_requested`, or an unknown reason starts a replacement connection, and so does a ping timeout. The old connection keeps reading and acknowledging until the new connection has its hello. Then the old connection closes. - **Close order:** a connection closes in four steps. First, it waits for its in-flight handlers, at most 3 seconds (Slack's ack window). Envelopes that arrive during this wait still get handled and acknowledged. Second, `Close(StatusNormalClosure)` sends the close frame. Third, it waits for the read loop to end. Fourth, it cancels the handler context. The read loop never uses a context that can be cancelled, because a cancelled read context closes the connection without a close frame. Only `Close` ends the read loop, so every close sends a close frame. For the same reason, a stop does not cancel a websocket handshake that runs: Slack can accept the connection before the handshake returns. The close waits for the handshake, which the 30 second connect limit bounds, and then sends the close frame. diff --git a/docs/reference.md b/docs/reference.md index fb6a5ec..cd6fb83 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -833,7 +833,7 @@ Socket Mode: | `ListenOptions` field | Empty value | | --- | --- | | `OnConnect` | No-op. Called with the `Hello` (`NumConnections`, `AppID`, `Host`) of each connection when it is up. | -| `OnError` | No-op. Called with each error that `Listen` recovers from, for example `ErrPingTimeout`, or a `*DisconnectError` with the reason `too_many_websockets`. | +| `OnError` | No-op. Called with each error that `Listen` recovers from, for example `ErrPingTimeout`, `ErrConnectTimeout` (a connect took more than 30 seconds), or a `*DisconnectError` with the reason `too_many_websockets`. | | `PingTimeout` | 30 seconds. `Listen` starts to check it after the first ping. | | `MinBackoff` | 1 second. | | `MaxBackoff` | 1 minute. Also the wait after `too_many_websockets`. | From 1c2d84ad6be000f0d35cd08475dd5440dc35e6b4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 06:09:06 +0000 Subject: [PATCH 28/33] test(adapters/slack): give the reconnect timing check more slack and drop an overlapping construction check --- adapters/slack/multitenant_test.go | 3 --- adapters/slack/slackapi/socketmode_test.go | 5 +++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/adapters/slack/multitenant_test.go b/adapters/slack/multitenant_test.go index bfa7198..a122fcc 100644 --- a/adapters/slack/multitenant_test.go +++ b/adapters/slack/multitenant_test.go @@ -78,9 +78,6 @@ func TestSlackConstructionModeSelection(t *testing.T) { if _, err := slack.New(ctx, slack.Options{SigningSecret: "secret"}); err == nil { t.Fatal("C4 neither bot token nor install store should fail") } - if _, err := slack.New(ctx, slack.Options{InstallStore: store}); err == nil { - t.Fatal("missing signing secret should fail in multi-tenant mode") - } } // New needs a transport: a signing secret for Webhook, an app token for diff --git a/adapters/slack/slackapi/socketmode_test.go b/adapters/slack/slackapi/socketmode_test.go index 95254b3..2b0041c 100644 --- a/adapters/slack/slackapi/socketmode_test.go +++ b/adapters/slack/slackapi/socketmode_test.go @@ -767,8 +767,9 @@ func TestListenReconnectsAfterDrop(t *testing.T) { t.Parallel() // MinBackoff and MaxBackoff are the same, so a backoff waits at least - // half of it. - const backoff = 300 * time.Millisecond + // half of it. It is long, so that a reconnect at once has time to spare + // on a loaded machine. + const backoff = time.Second tests := []struct { name string // up is the time that connection 1 is up before the drop. From a37a30e3da28b97f6cb321bf7b7ecb2f0520f548 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 06:09:06 +0000 Subject: [PATCH 29/33] docs(adr): note in ADR 0015 that ADR 0017 closes sync admission at Shutdown --- docs/adr/0015-runtime-coordination.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/adr/0015-runtime-coordination.md b/docs/adr/0015-runtime-coordination.md index 2c7bc0a..c9ccdcb 100644 --- a/docs/adr/0015-runtime-coordination.md +++ b/docs/adr/0015-runtime-coordination.md @@ -6,6 +6,10 @@ Accepted (the admission bound and the per-instance burst revival are implemented This is a decision-level document: it fixes decisions, invariants, and non-goals. Implementation mechanics — slot bookkeeping, timer and shutdown lifecycles, counter management — are deliberately not specified here; they are decided in the implementing PRs, where code and hardening tests can actually verify them against the invariants below. +ADR 0017 (Slack Socket Mode) adds one rule for synchronous dispatch: + +- After Runtime Shutdown starts, a dispatch in the synchronous **Dispatch Mode** returns `ErrAdmissionRejected`. This closes admission only at Shutdown. The non-goal below stands: synchronous dispatch has no capacity cap. + ## Context ADR 0002 made `DispatchDeferred` the opt-in **Ack-Then-Work** mode: the adapter acknowledges after the synchronous prelude and the handler runs on the **Detached Work Context**, bounded by `DetachTimeout`. ADR 0012 expanded the **Concurrency Strategy** set; the reduced implementation on main ships `drop`, `queue`, `debounce`, and `concurrent` plus **Lock Scope** and universal lease-loss cancellation, with `burst` and force/steerability staged on PR #53 pending this design track. From 526b1a95fdb5482e26b76f2c03c223bc001da64b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 06:13:16 +0000 Subject: [PATCH 30/33] test(adapters/slack/slackapi): read a Socket Mode envelope larger than the websocket default limit --- adapters/slack/slackapi/socketmode_test.go | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/adapters/slack/slackapi/socketmode_test.go b/adapters/slack/slackapi/socketmode_test.go index 2b0041c..1b65307 100644 --- a/adapters/slack/slackapi/socketmode_test.go +++ b/adapters/slack/slackapi/socketmode_test.go @@ -270,6 +270,39 @@ func TestListenAcks(t *testing.T) { requireCloseFrames(t, sm) } +// TestListenReadsLargePayloads checks that Listen reads an envelope larger than +// the 32 KiB default read limit of the websocket library. +func TestListenReadsLargePayloads(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + sm := srv.SocketMode(slackapitest.SocketModeOptions{AppToken: socketAppToken, ResendAfter: -1}) + deliveries := make(chan slackapi.Delivery, 1) + errs := make(chan error, 10) + opts := fastListen() + opts.OnError = sendTo(errs) + l := startListen(t, socketClient(srv), func(_ context.Context, d *slackapi.Delivery) (any, error) { + deliveries <- *d + return nil, nil + }, opts) + sm.WaitLive(t, socketWait, 1) + + payload := `{"type":"block_actions","value":"` + strings.Repeat("x", 100<<10) + `"}` + id := sm.Send("interactive", json.RawMessage(payload)) + if env := sm.WaitAcked(t, socketWait, id); env.Acks[0].Conn != 1 { + t.Errorf("ack on connection %d, want 1", env.Acks[0].Conn) + } + if d := recv(t, deliveries); string(d.Payload) != payload { + t.Errorf("handler got a payload of %d bytes, want %d", len(d.Payload), len(payload)) + } + + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } + requireCloseFrames(t, sm) + requireNoError(t, errs) +} + func TestListenAcksOnTheSameConnection(t *testing.T) { t.Parallel() From f92fa13e42c97ff67b1e9d5acffc23dc571e899b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 Oct 2026 06:14:02 +0000 Subject: [PATCH 31/33] docs(adapters/slack/slackapi): say when OnError gets a DisconnectError --- adapters/slack/slackapi/socketmode.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/adapters/slack/slackapi/socketmode.go b/adapters/slack/slackapi/socketmode.go index 3ac9c2e..b35b810 100644 --- a/adapters/slack/slackapi/socketmode.go +++ b/adapters/slack/slackapi/socketmode.go @@ -120,8 +120,9 @@ type Hello struct { // DisconnectError is a disconnect message from Slack that Listen treats as an // error. Listen returns it for the reason link_disabled (Socket Mode is off in -// the app settings). It gives it to ListenOptions.OnError for the reason -// too_many_websockets, and retries. +// the app settings). It gives it to ListenOptions.OnError, and retries, for the +// reason too_many_websockets and for any other disconnect message that comes +// before hello. type DisconnectError struct { // Reason is the reason in the message, for example "link_disabled". Reason string From a386e135d0af19208adabaca6957a9035495883a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 16 Oct 2026 05:02:47 +0000 Subject: [PATCH 32/33] test(slackapi): live Socket Mode smoke test --- adapters/slack/slackapi/live_test.go | 206 +++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/adapters/slack/slackapi/live_test.go b/adapters/slack/slackapi/live_test.go index 13cb87f..2da2c3b 100644 --- a/adapters/slack/slackapi/live_test.go +++ b/adapters/slack/slackapi/live_test.go @@ -15,6 +15,15 @@ // // For the status timeout check, also set SLACKAPI_LIVE_STATUS_WAIT=1 and watch // the test thread in Slack. +// +// TestLiveSocketMode is a smoke test of Socket Mode. It also needs: +// +// SLACKAPI_LIVE_APP_TOKEN app-level token (xapp-...) with connections:write +// +// The app needs Socket Mode on, the bot event message.channels, and the bot +// scopes chat:write and channels:history. Run it with: +// +// SLACKAPI_LIVE_TOKEN=xoxb-... SLACKAPI_LIVE_APP_TOKEN=xapp-... SLACKAPI_LIVE_CHANNEL=C... go test -run TestLiveSocketMode -v -count=1 -timeout 3m ./adapters/slack/slackapi/ package slackapi_test @@ -22,11 +31,14 @@ import ( "bytes" "cmp" "context" + "encoding/json" "errors" "fmt" + "log/slog" "net/http" "os" "slices" + "sync/atomic" "testing" "time" @@ -236,6 +248,200 @@ func TestLive(t *testing.T) { t.Logf("step 10 DownloadFile: %d bytes match the upload, %d redirects %v", n, len(redirects), redirects) } +func TestLiveSocketMode(t *testing.T) { + t.Parallel() + + token := os.Getenv("SLACKAPI_LIVE_TOKEN") + appToken := os.Getenv("SLACKAPI_LIVE_APP_TOKEN") + channel := os.Getenv("SLACKAPI_LIVE_CHANNEL") + if token == "" || appToken == "" || channel == "" { + t.Skip("set SLACKAPI_LIVE_TOKEN, SLACKAPI_LIVE_APP_TOKEN, and SLACKAPI_LIVE_CHANNEL to run the live Socket Mode smoke test") + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + + run := time.Now().UnixNano() + resendText := fmt.Sprintf("slackapi live Socket Mode test %d: not acked at first", run) + ackText := fmt.Sprintf("slackapi live Socket Mode test %d: acked", run) + resends := make(chan liveEnvelope, 10) + acks := make(chan liveEnvelope, 10) + hellos := make(chan slackapi.Hello, 10) + recovered := make(chan error, 10) + var nacked atomic.Bool + handler := func(_ context.Context, d *slackapi.Delivery) (any, error) { + switch { + case d.Type != slackapi.DeliveryEventsAPI: + case bytes.Contains(d.Payload, []byte(resendText)): + liveOffer(resends, liveEnvelope{d, time.Now()}) + if !nacked.Swap(true) { + return nil, errors.New("not acked, so that Slack resends it") + } + case bytes.Contains(d.Payload, []byte(ackText)): + liveOffer(acks, liveEnvelope{d, time.Now()}) + } + return nil, nil + } + + // logs is read only after Listen returns. + var logs bytes.Buffer + app := slackapi.New(slackapi.Options{ + Token: appToken, + Logger: slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})), + }) + listenCtx, stop := context.WithCancel(ctx) + listenDone := make(chan struct{}) + var listenErr error + go func() { + defer close(listenDone) + listenErr = app.Listen(listenCtx, handler, slackapi.ListenOptions{ + OnConnect: func(h slackapi.Hello) { liveOffer(hellos, h) }, + OnError: func(err error) { liveOffer(recovered, err) }, + }) + }() + t.Cleanup(func() { + stop() + <-listenDone + t.Logf("Listen logs:\n%s", logs.String()) + }) + + select { + case hello := <-hellos: + if hello.AppID == "" { + t.Fatalf("step 1 Listen: hello has no app ID") + } + t.Logf("step 1 Listen: connected, app %s, host %s, %d connections", hello.AppID, hello.Host, hello.NumConnections) + case <-listenDone: + t.Fatalf("step 1 Listen: %s", liveErr(listenErr)) + case <-ctx.Done(): + t.Fatalf("step 1 Listen: no hello: %v", ctx.Err()) + } + + bot := slackapi.New(slackapi.Options{Token: token}) + resendTS := livePost(ctx, t, "step 2", bot, channel, resendText) + first := liveNextEnvelope(ctx, t, "step 2", resends) + eventID := liveCheckMessageEvent(t, "step 2", first.delivery, channel, resendTS) + if first.delivery.RetryAttempt != 0 { + t.Fatalf("step 2: retry attempt %d, want 0", first.delivery.RetryAttempt) + } + t.Logf("step 2 Listen: envelope for message %s, event %s, not acked", resendTS, eventID) + + var resendAfter time.Duration + for resendAfter == 0 { + e := liveNextEnvelope(ctx, t, "step 3", resends) + if id := liveCheckMessageEvent(t, "step 3", e.delivery, channel, resendTS); id != eventID { + t.Fatalf("step 3: event %s, want %s", id, eventID) + } + if e.delivery.RetryAttempt == 0 { + t.Logf("step 3 Listen: duplicate envelope with retry attempt 0, acked") + continue + } + if e.delivery.RetryAttempt != 1 || e.delivery.RetryReason == "" { + t.Fatalf("step 3: retry attempt %d, reason %q, want attempt 1 and a reason", e.delivery.RetryAttempt, e.delivery.RetryReason) + } + resendAfter = e.at.Sub(first.at) + t.Logf("step 3 Listen: Slack resent the envelope after %s, retry attempt %d, reason %q, acked", resendAfter.Round(time.Millisecond), e.delivery.RetryAttempt, e.delivery.RetryReason) + } + + ackTS := livePost(ctx, t, "step 4", bot, channel, ackText) + acked := liveNextEnvelope(ctx, t, "step 4", acks) + eventID = liveCheckMessageEvent(t, "step 4", acked.delivery, channel, ackTS) + if acked.delivery.RetryAttempt != 0 { + t.Fatalf("step 4: retry attempt %d, want 0", acked.delivery.RetryAttempt) + } + t.Logf("step 4 Listen: envelope for message %s, event %s, acked", ackTS, eventID) + + // Step 3 shows that Slack resends an envelope that is not acked, so no + // resend in this time shows that Slack got the ack. + quiet := time.NewTimer(3 * resendAfter) + defer quiet.Stop() + for waiting := true; waiting; { + select { + case e := <-acks: + if e.delivery.RetryAttempt != 0 { + t.Fatalf("step 5: Slack resent the acked envelope: retry attempt %d, reason %q", e.delivery.RetryAttempt, e.delivery.RetryReason) + } + t.Logf("step 5 Listen: duplicate envelope with retry attempt 0, acked") + case <-quiet.C: + t.Logf("step 5 Listen: no resend in %s after the ack", (3 * resendAfter).Round(time.Millisecond)) + waiting = false + case <-ctx.Done(): + t.Fatalf("step 5: %v", ctx.Err()) + } + } + + stop() + <-listenDone + if listenErr != nil { + t.Fatalf("step 6 Listen: %s", liveErr(listenErr)) + } + if bytes.Contains(logs.Bytes(), []byte("slack socket mode close failed")) { + t.Fatalf("step 6 Listen: close handshake failed, see the logs") + } + for len(recovered) > 0 { + t.Errorf("step 6 Listen: recovered error: %v", <-recovered) + } + t.Logf("step 6 Listen: stopped, and Slack answered the close frame") +} + +// liveEnvelope is an envelope that the handler of TestLiveSocketMode got, and +// the time that it got it. +type liveEnvelope struct { + delivery *slackapi.Delivery + at time.Time +} + +// liveOffer sends v on ch, or drops it when ch is full, so that a callback of +// Listen never blocks. +func liveOffer[T any](ch chan<- T, v T) { + select { + case ch <- v: + default: + } +} + +func livePost(ctx context.Context, t *testing.T, step string, bot *slackapi.Client, channel, text string) string { + t.Helper() + posted, err := bot.PostMessage(ctx, slackapi.PostMessageRequest{Channel: channel, Text: text}) + if err != nil { + t.Fatalf("%s PostMessage: %s", step, liveErr(err)) + } + liveDeleteMessageOnCleanup(t, bot, channel, posted.TS) + return posted.TS +} + +func liveNextEnvelope(ctx context.Context, t *testing.T, step string, envelopes <-chan liveEnvelope) liveEnvelope { + t.Helper() + select { + case e := <-envelopes: + return e + case <-ctx.Done(): + t.Fatalf("%s: no envelope: %v", step, ctx.Err()) + } + return liveEnvelope{} +} + +// liveCheckMessageEvent checks that d is the message event for ts in channel, +// and returns its event ID. +func liveCheckMessageEvent(t *testing.T, step string, d *slackapi.Delivery, channel, ts string) string { + t.Helper() + var callback struct { + Type string `json:"type"` + EventID string `json:"event_id"` + Event struct { + Type string `json:"type"` + Channel string `json:"channel"` + TS string `json:"ts"` + } `json:"event"` + } + if err := json.Unmarshal(d.Payload, &callback); err != nil { + t.Fatalf("%s: decode payload: %v", step, err) + } + if callback.Type != "event_callback" || callback.Event.Type != "message" || callback.Event.Channel != channel || callback.Event.TS != ts || callback.EventID == "" { + t.Fatalf("%s: got %s event %s %q in %s at %s, want event_callback with the message %s in %s", step, callback.Type, callback.EventID, callback.Event.Type, callback.Event.Channel, callback.Event.TS, ts, channel) + } + return callback.EventID +} + // liveStatusWait sets the thread status, sets it again after 100 s, and waits // 60 s more, so the user can see whether the second call restarts the // 2-minute status timeout. From b9ef85f4e4f928a2225a7dc2e6ff061d7c4ceee1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 16 Oct 2026 05:11:28 +0000 Subject: [PATCH 33/33] fix(slackapi): hide the Socket Mode ticket in dial errors A failed handshake returned the *url.Error of the http.Client, whose URL holds the ticket query of the connection. Listen gave that error to OnError and the log. Dial errors now keep the cause but drop the query. --- adapters/slack/slackapi/socketmode.go | 15 ++++++++++- adapters/slack/slackapi/socketmode_test.go | 29 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/adapters/slack/slackapi/socketmode.go b/adapters/slack/slackapi/socketmode.go index b35b810..8599b97 100644 --- a/adapters/slack/slackapi/socketmode.go +++ b/adapters/slack/slackapi/socketmode.go @@ -7,6 +7,7 @@ import ( "fmt" "math/rand/v2" "net/url" + "strings" "sync" "time" @@ -490,12 +491,24 @@ func (c *socketConn) dial() (*websocket.Conn, error) { OnPingReceived: c.onPing, }) if err != nil { - return nil, fmt.Errorf("slack: socket mode dial: %w", err) + return nil, fmt.Errorf("slack: socket mode dial: %w", withoutTicket(err)) } ws.SetReadLimit(maxPayloadBytes) return ws, nil } +// withoutTicket removes the query of the connection URL from a dial error, +// because the query holds the ticket of the connection. The URL is in the +// *url.Error from the http.Client, and in the messages that wrap it. +func withoutTicket(err error) error { + ue, ok := errors.AsType[*url.Error](err) + if !ok { + return err + } + u, _, _ := strings.Cut(ue.URL, "?") + return &url.Error{Op: ue.Op, URL: u, Err: ue.Err} +} + // socketMessage holds the fields of every message that Slack sends over // Socket Mode. type socketMessage struct { diff --git a/adapters/slack/slackapi/socketmode_test.go b/adapters/slack/slackapi/socketmode_test.go index 1b65307..f30df16 100644 --- a/adapters/slack/slackapi/socketmode_test.go +++ b/adapters/slack/slackapi/socketmode_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "net/http/httptest" "net/http/httputil" @@ -1055,6 +1056,34 @@ func TestListenStopDuringTheHandshake(t *testing.T) { requireCloseFrames(t, sm) } +func TestListenDialErrorHidesTheTicket(t *testing.T) { + t.Parallel() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + closed := ln.Addr().String() + ln.Close() + srv := slackapitest.NewServer(t) + srv.Respond("apps.connections.open", map[string]any{"ok": true, "url": "ws://" + closed + "/link/?ticket=secret-ticket"}) + errs := make(chan error, 10) + opts := fastListen() + opts.OnError = sendTo(errs) + l := startListen(t, socketClient(srv), ackAll, opts) + + err = recv(t, errs) + if strings.Contains(err.Error(), "secret-ticket") { + t.Errorf("OnError(%v), want no ticket", err) + } + if _, ok := errors.AsType[*net.OpError](err); !ok { + t.Errorf("OnError(%v), want the *net.OpError of the dial", err) + } + if err := l.stop(t); err != nil { + t.Fatalf("Listen: %v", err) + } +} + func TestListenUsesTheProxyOfTheHTTPClient(t *testing.T) { t.Parallel()