@vellumai/vellum-gateway 0.1.7

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.
Files changed (42) hide show
  1. package/.dockerignore +7 -0
  2. package/.env.example +59 -0
  3. package/Dockerfile +44 -0
  4. package/README.md +186 -0
  5. package/bun.lock +391 -0
  6. package/eslint.config.mjs +23 -0
  7. package/knip.json +8 -0
  8. package/package.json +27 -0
  9. package/src/__tests__/bearer-auth.test.ts +40 -0
  10. package/src/__tests__/config.test.ts +236 -0
  11. package/src/__tests__/dedup-cache.test.ts +101 -0
  12. package/src/__tests__/load-guards.test.ts +86 -0
  13. package/src/__tests__/probes.test.ts +94 -0
  14. package/src/__tests__/reply-path.test.ts +51 -0
  15. package/src/__tests__/resolve-assistant.test.ts +118 -0
  16. package/src/__tests__/runtime-client.test.ts +228 -0
  17. package/src/__tests__/runtime-proxy-auth.test.ts +127 -0
  18. package/src/__tests__/runtime-proxy.test.ts +262 -0
  19. package/src/__tests__/schema.test.ts +128 -0
  20. package/src/__tests__/telegram-normalize.test.ts +303 -0
  21. package/src/__tests__/telegram-only-default.test.ts +134 -0
  22. package/src/__tests__/telegram-send-attachments.test.ts +185 -0
  23. package/src/cli/schema.ts +8 -0
  24. package/src/config.ts +254 -0
  25. package/src/dedup-cache.ts +104 -0
  26. package/src/handlers/handle-inbound.ts +104 -0
  27. package/src/http/auth/bearer.ts +34 -0
  28. package/src/http/routes/runtime-proxy.ts +143 -0
  29. package/src/http/routes/telegram-webhook.ts +272 -0
  30. package/src/index.ts +117 -0
  31. package/src/logger.ts +103 -0
  32. package/src/routing/resolve-assistant.ts +45 -0
  33. package/src/routing/types.ts +11 -0
  34. package/src/runtime/client.ts +212 -0
  35. package/src/schema.ts +383 -0
  36. package/src/telegram/api.ts +153 -0
  37. package/src/telegram/download.ts +63 -0
  38. package/src/telegram/normalize.ts +118 -0
  39. package/src/telegram/send.ts +107 -0
  40. package/src/telegram/verify.ts +17 -0
  41. package/src/types.ts +37 -0
  42. package/tsconfig.json +20 -0
