@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 +59 -7
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/__tests__/load-guards.test.ts +3 -3
- package/src/__tests__/probes.test.ts +1 -1
- package/src/__tests__/slack-config.test.ts +85 -0
- package/src/__tests__/slack-deliver.test.ts +260 -0
- package/src/__tests__/slack-normalize.test.ts +216 -0
- package/src/__tests__/sms-ingress-guard.test.ts +10 -10
- package/src/__tests__/telegram-only-default.test.ts +1 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +16 -16
- package/src/channels/transport-hints.ts +17 -0
- package/src/config.ts +44 -1
- package/src/credential-reader.ts +53 -0
- package/src/credential-watcher.ts +24 -1
- package/src/dedup-cache.ts +30 -0
- package/src/http/routes/runtime-proxy.ts +7 -1
- package/src/http/routes/slack-deliver.ts +100 -0
- package/src/http/routes/telegram-webhook.test.ts +15 -15
- package/src/http/routes/telegram-webhook.ts +3 -1
- package/src/http/routes/twilio-sms-webhook.test.ts +27 -27
- package/src/http/routes/twilio-sms-webhook.ts +3 -1
- package/src/http/routes/whatsapp-webhook.test.ts +7 -7
- package/src/http/routes/whatsapp-webhook.ts +3 -1
- package/src/index.ts +90 -5
- package/src/schema.ts +87 -0
- package/src/slack/normalize.ts +82 -0
- package/src/slack/socket-mode.ts +259 -0
- package/src/types.ts +1 -1
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,
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
|
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
|
@@ -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
|
+
});
|