Skip to content

Commit be1839e

Browse files
authored
channels: mount Slack HTTP receiver on shared Bun.serve (#103)
* channels: mount Slack HTTP receiver on shared Bun.serve The HTTP-mode Slack channel was binding ExpressReceiver to the same port as Phantom's main Bun.serve listener. The bind silently failed, the router caught the rejection, and /slack/* never came up; live canary traffic verified `POST /slack/events` returned 404 and `/health.channels.slack` was false. Refactor SlackHttpChannel into a Bolt App driven by a tiny BunReceiver whose start/stop are no-ops. The three Slack ingress paths now mount on the existing Bun.serve via slack-http-routes.ts; the channel exposes handleEvent, handleInteractivity, and handleCommand, each running the existing slack-gateway-verifier guard before dispatching the parsed body into app.processEvent. The verifier file is unchanged. Slash commands (urlencoded with top-level team_id) are picked up by a small extractor in the dispatcher so the third Slack endpoint can satisfy team-id binding without touching the verifier. Tests cover the verifier guard happy and failure paths via real Request objects, plus a hermetic Bun.serve test that proves the routes are alive end-to-end. * channels: gate slack-http dispatch on channel.isConnected() (Codex P1) The provider is wired in src/index.ts during channel setup before router.connectAll() runs, so inbound POSTs arriving in the startup window dispatched into a half-initialized channel. The gate also covers the post-disconnect() path (state flips to "disconnected") and the auth-failure path (state flips to "error"). Both 503 paths return JSON. Slack retries 503 up to 3 times within 5 minutes per the inbound contract, so the gate resolves naturally. +3 tests pinning the not-yet-connected case, the post-disconnect sibling case (audit doc inline), and the same gate on /slack/interactivity. * channels: race processEvent against ack in dispatchToBolt (Codex round 2 P1+P2) Previously dispatchToBolt awaited input.app.processEvent before returning, but Bolt's processEvent resolves only when listener middleware finishes, not when ack() fires. Phantom's Slack listener calls runtime.handleMessage which routinely outlasts Slack's ~3s ack window, so /slack/events stayed open past the deadline and triggered Slack-side retries. The catch path also called ackFn() with no args, which resolves to 200 and silently suppressed listener failures. Replace the await + try/catch with Promise.race over three tagged outcomes: ack winner returns the listener's response immediately, processEvent rejecting before ack surfaces as 500 so Slack retries, and processEvent resolving without any ack falls back to a 200 with an operator warning (defense against a hypothetical Bolt regression). The async listener work continues in the background, matching the previous HTTPReceiver processBeforeResponse=false semantics. Tests: +3 covering long-running listener (handler returns 200 while listener continues), processEvent rejects before ack (handler 500), and processEvent resolves without ack (handler 200 + warning). Total 1971 pass, typecheck and lint clean. File at 294 lines (under 300 channels/ budget).
1 parent 337a282 commit be1839e

9 files changed

Lines changed: 1026 additions & 294 deletions

src/channels/__tests__/slack-channel-factory.test.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,6 @@ describe("createSlackChannel", () => {
125125
const ch = await createSlackChannel({
126126
transport: "socket",
127127
channelsConfig: null,
128-
port: 3100,
129128
});
130129
expect(ch).toBeNull();
131130
});
@@ -137,7 +136,6 @@ describe("createSlackChannel", () => {
137136
const ch = await createSlackChannel({
138137
transport: "socket",
139138
channelsConfig: disabled,
140-
port: 3100,
141139
});
142140
expect(ch).toBeNull();
143141
});
@@ -146,7 +144,6 @@ describe("createSlackChannel", () => {
146144
const ch = await createSlackChannel({
147145
transport: "socket",
148146
channelsConfig: SOCKET_CONFIG,
149-
port: 3100,
150147
});
151148
expect(ch).toBeInstanceOf(SlackChannel);
152149
});
@@ -158,7 +155,6 @@ describe("createSlackChannel", () => {
158155
createSlackChannel({
159156
transport: "http",
160157
channelsConfig: null,
161-
port: 3100,
162158
identityFetcher: idFetcher,
163159
secretsFetcher: secFetcher,
164160
}),
@@ -171,7 +167,6 @@ describe("createSlackChannel", () => {
171167
const ch = await createSlackChannel({
172168
transport: "http",
173169
channelsConfig: null,
174-
port: 3100,
175170
identityFetcher: idFetcher,
176171
secretsFetcher: secFetcher,
177172
});
@@ -190,7 +185,6 @@ describe("createSlackChannel", () => {
190185
await createSlackChannel({
191186
transport: "http",
192187
channelsConfig: null,
193-
port: 3100,
194188
identityFetcher: idFetcher,
195189
secretsFetcher: secFetcher,
196190
});
@@ -211,7 +205,6 @@ describe("createSlackChannel", () => {
211205
const ch = await createSlackChannel({
212206
transport: "http",
213207
channelsConfig: SOCKET_CONFIG,
214-
port: 3100,
215208
identityFetcher: idFetcher,
216209
secretsFetcher: secFetcher,
217210
});
@@ -232,7 +225,6 @@ describe("createSlackChannel", () => {
232225
const ch = await createSlackChannel({
233226
transport: "http",
234227
channelsConfig: null,
235-
port: 3100,
236228
metadataBaseUrl: "http://gateway.test",
237229
identityFetcher: idFetcher,
238230
secretsFetcher: secFetcher,

0 commit comments

Comments
 (0)