@@ -0,0 +1,236 @@
1
+ import { writeFileSync, unlinkSync } from "node:fs";
2
+ import { describe, test, expect } from "bun:test";
3
+ import { loadConfig } from "../config.js";
4
+
5
+ const BASE_ENV = {
6
+ TELEGRAM_BOT_TOKEN: "tok",
7
+ TELEGRAM_WEBHOOK_SECRET: "wh-sec",
8
+ ASSISTANT_RUNTIME_BASE_URL: "http://localhost:7821",
9
+ };
10
+
11
+ function withEnv(overrides: Record<string, string | undefined>, fn: () => void) {
12
+ const saved: Record<string, string | undefined> = {};
13
+ const allKeys = [
14
+ ...Object.keys(BASE_ENV),
15
+ ...Object.keys(overrides),
16
+ "GATEWAY_RUNTIME_PROXY_ENABLED",
17
+ "GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH",
18
+ "RUNTIME_BEARER_TOKEN",
19
+ "RUNTIME_PROXY_BEARER_TOKEN",
20
+ "GATEWAY_ASSISTANT_ROUTING_JSON",
21
+ "GATEWAY_DEFAULT_ASSISTANT_ID",
22
+ "GATEWAY_UNMAPPED_POLICY",
23
+ "GATEWAY_PORT",
24
+ "GATEWAY_SHUTDOWN_DRAIN_MS",
25
+ "GATEWAY_RUNTIME_TIMEOUT_MS",
26
+ "GATEWAY_RUNTIME_MAX_RETRIES",
27
+ "GATEWAY_RUNTIME_INITIAL_BACKOFF_MS",
28
+ "GATEWAY_TELEGRAM_TIMEOUT_MS",
29
+ "GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES",
30
+ "GATEWAY_MAX_ATTACHMENT_BYTES",
31
+ "GATEWAY_MAX_ATTACHMENT_CONCURRENCY",
32
+ "VELLUM_HTTP_TOKEN_PATH",
33
+ ];
34
+
35
+ for (const key of allKeys) {
36
+ saved[key] = process.env[key];
37
+ delete process.env[key];
38
+ }
39
+
40
+ Object.assign(process.env, BASE_ENV, overrides);
41
+
42
+ try {
43
+ fn();
44
+ } finally {
45
+ for (const key of allKeys) {
46
+ if (saved[key] === undefined) {
47
+ delete process.env[key];
48
+ } else {
49
+ process.env[key] = saved[key];
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ describe("config: Telegram-only default mode", () => {
56
+ test("proxy is disabled when GATEWAY_RUNTIME_PROXY_ENABLED is unset", () => {
57
+ withEnv({}, () => {
58
+ const config = loadConfig();
59
+ expect(config.runtimeProxyEnabled).toBe(false);
60
+ });
61
+ });
62
+
63
+ test("proxy is disabled when GATEWAY_RUNTIME_PROXY_ENABLED is explicitly false", () => {
64
+ withEnv({ GATEWAY_RUNTIME_PROXY_ENABLED: "false" }, () => {
65
+ const config = loadConfig();
66
+ expect(config.runtimeProxyEnabled).toBe(false);
67
+ });
68
+ });
69
+ });
70
+
71
+ describe("config: runtime proxy flags", () => {
72
+ test("proxy disabled by default", () => {
73
+ withEnv({ VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token" }, () => {
74
+ const config = loadConfig();
75
+ expect(config.runtimeProxyEnabled).toBe(false);
76
+ expect(config.runtimeProxyRequireAuth).toBe(true);
77
+ expect(config.runtimeProxyBearerToken).toBeUndefined();
78
+ });
79
+ });
80
+
81
+ test("proxy enabled with auth and valid token", () => {
82
+ withEnv(
83
+ {
84
+ GATEWAY_RUNTIME_PROXY_ENABLED: "true",
85
+ RUNTIME_PROXY_BEARER_TOKEN: "secret-key",
86
+ },
87
+ () => {
88
+ const config = loadConfig();
89
+ expect(config.runtimeProxyEnabled).toBe(true);
90
+ expect(config.runtimeProxyRequireAuth).toBe(true);
91
+ expect(config.runtimeProxyBearerToken).toBe("secret-key");
92
+ },
93
+ );
94
+ });
95
+
96
+ test("proxy enabled with auth disabled (no token needed)", () => {
97
+ withEnv(
98
+ {
99
+ GATEWAY_RUNTIME_PROXY_ENABLED: "true",
100
+ GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
101
+ VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token",
102
+ },
103
+ () => {
104
+ const config = loadConfig();
105
+ expect(config.runtimeProxyEnabled).toBe(true);
106
+ expect(config.runtimeProxyRequireAuth).toBe(false);
107
+ expect(config.runtimeProxyBearerToken).toBeUndefined();
108
+ },
109
+ );
110
+ });
111
+
112
+ test("proxy enabled with auth required but no token throws", () => {
113
+ withEnv(
114
+ {
115
+ GATEWAY_RUNTIME_PROXY_ENABLED: "true",
116
+ GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "true",
117
+ VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token",
118
+ },
119
+ () => {
120
+ expect(() => loadConfig()).toThrow(
121
+ "RUNTIME_PROXY_BEARER_TOKEN is required when proxy is enabled with auth required",
122
+ );
123
+ },
124
+ );
125
+ });
126
+
127
+ test("proxy disabled ignores missing token even with auth required", () => {
128
+ withEnv(
129
+ {
130
+ GATEWAY_RUNTIME_PROXY_ENABLED: "false",
131
+ GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "true",
132
+ VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token",
133
+ },
134
+ () => {
135
+ const config = loadConfig();
136
+ expect(config.runtimeProxyEnabled).toBe(false);
137
+ },
138
+ );
139
+ });
140
+
141
+ test("proxy enabled defaults auth to required", () => {
142
+ withEnv(
143
+ {
144
+ GATEWAY_RUNTIME_PROXY_ENABLED: "true",
145
+ RUNTIME_PROXY_BEARER_TOKEN: "my-token",
146
+ VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token",
147
+ },
148
+ () => {
149
+ const config = loadConfig();
150
+ expect(config.runtimeProxyRequireAuth).toBe(true);
151
+ },
152
+ );
153
+ });
154
+
155
+ test("reads bearer token from http-token file when available", () => {
156
+ /** Verifies the gateway reads the daemon's http-token file for auth. */
157
+ withEnv(
158
+ {
159
+ GATEWAY_RUNTIME_PROXY_ENABLED: "true",
160
+ VELLUM_HTTP_TOKEN_PATH: "/tmp/test-http-token",
161
+ },
162
+ () => {
163
+ // GIVEN an http-token file exists with a known token
164
+ writeFileSync("/tmp/test-http-token", "file-based-token\n");
165
+
166
+ // WHEN we load the config
167
+ const config = loadConfig();
168
+
169
+ // THEN the bearer token is read from the file (trimmed)
170
+ expect(config.runtimeProxyBearerToken).toBe("file-based-token");
171
+
172
+ // AND cleanup
173
+ unlinkSync("/tmp/test-http-token");
174
+ },
175
+ );
176
+ });
177
+
178
+ test("env var takes precedence over http-token file", () => {
179
+ /** Verifies that the env var is preferred over the http-token file. */
180
+ withEnv(
181
+ {
182
+ GATEWAY_RUNTIME_PROXY_ENABLED: "true",
183
+ RUNTIME_PROXY_BEARER_TOKEN: "env-token",
184
+ VELLUM_HTTP_TOKEN_PATH: "/tmp/test-http-token-priority",
185
+ },
186
+ () => {
187
+ // GIVEN an http-token file exists with a different token than the env var
188
+ writeFileSync("/tmp/test-http-token-priority", "file-token");
189
+
190
+ // WHEN we load the config
191
+ const config = loadConfig();
192
+
193
+ // THEN the env var token takes precedence
194
+ expect(config.runtimeProxyBearerToken).toBe("env-token");
195
+
196
+ // AND cleanup
197
+ unlinkSync("/tmp/test-http-token-priority");
198
+ },
199
+ );
200
+ });
201
+
202
+ test("falls back to env var when http-token file is missing", () => {
203
+ /** Verifies fallback to RUNTIME_PROXY_BEARER_TOKEN env var. */
204
+ withEnv(
205
+ {
206
+ GATEWAY_RUNTIME_PROXY_ENABLED: "true",
207
+ RUNTIME_PROXY_BEARER_TOKEN: "env-fallback-token",
208
+ VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token",
209
+ },
210
+ () => {
211
+ // GIVEN the http-token file does not exist
212
+ // WHEN we load the config
213
+ const config = loadConfig();
214
+
215
+ // THEN the env var token is used as fallback
216
+ expect(config.runtimeProxyBearerToken).toBe("env-fallback-token");
217
+ },
218
+ );
219
+ });
220
+ });
221
+
222
+ describe("config: runtime bearer token", () => {
223
+ test("runtimeBearerToken is undefined when RUNTIME_BEARER_TOKEN is unset", () => {
224
+ withEnv({}, () => {
225
+ const config = loadConfig();
226
+ expect(config.runtimeBearerToken).toBeUndefined();
227
+ });
228
+ });
229
+
230
+ test("runtimeBearerToken is set from RUNTIME_BEARER_TOKEN env var", () => {
231
+ withEnv({ RUNTIME_BEARER_TOKEN: "rt-secret" }, () => {
232
+ const config = loadConfig();
233
+ expect(config.runtimeBearerToken).toBe("rt-secret");
234
+ });
235
+ });
236
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, test, expect, beforeEach } from "bun:test";
2
+ import { DedupCache } from "../dedup-cache.js";
3
+
4
+ describe("DedupCache", () => {
5
+ let cache: DedupCache;
6
+
7
+ beforeEach(() => {
8
+ cache = new DedupCache(5_000, 100);
9
+ });
10
+
11
+ test("returns undefined for unseen update_id", () => {
12
+ expect(cache.get(123)).toBeUndefined();
13
+ });
14
+
15
+ test("returns cached entry after set", () => {
16
+ cache.set(100, '{"ok":true}', 200);
17
+ const hit = cache.get(100);
18
+ expect(hit).toEqual({ body: '{"ok":true}', status: 200 });
19
+ });
20
+
21
+ test("different update_ids are independent", () => {
22
+ cache.set(1, '{"a":1}', 200);
23
+ cache.set(2, '{"a":2}', 201);
24
+ expect(cache.get(1)?.body).toBe('{"a":1}');
25
+ expect(cache.get(2)?.body).toBe('{"a":2}');
26
+ expect(cache.get(3)).toBeUndefined();
27
+ });
28
+
29
+ test("expired entries are not returned", () => {
30
+ // Use a 1ms TTL so entries expire immediately
31
+ const shortCache = new DedupCache(1, 100);
32
+ shortCache.set(42, '{"ok":true}', 200);
33
+
34
+ // Wait for the entry to expire
35
+ const start = Date.now();
36
+ while (Date.now() - start < 5) {
37
+ // busy-wait
38
+ }
39
+
40
+ expect(shortCache.get(42)).toBeUndefined();
41
+ });
42
+
43
+ test("evicts oldest entry when at max capacity", () => {
44
+ const tinyCache = new DedupCache(60_000, 3);
45
+ tinyCache.set(1, "a", 200);
46
+ tinyCache.set(2, "b", 200);
47
+ tinyCache.set(3, "c", 200);
48
+ expect(tinyCache.size).toBe(3);
49
+
50
+ // Adding a 4th should evict the oldest (1)
51
+ tinyCache.set(4, "d", 200);
52
+ expect(tinyCache.size).toBe(3);
53
+ expect(tinyCache.get(1)).toBeUndefined();
54
+ expect(tinyCache.get(4)?.body).toBe("d");
55
+ });
56
+
57
+ test("size reflects current entries", () => {
58
+ expect(cache.size).toBe(0);
59
+ cache.set(1, "x", 200);
60
+ expect(cache.size).toBe(1);
61
+ cache.set(2, "y", 200);
62
+ expect(cache.size).toBe(2);
63
+ });
64
+
65
+ test("reserve returns true for unseen update_id and populates cache", () => {
66
+ expect(cache.reserve(50)).toBe(true);
67
+ expect(cache.size).toBe(1);
68
+ const hit = cache.get(50);
69
+ expect(hit).toBeDefined();
70
+ expect(hit!.status).toBe(200);
71
+ });
72
+
73
+ test("reserve returns false for already-reserved update_id", () => {
74
+ cache.reserve(60);
75
+ expect(cache.reserve(60)).toBe(false);
76
+ });
77
+
78
+ test("reserve returns false for already-cached update_id", () => {
79
+ cache.set(70, '{"done":true}', 200);
80
+ expect(cache.reserve(70)).toBe(false);
81
+ });
82
+
83
+ test("set overwrites a reserved entry with the final response", () => {
84
+ cache.reserve(80);
85
+ cache.set(80, '{"final":true}', 201);
86
+ const hit = cache.get(80);
87
+ expect(hit).toEqual({ body: '{"final":true}', status: 201 });
88
+ });
89
+
90
+ test("reserve succeeds after a reserved entry expires", () => {
91
+ const shortCache = new DedupCache(1, 100);
92
+ shortCache.reserve(90);
93
+
94
+ const start = Date.now();
95
+ while (Date.now() - start < 5) {
96
+ // busy-wait for expiry
97
+ }
98
+
99
+ expect(shortCache.reserve(90)).toBe(true);
100
+ });
101
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { createTelegramWebhookHandler } from "../http/routes/telegram-webhook.js";
3
+ import type { GatewayConfig } from "../config.js";
4
+
5
+ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
6
+ return {
7
+ telegramBotToken: "tok",
8
+ telegramWebhookSecret: "wh-sec",
9
+ telegramApiBaseUrl: "https://api.telegram.org",
10
+ assistantRuntimeBaseUrl: "http://localhost:7821",
11
+ routingEntries: [],
12
+ defaultAssistantId: undefined,
13
+ unmappedPolicy: "reject",
14
+ port: 7830,
15
+ runtimeBearerToken: undefined,
16
+ runtimeProxyEnabled: false,
17
+ runtimeProxyRequireAuth: true,
18
+ runtimeProxyBearerToken: undefined,
19
+ shutdownDrainMs: 5000,
20
+ runtimeTimeoutMs: 30000,
21
+ runtimeMaxRetries: 2,
22
+ runtimeInitialBackoffMs: 500,
23
+ telegramInitialBackoffMs: 1000,
24
+ telegramMaxRetries: 3,
25
+ telegramTimeoutMs: 15000,
26
+ maxWebhookPayloadBytes: 256, // very small for testing
27
+ logFile: { dir: undefined, retentionDays: 30 },
28
+ maxAttachmentBytes: 20971520,
29
+ maxAttachmentConcurrency: 3,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe("payload size guard", () => {
35
+ test("returns 413 when content-length exceeds limit", async () => {
36
+ const handler = createTelegramWebhookHandler(makeConfig());
37
+ const body = JSON.stringify({ data: "x".repeat(300) });
38
+ const req = new Request("http://localhost:7830/webhooks/telegram", {
39
+ method: "POST",
40
+ body,
41
+ headers: {
42
+ "content-type": "application/json",
43
+ "content-length": String(body.length),
44
+ "x-telegram-bot-api-secret-token": "wh-sec",
45
+ },
46
+ });
47
+ const res = await handler(req);
48
+ expect(res.status).toBe(413);
49
+ const json = await res.json();
50
+ expect(json.error).toBe("Payload too large");
51
+ });
52
+
53
+ test("returns 413 when body exceeds limit even without content-length", async () => {
54
+ const handler = createTelegramWebhookHandler(makeConfig());
55
+ const body = JSON.stringify({ data: "x".repeat(300) });
56
+ const req = new Request("http://localhost:7830/webhooks/telegram", {
57
+ method: "POST",
58
+ body,
59
+ headers: {
60
+ "content-type": "application/json",
61
+ "x-telegram-bot-api-secret-token": "wh-sec",
62
+ },
63
+ });
64
+ const res = await handler(req);
65
+ expect(res.status).toBe(413);
66
+ });
67
+
68
+ test("accepts payload within limit", async () => {
69
+ const handler = createTelegramWebhookHandler(
70
+ makeConfig({ maxWebhookPayloadBytes: 10000 }),
71
+ );
72
+ const body = JSON.stringify({ update_id: 1, message: { text: "hi", chat: { id: 1, type: "private" }, from: { id: 1 }, message_id: 1 } });
73
+ const req = new Request("http://localhost:7830/webhooks/telegram", {
74
+ method: "POST",
75
+ body,
76
+ headers: {
77
+ "content-type": "application/json",
78
+ "content-length": String(body.length),
79
+ "x-telegram-bot-api-secret-token": "wh-sec",
80
+ },
81
+ });
82
+ const res = await handler(req);
83
+ // Will fail downstream (routing reject) but should NOT be 413
84
+ expect(res.status).not.toBe(413);
85
+ });
86
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, test, expect, afterAll } from "bun:test";
2
+
3
+ const PORT = 19831;
4
+
5
+ const env: Record<string, string> = {
6
+ TELEGRAM_BOT_TOKEN: "test-tok",
7
+ TELEGRAM_WEBHOOK_SECRET: "wh-sec",
8
+ ASSISTANT_RUNTIME_BASE_URL: "http://localhost:7821",
9
+ GATEWAY_PORT: String(PORT),
10
+ };
11
+
12
+ const saved: Record<string, string | undefined> = {};
13
+ for (const [k, v] of Object.entries(env)) {
14
+ saved[k] = process.env[k];
15
+ process.env[k] = v;
16
+ }
17
+ saved["GATEWAY_RUNTIME_PROXY_ENABLED"] = process.env.GATEWAY_RUNTIME_PROXY_ENABLED;
18
+ delete process.env.GATEWAY_RUNTIME_PROXY_ENABLED;
19
+
20
+ const { loadConfig } = await import("../config.js");
21
+ const { createTelegramWebhookHandler } = await import(
22
+ "../http/routes/telegram-webhook.js"
23
+ );
24
+
25
+ const config = loadConfig();
26
+
27
+ const handleTelegramWebhook = createTelegramWebhookHandler(
28
+ config,
29
+ async () => {},
30
+ );
31
+
32
+ let draining = false;
33
+
34
+ const server = Bun.serve({
35
+ port: PORT,
36
+ async fetch(req) {
37
+ const url = new URL(req.url);
38
+
39
+ if (url.pathname === "/healthz") {
40
+ return Response.json({ status: "ok" });
41
+ }
42
+
43
+ if (url.pathname === "/readyz") {
44
+ if (draining) {
45
+ return Response.json({ status: "draining" }, { status: 503 });
46
+ }
47
+ return Response.json({ status: "ok" });
48
+ }
49
+
50
+ if (url.pathname === "/webhooks/telegram") {
51
+ return handleTelegramWebhook(req);
52
+ }
53
+
54
+ return Response.json({ error: "Not found" }, { status: 404 });
55
+ },
56
+ });
57
+
58
+ afterAll(() => {
59
+ server.stop(true);
60
+ for (const [k, v] of Object.entries(saved)) {
61
+ if (v === undefined) delete process.env[k];
62
+ else process.env[k] = v;
63
+ }
64
+ });
65
+
66
+ describe("/healthz", () => {
67
+ test("returns 200 with ok status", async () => {
68
+ const res = await fetch(`http://localhost:${PORT}/healthz`);
69
+ expect(res.status).toBe(200);
70
+ const body = await res.json();
71
+ expect(body.status).toBe("ok");
72
+ });
73
+ });
74
+
75
+ describe("/readyz", () => {
76
+ test("returns 200 when not draining", async () => {
77
+ const res = await fetch(`http://localhost:${PORT}/readyz`);
78
+ expect(res.status).toBe(200);
79
+ const body = await res.json();
80
+ expect(body.status).toBe("ok");
81
+ });
82
+
83
+ test("returns 503 when draining", async () => {
84
+ draining = true;
85
+ try {
86
+ const res = await fetch(`http://localhost:${PORT}/readyz`);
87
+ expect(res.status).toBe(503);
88
+ const body = await res.json();
89
+ expect(body.status).toBe("draining");
90
+ } finally {
91
+ draining = false;
92
+ }
93
+ });
94
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ buildTelegramTransportMetadata,
4
+ TELEGRAM_CHANNEL_TRANSPORT_HINTS,
5
+ TELEGRAM_CHANNEL_TRANSPORT_UX_BRIEF,
6
+ } from "../http/routes/telegram-webhook.js";
7
+ import { splitText } from "../telegram/send.js";
8
+
9
+ describe("splitText", () => {
10
+ test("returns single chunk for short text", () => {
11
+ const chunks = splitText("Hello!");
12
+ expect(chunks).toEqual(["Hello!"]);
13
+ });
14
+
15
+ test("returns single chunk for exactly max length", () => {
16
+ const text = "x".repeat(4000);
17
+ const chunks = splitText(text);
18
+ expect(chunks).toHaveLength(1);
19
+ expect(chunks[0]).toBe(text);
20
+ });
21
+
22
+ test("splits text exceeding max length", () => {
23
+ const text = "x".repeat(8500);
24
+ const chunks = splitText(text);
25
+ expect(chunks).toHaveLength(3);
26
+ expect(chunks[0]).toHaveLength(4000);
27
+ expect(chunks[1]).toHaveLength(4000);
28
+ expect(chunks[2]).toHaveLength(500);
29
+ expect(chunks.join("")).toBe(text);
30
+ });
31
+
32
+ test("handles empty string", () => {
33
+ const chunks = splitText("");
34
+ expect(chunks).toEqual([""]);
35
+ });
36
+ });
37
+
38
+ describe("telegram onboarding transport metadata", () => {
39
+ test("publishes deterministic channel-safe hints", () => {
40
+ const metadata = buildTelegramTransportMetadata();
41
+ expect(metadata.hints).toEqual([...TELEGRAM_CHANNEL_TRANSPORT_HINTS]);
42
+ expect(metadata.hints).toContain("defer-dashboard-only-tasks");
43
+ });
44
+
45
+ test("publishes explicit dashboard deferral UX brief", () => {
46
+ const metadata = buildTelegramTransportMetadata();
47
+ expect(metadata.uxBrief).toBe(TELEGRAM_CHANNEL_TRANSPORT_UX_BRIEF);
48
+ expect(metadata.uxBrief.toLowerCase()).toContain("defer");
49
+ expect(metadata.uxBrief.toLowerCase()).toContain("dashboard");
50
+ });
51
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { resolveAssistant, isRejection } from "../routing/resolve-assistant.js";
3
+ import type { GatewayConfig } from "../config.js";
4
+
5
+ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
6
+ return {
7
+ telegramBotToken: "tok",
8
+ telegramWebhookSecret: "wh-ver",
9
+ telegramApiBaseUrl: "https://api.telegram.org",
10
+ assistantRuntimeBaseUrl: "http://localhost:7821",
11
+ routingEntries: [],
12
+ defaultAssistantId: undefined,
13
+ unmappedPolicy: "reject",
14
+ port: 7830,
15
+ runtimeBearerToken: undefined,
16
+ runtimeProxyEnabled: false,
17
+ runtimeProxyRequireAuth: true,
18
+ runtimeProxyBearerToken: undefined,
19
+ shutdownDrainMs: 5000,
20
+ runtimeTimeoutMs: 30000,
21
+ runtimeMaxRetries: 2,
22
+ runtimeInitialBackoffMs: 500,
23
+ telegramInitialBackoffMs: 1000,
24
+ telegramMaxRetries: 3,
25
+ telegramTimeoutMs: 15000,
26
+ maxWebhookPayloadBytes: 1048576,
27
+ logFile: { dir: undefined, retentionDays: 30 },
28
+ maxAttachmentBytes: 20971520,
29
+ maxAttachmentConcurrency: 3,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe("resolveAssistant", () => {
35
+ test("resolves by chat_id match", () => {
36
+ const config = makeConfig({
37
+ routingEntries: [
38
+ { type: "chat_id", key: "99001", assistantId: "assistant-a" },
39
+ { type: "user_id", key: "55001", assistantId: "assistant-b" },
40
+ ],
41
+ });
42
+
43
+ const result = resolveAssistant(config, "99001", "55001");
44
+ expect(isRejection(result)).toBe(false);
45
+ if (!isRejection(result)) {
46
+ expect(result.assistantId).toBe("assistant-a");
47
+ expect(result.routeSource).toBe("chat_id");
48
+ }
49
+ });
50
+
51
+ test("falls back to user_id when chat_id does not match", () => {
52
+ const config = makeConfig({
53
+ routingEntries: [
54
+ { type: "chat_id", key: "99999", assistantId: "assistant-a" },
55
+ { type: "user_id", key: "55001", assistantId: "assistant-b" },
56
+ ],
57
+ });
58
+
59
+ const result = resolveAssistant(config, "99001", "55001");
60
+ expect(isRejection(result)).toBe(false);
61
+ if (!isRejection(result)) {
62
+ expect(result.assistantId).toBe("assistant-b");
63
+ expect(result.routeSource).toBe("user_id");
64
+ }
65
+ });
66
+
67
+ test("falls back to default policy when no explicit match", () => {
68
+ const config = makeConfig({
69
+ unmappedPolicy: "default",
70
+ defaultAssistantId: "assistant-default",
71
+ });
72
+
73
+ const result = resolveAssistant(config, "99001", "55001");
74
+ expect(isRejection(result)).toBe(false);
75
+ if (!isRejection(result)) {
76
+ expect(result.assistantId).toBe("assistant-default");
77
+ expect(result.routeSource).toBe("default");
78
+ }
79
+ });
80
+
81
+ test("rejects when policy is reject and no match", () => {
82
+ const config = makeConfig({
83
+ unmappedPolicy: "reject",
84
+ });
85
+
86
+ const result = resolveAssistant(config, "99001", "55001");
87
+ expect(isRejection(result)).toBe(true);
88
+ if (isRejection(result)) {
89
+ expect(result.reason).toContain("No route configured");
90
+ }
91
+ });
92
+
93
+ test("chat_id takes priority over user_id for same assistant", () => {
94
+ const config = makeConfig({
95
+ routingEntries: [
96
+ { type: "user_id", key: "55001", assistantId: "assistant-user" },
97
+ { type: "chat_id", key: "99001", assistantId: "assistant-chat" },
98
+ ],
99
+ });
100
+
101
+ const result = resolveAssistant(config, "99001", "55001");
102
+ expect(isRejection(result)).toBe(false);
103
+ if (!isRejection(result)) {
104
+ expect(result.assistantId).toBe("assistant-chat");
105
+ expect(result.routeSource).toBe("chat_id");
106
+ }
107
+ });
108
+
109
+ test("rejects with default policy but no default assistant configured", () => {
110
+ const config = makeConfig({
111
+ unmappedPolicy: "default",
112
+ defaultAssistantId: undefined,
113
+ });
114
+
115
+ const result = resolveAssistant(config, "99001", "55001");
116
+ expect(isRejection(result)).toBe(true);
117
+ });
118
+ });