Skip to content

feat(adapters/slack): add Socket Mode - #112

Open
ethanndickson wants to merge 33 commits into
mainfrom
sockets-j2dw
Open

ethanndickson wants to merge 33 commits into
mainfrom
sockets-j2dw

Conversation

@ethanndickson

@ethanndickson ethanndickson commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Slack can deliver events over Socket Mode instead of HTTP: the app opens a websocket to Slack and gets its events on it, so it doesn't need a public URL. A bot behind a firewall, a VPN, or Tailscale then works without a tunnel, and so does trying the library on a laptop. Until now we only supported HTTP.

This PR adds a Socket Mode client to slackapi, which an application with its own chat engine (say, a Slack bot built into another server) can call with its own handler, plus a fake Socket Mode in slackapitest to test against. It replaces a connection with no gap when Slack asks for a refresh or stops sending pings, always closes with a close frame (otherwise Slack can keep sending events to a dead connection for hours), and stops on token errors whilst backing off on everything else. It's built on github.com/coder/websocket, which is the module's first dependency. I've explained why in ADR 0017, along with the rest of the design.

Both transports now share one pipeline, so a payload gives the same chat.Event over HTTP and over Socket Mode. With chat.New, you set AppToken and call Chat.Listen, and Shutdown stops acking before it closes the connections, so Slack sends new events elsewhere.

There are a few behaviour changes. After Shutdown starts, sync mode now rejects dispatches with ErrAdmissionRejected, the same as deferred mode, so its message is now chat: dispatch admission rejected instead of chat: deferred dispatch admission rejected, in case anyone matches on the string. The slash command busy reply is now JSON, since a Socket Mode ack has to be. When AppToken is set, SigningSecret is no longer required, and without it Webhook just answers 404.

The docs and the hello-world example now lead with Socket Mode. Most of the diff is tests and the fake.

… 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.
…d 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.
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.
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.
…down 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".
…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.
…ndle 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.
… 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.
…hen 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.
@ethanndickson

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T05:14:51.488486Z b9ef85f Manual request
🔒 Security Review ✅ Completed 2026-10-16T05:19:27.519444Z b9ef85f Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: f92fa13e42

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ThomasK33

Copy link
Copy Markdown
Member

Maintainer review. I accept the direction. Socket Mode, one shared Delivery and Handler pipeline for both transports, the Listener capability, and the Shutdown order all fit the architecture. ADR 0017 records decisions, not mechanism, which is what we want. The core runtime changes in runtime.go and admission.go read correctly to me. I am not asking for a split: the commits are cut by area, and most of the diff is tests and the fake.

Local check: go test -race -count=5 ./adapters/slack/... . passed on f92fa13 with Go 1.27.1. CI is green, and the Codex code review is clean on this head.

Before merge:

  1. Hold the merge until the owner confirms the dependency. ADR 0017 ends the module's zero-dependency rule, which ADR 0016 recorded. I think github.com/coder/websocket is the right choice: Coder maintains it, it has no transitive dependencies, and its Close sends the close frame that the design needs. But reversing a recorded project rule is the owner's call, so I have asked him. I will post his answer here.
  2. Validate Socket Mode against live Slack. The README hello world and the tutorial now lead with Socket Mode, but only the fake tests it. bug(adapters/slack): HistoryReader fails on live Slack — conversations.history/replies sent as JSON #98 shows why that is not enough: the fake accepted JSON history calls that real Slack rejects. Please extend TestLive (or add an env-gated live test) that opens a Socket Mode connection, receives and acks one real envelope, and closes with a close frame. Then report the result here. If you can't run it before merge, mark Socket Mode as experimental in the README and the adapter docs until it is validated.
  3. Run @codex security review on the final head. Only @codex review has run so far. This PR adds a websocket client that handles tokens and remote input, so the security pass matters here.
  4. List all behavior changes in the PR body. The body names two. Please also name the ErrAdmissionRejected message change ("chat: deferred dispatch admission rejected" is now "chat: dispatch admission rejected"), in case a caller matches the string. Also say that SigningSecret is no longer required when AppToken is set.

After that, the normal gate applies: green CI, a clean @codex review and a clean @codex security review on the final head, and zero unresolved threads.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: medium

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f92fa13e42

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread adapters/slack/slackapi/socketmode.go Outdated
@ethanndickson

Copy link
Copy Markdown
Member Author

@codex review

@ethanndickson

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: a386e135d0

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: a386e135d0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.
@ThomasK33

Copy link
Copy Markdown
Member

Thanks for the quick update. Here is where the review items stand on a386e13:

  1. Dependency: Still waiting on the owner. Please keep holding the merge.
  2. Live Socket Mode test: TestLiveSocketMode looks right. Did you run it against your workspace? Please post the result here, as you did for TestLive.
  3. Security review: Clean on a386e13. One open Codex thread from 04:44 UTC is a real leak, though: a failed Socket Mode dial puts the Slack ticket into OnError and the logs. I reproduced it, and the details are in the thread. Please fix it. The fix push resets both Codex verdicts, so run both reviews again on the new head.
  4. PR body: Done. Thank you.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: medium

@ethanndickson

Copy link
Copy Markdown
Member Author

@codex review

@ethanndickson

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: b9ef85f4e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: b9ef85f4e4

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ethanndickson

Copy link
Copy Markdown
Member Author

Thanks Thomas!

  1. Sounds good, I'll wait for his answer.
  2. I've added TestLiveSocketMode in a386e13, next to TestLive, and it passes against a dev app in my test workspace. It opens a connection, posts a message with the bot token, and leaves the first envelope unacked. Slack resends it after about 3.3s with retry_attempt 1 and retry_reason timeout, so the busy path works on real Slack too. The test acks the resend, then posts a second message and acks its envelope straight away, and Slack sends nothing more for the next 10s. Finally Listen stops and Slack answers the close frame, so the close handshake completes. Whilst probing I also saw that Slack resends at about 3s, 1 minute and 5 minutes with a new envelope ID each time. Once, it delivered the same event twice with retry_attempt 0, which the adapter already dedupes by event ID.
  3. @codex review and @codex security review are both clean on b9ef85f.
  4. Done, the body now lists both.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@ThomasK33

Copy link
Copy Markdown
Member

Thanks. I checked b9ef85f, and every merge check passes:

  • CI passes.
  • The Codex code review and the Codex security review are both clean on b9ef85f.
  • No review threads are open.
  • The ticket fix is right. withoutTicket drops the query, and the *net.OpError cause survives for retry classification. TestListenDialErrorHidesTheTicket covers both.
  • Your live Socket Mode run covers resend, ack and the close handshake. The resend-timing notes are useful.

The only open item is the dependency decision. Please keep holding the merge. I will post the owner's answer here.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: medium

@ThomasK33 ThomasK33 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The owner approved the dependency: github.com/coder/websocket is allowed as the module's first dependency, as ADR 0017 proposes.

That was the last open item. Every merge check passes on b9ef85f: CI is green, the Codex code review and security review are both clean on this head, and no review threads are open. It still merges cleanly onto the current main (3c2a418). You're clear to merge.

After it merges, GitHub retargets #113 to main. If that changes #113's head, please run both Codex reviews again there.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: medium

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants