@vellumai/vellum-gateway 0.3.14 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -4,7 +4,7 @@ This document owns public-ingress and channel webhook architecture. The repo-lev
4
4
 
5
5
  ## Ingress Boundary Architecture — Gateway-Only Public Ingress
6
6
 
7
- All external webhook endpoints (Telegram, Twilio, OAuth, future channels) are handled exclusively by the gateway. The runtime never exposes webhook ingress. The runtime-proxy explicitly blocks forwarding of `/webhooks/*` paths.
7
+ All external webhook endpoints (Telegram, Twilio, WhatsApp, OAuth) and persistent channel connections (Slack Socket Mode) are handled exclusively by the gateway. The runtime never exposes webhook ingress. The runtime-proxy explicitly blocks forwarding of `/webhooks/*` paths.
8
8
 
9
9
  This boundary is enforced at three layers:
10
10
 
@@ -18,6 +18,7 @@ Internet
18
18
  +-- Twilio ----------> Gateway POST /webhooks/twilio/* --> Runtime /v1/internal/twilio/*
19
19
  +-- OAuth Provider ---> Gateway GET /webhooks/oauth/callback --> Runtime /v1/internal/oauth/callback
20
20
  +-- Telegram --------> Gateway POST /webhooks/telegram --> Runtime /v1/channels/inbound
21
+ +-- Slack ------------> Gateway (Socket Mode WebSocket) --> Runtime /v1/channels/inbound
21
22
  |
22
23
  +-- Tunnel (ngrok, Cloudflare, etc.)
23
24
  |
@@ -174,10 +175,9 @@ All channel ingress paths canonicalize the `assistantId` via `normalizeAssistant
174
175
 
175
176
  #### Guardian Verify Code Parsing
176
177
 
177
- The inbound message handler (`inbound-message-handler.ts`) accepts verification codes in two formats:
178
+ The inbound message handler (`inbound-message-handler.ts`) accepts verification codes as bare-code replies:
178
179
 
179
180
  - **Bare code**: A 6-digit numeric code sent as the entire message body. This is the primary flow — the user is shown a verification code in setup UI and sends that code in-channel as a plain message.
180
- - **Legacy command**: `/guardian_verify <code>` (or `/guardian_verify@BotName <code>` for Telegram group chats). This format is still accepted for backward compatibility but is no longer the recommended flow.
181
181
 
182
182
  #### Explicit Rebind Policy
183
183
 
@@ -246,13 +246,13 @@ flowchart TD
246
246
  HAS_BINDING -- No --> DENY_ESCALATE["Deny: escalate_no_guardian"]
247
247
  HAS_BINDING -- Yes --> CREATE_APPROVAL["Create approval request<br/>+ notify guardian (dual-surface)"]
248
248
 
249
- ESCALATE_CHECK -- No --> VERIFY_CHECK{"Guardian verify<br/>code or command?"}
249
+ ESCALATE_CHECK -- No --> VERIFY_CHECK{"Guardian verify<br/>code?"}
250
250
  VERIFY_CHECK -- Yes --> VERIFY["Validate challenge<br/>→ create guardian binding"]
251
251
  VERIFY_CHECK -- No --> ROLE_RESOLVE["Resolve actor role<br/>(guardian-context-resolver)"]
252
252
  ROLE_RESOLVE --> APPROVAL_INTERCEPT["Approval interception<br/>+ message processing"]
253
253
  ```
254
254
 
255
- This ordering ensures that ingress ACL decisions are finalized before any agent processing occurs. Guardian verification codes (bare codes or the legacy `/guardian_verify` command) are intercepted after ACL enforcement but before the agent loop, so they never trigger inference.
255
+ This ordering ensures that ingress ACL decisions are finalized before any agent processing occurs. Guardian verification code replies are intercepted after ACL enforcement but before the agent loop, so they never trigger inference.
256
256
 
257
257
  #### Actor Role Resolution
258
258
 
@@ -303,7 +303,7 @@ The `channelGuardianApprovalRequests` table tracks per-run approval state. Each
303
303
  | `assistant/src/memory/channel-guardian-store.ts` | CRUD for guardian bindings, verification challenges, and approval requests (all scoped by `assistantId`) |
304
304
  | `assistant/src/runtime/channel-guardian-service.ts` | Challenge creation/validation, guardian identity checks (`isGuardian()`, `getGuardianBinding()`) -- all accept `assistantId` |
305
305
  | `assistant/src/runtime/guardian-context-resolver.ts` | Actor role classification: guardian / non-guardian / unverified_channel based on binding state + sender identity |
306
- | `assistant/src/runtime/routes/inbound-message-handler.ts` | Ingress ACL enforcement, `/guardian_verify` command intercept, escalation creation, actor role resolution |
306
+ | `assistant/src/runtime/routes/inbound-message-handler.ts` | Ingress ACL enforcement, verification-code intercept, escalation creation, actor role resolution |
307
307
  | `assistant/src/runtime/routes/channel-routes.ts` | Approval routing to guardian, proactive expiry sweep (`sweepExpiredGuardianApprovals`, `startGuardianExpirySweep`) |
308
308
  | `assistant/src/calls/guardian-dispatch.ts` | Cross-channel ASK_GUARDIAN dispatch: creates guardian_action_requests, fans out to mac/telegram/sms, manages deliveries |
309
309
  | `assistant/src/calls/guardian-action-sweep.ts` | Periodic 60s sweep for expired guardian action requests; sends expiry notices to delivery channels |
@@ -315,7 +315,7 @@ The ingress membership system extends the guardian security model to support con
315
315
 
316
316
  #### Ingress Membership ACL
317
317
 
318
- The channel inbound handler (`inbound-message-handler.ts`) enforces an access control layer between message receipt and agent processing. The ACL runs at the top of the handler, before guardian role resolution or `/guardian_verify` command interception (see [Inbound Message Decision Chain](#inbound-message-decision-chain) for the full ordering):
318
+ The channel inbound handler (`inbound-message-handler.ts`) enforces an access control layer between message receipt and agent processing. The ACL runs at the top of the handler, before guardian role resolution or verification-code interception (see [Inbound Message Decision Chain](#inbound-message-decision-chain) for the full ordering):
319
319
 
320
320
  1. When `senderExternalUserId` is present, the handler looks up the sender in `assistant_ingress_members` by `(sourceChannel, externalUserId)` or `(sourceChannel, externalChatId)`.
321
321
  2. If no member record exists, the message is denied (`not_a_member`).
@@ -435,6 +435,58 @@ In single-assistant mode (the default local deployment), routing is automaticall
435
435
 
436
436
  In multi-assistant mode, the operator must configure `GATEWAY_ASSISTANT_ROUTING_JSON` to map specific chat/user IDs to assistant IDs.
437
437
 
438
+ ### Slack Channel (Socket Mode)
439
+
440
+ The Slack channel enables inbound and outbound messaging via Slack's Socket Mode API. Unlike Telegram, SMS, and WhatsApp which use HTTP webhook ingress, Slack uses a persistent WebSocket connection — no public ingress URL is required.
441
+
442
+ **Connection lifecycle** (`apps.connections.open`):
443
+
444
+ 1. The gateway calls `POST https://slack.com/api/apps.connections.open` with the Slack app-level token (`xapp-...`) to obtain a WebSocket URL.
445
+ 2. A `SlackSocketModeClient` opens the WebSocket and maintains a single active connection.
446
+ 3. On connection, the client resets its reconnect counter. On close or error, it schedules a reconnect with capped exponential backoff (1s base, 30s max) plus 0-50% jitter.
447
+ 4. Slack may send a `disconnect` envelope to request a reconnect (e.g., server rotation). The client handles this by closing the current socket and reconnecting immediately (attempt counter reset to 0).
448
+
449
+ **Event processing** (inbound):
450
+
451
+ 1. Every Socket Mode envelope is ACKed immediately by echoing `{ envelope_id }` back on the WebSocket — this is required by Slack regardless of whether the event is processed.
452
+ 2. Only `events_api` envelopes with `app_mention` events are processed in MVP. Other envelope types (slash commands, interactive payloads) are ACKed but ignored.
453
+ 3. Events are deduplicated by `event_id` using an in-memory `Map<string, number>` with a 24-hour TTL. A periodic cleanup sweep runs every hour to evict expired entries.
454
+ 4. The `normalizeSlackAppMention()` function strips leading bot-mention tokens (`<@U...>`) from the message text and produces a `GatewayInboundEventV1` with `sourceChannel: "slack"`, using the Slack channel ID as `externalChatId` and the sender's user ID as `externalUserId`.
455
+ 5. Routing uses the standard `resolveAssistant()` chain (chat_id -> user_id -> default/reject). Events that cannot be routed are dropped.
456
+ 6. The normalized event is forwarded to the runtime via `POST /v1/channels/inbound` with Slack-specific transport hints and a `replyCallbackUrl` pointing to `/deliver/slack`.
457
+
458
+ **Egress** (`POST /deliver/slack`):
459
+
460
+ 1. The runtime calls the gateway's `/deliver/slack` endpoint with `{ chatId, text }` or `{ to, text }` (alias). The `chatId` field maps to the Slack channel ID where the reply should be posted.
461
+ 2. The gateway authenticates the request via bearer token (same fail-closed model as other deliver endpoints).
462
+ 3. The gateway posts the message via `POST https://slack.com/api/chat.postMessage` using the bot token.
463
+ 4. Threading is supported via a `threadTs` query parameter on the deliver URL. When present, replies are posted as thread replies to the specified message timestamp.
464
+
465
+ **Credential management:**
466
+
467
+ The Slack channel requires two tokens:
468
+
469
+ | Token | Format | Purpose |
470
+ |-------|--------|---------|
471
+ | App token | `xapp-...` | Used for `apps.connections.open` to establish the Socket Mode WebSocket connection |
472
+ | Bot token | `xoxb-...` | Used for `chat.postMessage` to send outbound messages and for `auth.test` validation |
473
+
474
+ Both tokens are stored in secure storage (`credential:slack_channel:app_token`, `credential:slack_channel:bot_token`) via the assistant's Slack channel config endpoints (see `assistant/ARCHITECTURE.md`). The gateway reads them via its `credential-reader` module using the same keychain-first fallback strategy as Telegram credentials.
475
+
476
+ **Auto-reconnect behavior:**
477
+
478
+ The Socket Mode client auto-reconnects on any WebSocket close or error. The backoff schedule is: `min(1000 * 2^attempt, 30000)` + random jitter. After a successful connection, the attempt counter resets. Slack-initiated disconnects (envelope `type: "disconnect"`) trigger an immediate reconnect with no backoff.
479
+
480
+ **Key modules:**
481
+
482
+ | Module | Purpose |
483
+ |--------|---------|
484
+ | `gateway/src/slack/socket-mode.ts` | `SlackSocketModeClient` — WebSocket lifecycle, ACK, dedup, auto-reconnect |
485
+ | `gateway/src/slack/normalize.ts` | `normalizeSlackAppMention()` — event normalization and bot-mention stripping |
486
+ | `gateway/src/http/routes/slack-deliver.ts` | `/deliver/slack` — outbound message delivery via `chat.postMessage` |
487
+
488
+ **Limitations (MVP):** Text-only — attachments are rejected. Only `app_mention` events are processed (direct messages to the bot are not handled). Rich approval UI (inline buttons) is not supported.
489
+
438
490
  ---
439
491
 
440
492
  ## AI Phone Calls — Twilio ConversationRelay
package/README.md CHANGED
@@ -403,7 +403,7 @@ See [`benchmarking/gateway/README.md`](../benchmarking/gateway/README.md) for lo
403
403
 
404
404
  | Symptom | Cause | Resolution |
405
405
  |---------|-------|------------|
406
- | `/guardian_verify` command gets no reply | The verification message did not reach the runtime, or the challenge expired | Ensure the gateway is running, the bot token is valid, and the Telegram webhook is registered. Challenges expire after 10 minutes -- generate a new one via the desktop UI. |
406
+ | Guardian verification code reply gets no response | The verification message did not reach the runtime, or the challenge expired | Ensure the gateway is running, the bot token is valid, and the Telegram webhook is registered. Challenges expire after 10 minutes -- generate a new one via the desktop UI. |
407
407
  | Non-guardian actions auto-denied with "no guardian configured" | No guardian binding exists for the channel. The runtime is fail-closed for unverified channels. | Set up a guardian by running the verification flow from the desktop UI. |
408
408
  | Approval prompt not delivered to guardian | The `replyCallbackUrl` may be unreachable, or the guardian's chat ID is stale | Verify `GATEWAY_INTERNAL_BASE_URL` is set correctly (especially in containerized deployments). Re-verify the guardian if the chat ID has changed. |
409
409
  | Guardian approval expired | The 30-minute TTL elapsed without a decision. A proactive sweep (every 60s) auto-denied the approval and notified both the requester and guardian. | The non-guardian user must re-trigger the action. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "bun run --watch src/index.ts",
@@ -54,7 +54,7 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
54
54
 
55
55
  describe("payload size guard", () => {
56
56
  test("returns 413 when content-length exceeds limit", async () => {
57
- const handler = createTelegramWebhookHandler(makeConfig());
57
+ const { handler } = createTelegramWebhookHandler(makeConfig());
58
58
  const body = JSON.stringify({ data: "x".repeat(300) });
59
59
  const req = new Request("http://localhost:7830/webhooks/telegram", {
60
60
  method: "POST",
@@ -72,7 +72,7 @@ describe("payload size guard", () => {
72
72
  });
73
73
 
74
74
  test("returns 413 when body exceeds limit even without content-length", async () => {
75
- const handler = createTelegramWebhookHandler(makeConfig());
75
+ const { handler } = createTelegramWebhookHandler(makeConfig());
76
76
  const body = JSON.stringify({ data: "x".repeat(300) });
77
77
  const req = new Request("http://localhost:7830/webhooks/telegram", {
78
78
  method: "POST",
@@ -87,7 +87,7 @@ describe("payload size guard", () => {
87
87
  });
88
88
 
89
89
  test("accepts payload within limit", async () => {
90
- const handler = createTelegramWebhookHandler(
90
+ const { handler } = createTelegramWebhookHandler(
91
91
  makeConfig({ maxWebhookPayloadBytes: 10000 }),
92
92
  );
93
93
  const body = JSON.stringify({ update_id: 1, message: { text: "hi", chat: { id: 1, type: "private" }, from: { id: 1 }, message_id: 1 } });
@@ -22,7 +22,7 @@ const { createTelegramWebhookHandler } = await import(
22
22
 
23
23
  const config = loadConfig();
24
24
 
25
- const handleTelegramWebhook = createTelegramWebhookHandler(config);
25
+ const { handler: handleTelegramWebhook } = createTelegramWebhookHandler(config);
26
26
 
27
27
  let draining = false;
28
28
 
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { isSlackChannelConfigured, type GatewayConfig } from "../config.js";
3
+
4
+ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
5
+ return {
6
+ assistantRuntimeBaseUrl: "http://localhost:7821",
7
+ defaultAssistantId: undefined,
8
+ gatewayInternalBaseUrl: "http://127.0.0.1:7830",
9
+ logFile: { dir: undefined, retentionDays: 30 },
10
+ maxAttachmentBytes: 20971520,
11
+ maxAttachmentConcurrency: 3,
12
+ maxWebhookPayloadBytes: 1048576,
13
+ port: 7830,
14
+ routingEntries: [],
15
+ runtimeBearerToken: undefined,
16
+ runtimeGatewayOriginSecret: undefined,
17
+ runtimeInitialBackoffMs: 500,
18
+ runtimeMaxRetries: 2,
19
+ runtimeProxyBearerToken: undefined,
20
+ runtimeProxyEnabled: false,
21
+ runtimeProxyRequireAuth: false,
22
+ runtimeTimeoutMs: 30000,
23
+ shutdownDrainMs: 5000,
24
+ telegramApiBaseUrl: "https://api.telegram.org",
25
+ telegramBotToken: undefined,
26
+ telegramDeliverAuthBypass: false,
27
+ telegramInitialBackoffMs: 1000,
28
+ telegramMaxRetries: 3,
29
+ telegramTimeoutMs: 15000,
30
+ telegramWebhookSecret: undefined,
31
+ twilioAuthToken: undefined,
32
+ twilioAccountSid: undefined,
33
+ twilioPhoneNumber: undefined,
34
+ smsDeliverAuthBypass: false,
35
+ ingressPublicBaseUrl: undefined,
36
+ unmappedPolicy: "reject",
37
+ whatsappPhoneNumberId: undefined,
38
+ whatsappAccessToken: undefined,
39
+ whatsappAppSecret: undefined,
40
+ whatsappWebhookVerifyToken: undefined,
41
+ whatsappDeliverAuthBypass: false,
42
+ whatsappTimeoutMs: 15000,
43
+ whatsappMaxRetries: 3,
44
+ whatsappInitialBackoffMs: 1000,
45
+ slackChannelBotToken: undefined,
46
+ slackChannelAppToken: undefined,
47
+ slackDeliverAuthBypass: false,
48
+ trustProxy: false,
49
+ ...overrides,
50
+ } as GatewayConfig;
51
+ }
52
+
53
+ describe("isSlackChannelConfigured", () => {
54
+ test("returns true when both tokens are set", () => {
55
+ const config = makeConfig({
56
+ slackChannelBotToken: "xoxb-test-token",
57
+ slackChannelAppToken: "xapp-test-token",
58
+ });
59
+ expect(isSlackChannelConfigured(config)).toBe(true);
60
+ });
61
+
62
+ test("returns false when bot token is missing", () => {
63
+ const config = makeConfig({
64
+ slackChannelBotToken: undefined,
65
+ slackChannelAppToken: "xapp-test-token",
66
+ });
67
+ expect(isSlackChannelConfigured(config)).toBe(false);
68
+ });
69
+
70
+ test("returns false when app token is missing", () => {
71
+ const config = makeConfig({
72
+ slackChannelBotToken: "xoxb-test-token",
73
+ slackChannelAppToken: undefined,
74
+ });
75
+ expect(isSlackChannelConfigured(config)).toBe(false);
76
+ });
77
+
78
+ test("returns false when both tokens are missing", () => {
79
+ const config = makeConfig({
80
+ slackChannelBotToken: undefined,
81
+ slackChannelAppToken: undefined,
82
+ });
83
+ expect(isSlackChannelConfigured(config)).toBe(false);
84
+ });
85
+ });
@@ -0,0 +1,260 @@
1
+ import { describe, test, expect, mock, beforeEach } from "bun:test";
2
+ import type { GatewayConfig } from "../config.js";
3
+
4
+ type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
5
+ let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
6
+
7
+ mock.module("../fetch.js", () => ({
8
+ fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
9
+ }));
10
+
11
+ const { createSlackDeliverHandler } = await import("../http/routes/slack-deliver.js");
12
+
13
+ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
14
+ const merged: GatewayConfig = {
15
+ assistantRuntimeBaseUrl: "http://localhost:7821",
16
+ defaultAssistantId: undefined,
17
+ gatewayInternalBaseUrl: "http://127.0.0.1:7830",
18
+ logFile: { dir: undefined, retentionDays: 30 },
19
+ maxAttachmentBytes: 20971520,
20
+ maxAttachmentConcurrency: 3,
21
+ maxWebhookPayloadBytes: 1048576,
22
+ port: 7830,
23
+ routingEntries: [],
24
+ runtimeBearerToken: undefined,
25
+ runtimeGatewayOriginSecret: undefined,
26
+ runtimeInitialBackoffMs: 500,
27
+ runtimeMaxRetries: 2,
28
+ runtimeProxyBearerToken: undefined,
29
+ runtimeProxyEnabled: false,
30
+ runtimeProxyRequireAuth: false,
31
+ runtimeTimeoutMs: 30000,
32
+ shutdownDrainMs: 5000,
33
+ telegramApiBaseUrl: "https://api.telegram.org",
34
+ telegramBotToken: undefined,
35
+ telegramDeliverAuthBypass: false,
36
+ telegramInitialBackoffMs: 1000,
37
+ telegramMaxRetries: 3,
38
+ telegramTimeoutMs: 15000,
39
+ telegramWebhookSecret: undefined,
40
+ twilioAuthToken: undefined,
41
+ twilioAccountSid: undefined,
42
+ twilioPhoneNumber: undefined,
43
+ smsDeliverAuthBypass: false,
44
+ ingressPublicBaseUrl: undefined,
45
+ unmappedPolicy: "reject",
46
+ whatsappPhoneNumberId: undefined,
47
+ whatsappAccessToken: undefined,
48
+ whatsappAppSecret: undefined,
49
+ whatsappWebhookVerifyToken: undefined,
50
+ whatsappDeliverAuthBypass: false,
51
+ whatsappTimeoutMs: 15000,
52
+ whatsappMaxRetries: 3,
53
+ whatsappInitialBackoffMs: 1000,
54
+ slackChannelBotToken: "xoxb-test-bot-token",
55
+ slackChannelAppToken: undefined,
56
+ slackDeliverAuthBypass: true,
57
+ trustProxy: false,
58
+ ...overrides,
59
+ } as GatewayConfig;
60
+ if (merged.runtimeGatewayOriginSecret === undefined) {
61
+ merged.runtimeGatewayOriginSecret = merged.runtimeBearerToken;
62
+ }
63
+ return merged;
64
+ }
65
+
66
+ const TOKEN = "test-deliver-token";
67
+
68
+ function makeRequest(
69
+ body: unknown,
70
+ headers?: Record<string, string>,
71
+ queryString = "",
72
+ ): Request {
73
+ return new Request(`http://localhost:7830/deliver/slack${queryString}`, {
74
+ method: "POST",
75
+ headers: { "content-type": "application/json", ...headers },
76
+ body: JSON.stringify(body),
77
+ });
78
+ }
79
+
80
+ let fetchCalls: { url: string; body?: unknown; headers?: Record<string, string> }[];
81
+
82
+ beforeEach(() => {
83
+ fetchCalls = [];
84
+ fetchMock = mock(async (input: string | URL | Request, init?: RequestInit) => {
85
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
86
+ let body: unknown;
87
+ try {
88
+ if (init?.body) body = JSON.parse(String(init.body));
89
+ } catch { /* not JSON */ }
90
+ const headers: Record<string, string> = {};
91
+ if (init?.headers) {
92
+ const h = init.headers;
93
+ if (h && typeof h === "object" && !Array.isArray(h)) {
94
+ for (const [k, v] of Object.entries(h)) {
95
+ headers[k.toLowerCase()] = v;
96
+ }
97
+ }
98
+ }
99
+ fetchCalls.push({ url, body, headers });
100
+
101
+ // Slack API response
102
+ if (url.includes("slack.com/api/chat.postMessage")) {
103
+ return new Response(JSON.stringify({ ok: true }), {
104
+ status: 200,
105
+ headers: { "content-type": "application/json" },
106
+ });
107
+ }
108
+ return new Response("Not found", { status: 404 });
109
+ });
110
+ });
111
+
112
+ describe("slack-deliver endpoint", () => {
113
+ test("returns 401 when auth is required and missing", async () => {
114
+ const handler = createSlackDeliverHandler(
115
+ makeConfig({ runtimeProxyBearerToken: TOKEN, slackDeliverAuthBypass: false }),
116
+ );
117
+ const req = makeRequest({ chatId: "C123", text: "hello" });
118
+ const res = await handler(req);
119
+ expect(res.status).toBe(401);
120
+ const body = await res.json();
121
+ expect(body.error).toBe("Unauthorized");
122
+ });
123
+
124
+ test("returns 200 with valid payload containing chatId and text", async () => {
125
+ const handler = createSlackDeliverHandler(makeConfig());
126
+ const req = makeRequest({ chatId: "C123", text: "hello" });
127
+ const res = await handler(req);
128
+ expect(res.status).toBe(200);
129
+ const body = await res.json();
130
+ expect(body.ok).toBe(true);
131
+
132
+ // Verify the Slack API was called with the correct payload
133
+ const slackCall = fetchCalls.find((c) => c.url.includes("chat.postMessage"));
134
+ expect(slackCall).toBeDefined();
135
+ expect((slackCall!.body as any).channel).toBe("C123");
136
+ expect((slackCall!.body as any).text).toBe("hello");
137
+ });
138
+
139
+ test("threadTs query param gets passed as thread_ts to Slack API", async () => {
140
+ const handler = createSlackDeliverHandler(makeConfig());
141
+ const req = makeRequest(
142
+ { chatId: "C123", text: "reply in thread" },
143
+ undefined,
144
+ "?threadTs=1700000000.000050",
145
+ );
146
+ const res = await handler(req);
147
+ expect(res.status).toBe(200);
148
+
149
+ const slackCall = fetchCalls.find((c) => c.url.includes("chat.postMessage"));
150
+ expect(slackCall).toBeDefined();
151
+ expect((slackCall!.body as any).thread_ts).toBe("1700000000.000050");
152
+ });
153
+
154
+ test("returns 400 when chatId/to is missing", async () => {
155
+ const handler = createSlackDeliverHandler(makeConfig());
156
+ const req = makeRequest({ text: "hello" });
157
+ const res = await handler(req);
158
+ expect(res.status).toBe(400);
159
+ const body = await res.json();
160
+ expect(body.error).toBe("chatId is required");
161
+ });
162
+
163
+ test("returns 400 with 'not supported' message when attachments are provided", async () => {
164
+ const handler = createSlackDeliverHandler(makeConfig());
165
+ const req = makeRequest({ chatId: "C123", text: "hello", attachments: [{ id: "att-1" }] });
166
+ const res = await handler(req);
167
+ expect(res.status).toBe(400);
168
+ const body = await res.json();
169
+ expect(body.error).toContain("not supported");
170
+ });
171
+
172
+ test("returns 503 when bot token is not configured", async () => {
173
+ const handler = createSlackDeliverHandler(
174
+ makeConfig({ slackChannelBotToken: undefined }),
175
+ );
176
+ const req = makeRequest({ chatId: "C123", text: "hello" });
177
+ const res = await handler(req);
178
+ expect(res.status).toBe(503);
179
+ const body = await res.json();
180
+ expect(body.error).toContain("not configured");
181
+ });
182
+
183
+ test("accepts 'to' as alias for chatId", async () => {
184
+ const handler = createSlackDeliverHandler(makeConfig());
185
+ const req = makeRequest({ to: "C_TO_CHAN", text: "hello" });
186
+ const res = await handler(req);
187
+ expect(res.status).toBe(200);
188
+
189
+ const slackCall = fetchCalls.find((c) => c.url.includes("chat.postMessage"));
190
+ expect(slackCall).toBeDefined();
191
+ expect((slackCall!.body as any).channel).toBe("C_TO_CHAN");
192
+ });
193
+
194
+ test("returns 400 when text is missing", async () => {
195
+ const handler = createSlackDeliverHandler(makeConfig());
196
+ const req = makeRequest({ chatId: "C123" });
197
+ const res = await handler(req);
198
+ expect(res.status).toBe(400);
199
+ const body = await res.json();
200
+ expect(body.error).toBe("text is required");
201
+ });
202
+
203
+ test("returns 400 for invalid JSON", async () => {
204
+ const handler = createSlackDeliverHandler(makeConfig());
205
+ const req = new Request("http://localhost:7830/deliver/slack", {
206
+ method: "POST",
207
+ headers: { "content-type": "application/json" },
208
+ body: "not-json",
209
+ });
210
+ const res = await handler(req);
211
+ expect(res.status).toBe(400);
212
+ const body = await res.json();
213
+ expect(body.error).toBe("Invalid JSON");
214
+ });
215
+
216
+ test("returns 405 for GET requests", async () => {
217
+ const handler = createSlackDeliverHandler(makeConfig());
218
+ const req = new Request("http://localhost:7830/deliver/slack", {
219
+ method: "GET",
220
+ });
221
+ const res = await handler(req);
222
+ expect(res.status).toBe(405);
223
+ });
224
+
225
+ test("sends Authorization header with bot token to Slack API", async () => {
226
+ const handler = createSlackDeliverHandler(
227
+ makeConfig({ slackChannelBotToken: "xoxb-my-secret-token" }),
228
+ );
229
+ const req = makeRequest({ chatId: "C123", text: "hello" });
230
+ await handler(req);
231
+
232
+ const slackCall = fetchCalls.find((c) => c.url.includes("chat.postMessage"));
233
+ expect(slackCall).toBeDefined();
234
+ expect(slackCall!.headers!["authorization"]).toBe("Bearer xoxb-my-secret-token");
235
+ });
236
+
237
+ test("returns 502 when Slack API returns ok: false", async () => {
238
+ fetchMock = mock(async () => {
239
+ return new Response(JSON.stringify({ ok: false, error: "channel_not_found" }), {
240
+ status: 200,
241
+ headers: { "content-type": "application/json" },
242
+ });
243
+ });
244
+
245
+ const handler = createSlackDeliverHandler(makeConfig());
246
+ const req = makeRequest({ chatId: "C123", text: "hello" });
247
+ const res = await handler(req);
248
+ expect(res.status).toBe(502);
249
+ });
250
+
251
+ test("does not include thread_ts when threadTs query param is absent", async () => {
252
+ const handler = createSlackDeliverHandler(makeConfig());
253
+ const req = makeRequest({ chatId: "C123", text: "hello" });
254
+ await handler(req);
255
+
256
+ const slackCall = fetchCalls.find((c) => c.url.includes("chat.postMessage"));
257
+ expect(slackCall).toBeDefined();
258
+ expect((slackCall!.body as any).thread_ts).toBeUndefined();
259
+ });
260
+ });