@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,185 @@
1
+ import { describe, test, expect, mock, afterEach } from "bun:test";
2
+ import { sendTelegramAttachments } from "../telegram/send.js";
3
+ import type { RuntimeAttachmentMeta } from "../runtime/client.js";
4
+ import type { GatewayConfig } from "../config.js";
5
+
6
+ const makeConfig = (overrides: Partial<GatewayConfig> = {}): GatewayConfig => ({
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
+ const telegramOk = { ok: true, result: { message_id: 1 } };
34
+
35
+ function mockFetch(fn: (url: string, init?: RequestInit) => Promise<Response>) {
36
+ const m = mock(fn);
37
+ Object.assign(m, { preconnect: () => {} });
38
+ globalThis.fetch = m as unknown as typeof fetch;
39
+ return m;
40
+ }
41
+
42
+ describe("sendTelegramAttachments", () => {
43
+ const originalFetch = globalThis.fetch;
44
+
45
+ afterEach(() => {
46
+ globalThis.fetch = originalFetch;
47
+ });
48
+
49
+ test("sends image attachment via sendPhoto", async () => {
50
+ const calls: string[] = [];
51
+
52
+ mockFetch(async (url: string) => {
53
+ calls.push(url);
54
+ // Runtime download endpoint
55
+ if (url.includes("/attachments/att-1")) {
56
+ return new Response(
57
+ JSON.stringify({
58
+ id: "att-1",
59
+ filename: "photo.png",
60
+ mimeType: "image/png",
61
+ sizeBytes: 100,
62
+ kind: "generated_image",
63
+ data: "iVBORw0KGgo=",
64
+ }),
65
+ );
66
+ }
67
+ // Telegram sendPhoto
68
+ return new Response(JSON.stringify(telegramOk));
69
+ });
70
+
71
+ const config = makeConfig();
72
+ const meta: RuntimeAttachmentMeta = {
73
+ id: "att-1",
74
+ filename: "photo.png",
75
+ mimeType: "image/png",
76
+ sizeBytes: 100,
77
+ kind: "generated_image",
78
+ };
79
+
80
+ await sendTelegramAttachments(config, "chat-1", "assistant-a", [meta]);
81
+
82
+ // Should have called: 1) runtime download, 2) telegram sendPhoto
83
+ expect(calls).toHaveLength(2);
84
+ expect(calls[0]).toContain("/attachments/att-1");
85
+ expect(calls[1]).toContain("sendPhoto");
86
+ });
87
+
88
+ test("sends non-image attachment via sendDocument", async () => {
89
+ const calls: string[] = [];
90
+
91
+ mockFetch(async (url: string) => {
92
+ calls.push(url);
93
+ if (url.includes("/attachments/att-2")) {
94
+ return new Response(
95
+ JSON.stringify({
96
+ id: "att-2",
97
+ filename: "report.pdf",
98
+ mimeType: "application/pdf",
99
+ sizeBytes: 200,
100
+ kind: "filesystem",
101
+ data: "JVBER",
102
+ }),
103
+ );
104
+ }
105
+ return new Response(JSON.stringify(telegramOk));
106
+ });
107
+
108
+ const config = makeConfig();
109
+ const meta: RuntimeAttachmentMeta = {
110
+ id: "att-2",
111
+ filename: "report.pdf",
112
+ mimeType: "application/pdf",
113
+ sizeBytes: 200,
114
+ kind: "filesystem",
115
+ };
116
+
117
+ await sendTelegramAttachments(config, "chat-1", "assistant-a", [meta]);
118
+
119
+ expect(calls).toHaveLength(2);
120
+ expect(calls[0]).toContain("/attachments/att-2");
121
+ expect(calls[1]).toContain("sendDocument");
122
+ });
123
+
124
+ test("skips oversized attachments and sends failure notice", async () => {
125
+ const calls: string[] = [];
126
+
127
+ mockFetch(async (url: string) => {
128
+ calls.push(url);
129
+ return new Response(JSON.stringify(telegramOk));
130
+ });
131
+
132
+ const config = makeConfig({ maxAttachmentBytes: 50 });
133
+ const meta: RuntimeAttachmentMeta = {
134
+ id: "att-3",
135
+ filename: "huge.zip",
136
+ mimeType: "application/zip",
137
+ sizeBytes: 100, // exceeds 50 byte limit
138
+ kind: "filesystem",
139
+ };
140
+
141
+ await sendTelegramAttachments(config, "chat-1", "assistant-a", [meta]);
142
+
143
+ // Should have sent only the failure notice via sendMessage
144
+ expect(calls).toHaveLength(1);
145
+ expect(calls[0]).toContain("sendMessage");
146
+ });
147
+
148
+ test("continues sending remaining attachments on individual failure", async () => {
149
+ const calls: string[] = [];
150
+
151
+ mockFetch(async (url: string) => {
152
+ calls.push(url);
153
+ // First attachment download fails
154
+ if (url.includes("/attachments/att-fail")) {
155
+ return new Response('{"error":"not found"}', { status: 404 });
156
+ }
157
+ // Second attachment succeeds
158
+ if (url.includes("/attachments/att-ok")) {
159
+ return new Response(
160
+ JSON.stringify({
161
+ id: "att-ok",
162
+ filename: "good.png",
163
+ mimeType: "image/png",
164
+ sizeBytes: 50,
165
+ kind: "generated_image",
166
+ data: "iVBORw0KGgo=",
167
+ }),
168
+ );
169
+ }
170
+ return new Response(JSON.stringify(telegramOk));
171
+ });
172
+
173
+ const config = makeConfig();
174
+ const attachments: RuntimeAttachmentMeta[] = [
175
+ { id: "att-fail", filename: "bad.png", mimeType: "image/png", sizeBytes: 50, kind: "generated_image" },
176
+ { id: "att-ok", filename: "good.png", mimeType: "image/png", sizeBytes: 50, kind: "generated_image" },
177
+ ];
178
+
179
+ await sendTelegramAttachments(config, "chat-1", "assistant-a", attachments);
180
+
181
+ // Should have: download att-fail (fail), download att-ok, sendPhoto for att-ok, sendMessage for notice
182
+ expect(calls.filter((u) => u.includes("sendPhoto"))).toHaveLength(1);
183
+ expect(calls.filter((u) => u.includes("sendMessage"))).toHaveLength(1);
184
+ });
185
+ });
@@ -0,0 +1,8 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { buildSchema } from "../schema.js";
5
+
6
+ const filePath = join(tmpdir(), `vellum-gateway-schema-${Date.now()}.json`);
7
+ writeFileSync(filePath, JSON.stringify(buildSchema(), null, 2) + "\n");
8
+ console.log(filePath);
package/src/config.ts ADDED
@@ -0,0 +1,254 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { getLogger, type LogFileConfig } from "./logger.js";
5
+
6
+ const log = getLogger("config");
7
+
8
+ export type RoutingEntry = {
9
+ type: "chat_id" | "user_id";
10
+ key: string;
11
+ assistantId: string;
12
+ };
13
+
14
+ export type GatewayConfig = {
15
+ assistantRuntimeBaseUrl: string;
16
+ defaultAssistantId: string | undefined;
17
+ logFile: LogFileConfig;
18
+ maxAttachmentBytes: number;
19
+ maxAttachmentConcurrency: number;
20
+ maxWebhookPayloadBytes: number;
21
+ port: number;
22
+ routingEntries: RoutingEntry[];
23
+ /** Bearer token sent to the assistant runtime on gateway-to-runtime calls. */
24
+ runtimeBearerToken: string | undefined;
25
+ runtimeInitialBackoffMs: number;
26
+ runtimeMaxRetries: number;
27
+ runtimeProxyBearerToken: string | undefined;
28
+ runtimeProxyEnabled: boolean;
29
+ runtimeProxyRequireAuth: boolean;
30
+ runtimeTimeoutMs: number;
31
+ shutdownDrainMs: number;
32
+ telegramApiBaseUrl: string;
33
+ telegramBotToken: string | undefined;
34
+ telegramInitialBackoffMs: number;
35
+ telegramMaxRetries: number;
36
+ telegramTimeoutMs: number;
37
+ telegramWebhookSecret: string | undefined;
38
+ unmappedPolicy: "reject" | "default";
39
+ };
40
+
41
+ function parseRoutingJson(raw: string): RoutingEntry[] {
42
+ let parsed: Record<string, string>;
43
+ try {
44
+ parsed = JSON.parse(raw);
45
+ } catch {
46
+ throw new Error("GATEWAY_ASSISTANT_ROUTING_JSON is not valid JSON");
47
+ }
48
+
49
+ const entries: RoutingEntry[] = [];
50
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
51
+ throw new Error("GATEWAY_ASSISTANT_ROUTING_JSON must be a JSON object");
52
+ }
53
+ for (const [key, assistantId] of Object.entries(parsed)) {
54
+ if (typeof assistantId !== "string" || !assistantId) {
55
+ throw new Error(`Invalid assistant ID for routing key "${key}"`);
56
+ }
57
+ if (key.startsWith("chat:")) {
58
+ entries.push({ type: "chat_id", key: key.slice(5), assistantId });
59
+ } else if (key.startsWith("user:")) {
60
+ entries.push({ type: "user_id", key: key.slice(5), assistantId });
61
+ } else {
62
+ throw new Error(
63
+ `Invalid routing key "${key}": must start with "chat:" or "user:"`,
64
+ );
65
+ }
66
+ }
67
+ return entries;
68
+ }
69
+
70
+ function readHttpTokenFile(): string | null {
71
+ const tokenPath = process.env.VELLUM_HTTP_TOKEN_PATH
72
+ ?? join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum", "http-token");
73
+ try {
74
+ return readFileSync(tokenPath, "utf-8").trim() || null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export function loadConfig(): GatewayConfig {
81
+ const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN || undefined;
82
+ const telegramWebhookSecret = process.env.TELEGRAM_WEBHOOK_SECRET || undefined;
83
+
84
+ const telegramApiBaseUrl =
85
+ process.env.TELEGRAM_API_BASE_URL || "https://api.telegram.org";
86
+
87
+ const runtimePort = process.env.RUNTIME_HTTP_PORT || "7821";
88
+ const assistantRuntimeBaseUrl =
89
+ process.env.ASSISTANT_RUNTIME_BASE_URL || `http://localhost:${runtimePort}`;
90
+
91
+ const routingJson = process.env.GATEWAY_ASSISTANT_ROUTING_JSON || "{}";
92
+ const routingEntries = parseRoutingJson(routingJson);
93
+
94
+ const defaultAssistantId =
95
+ process.env.GATEWAY_DEFAULT_ASSISTANT_ID || undefined;
96
+
97
+ const unmappedPolicyRaw = process.env.GATEWAY_UNMAPPED_POLICY || "reject";
98
+ if (unmappedPolicyRaw !== "reject" && unmappedPolicyRaw !== "default") {
99
+ throw new Error(
100
+ `GATEWAY_UNMAPPED_POLICY must be "reject" or "default", got "${unmappedPolicyRaw}"`,
101
+ );
102
+ }
103
+ const unmappedPolicy = unmappedPolicyRaw;
104
+
105
+ if (unmappedPolicy === "default" && !defaultAssistantId) {
106
+ throw new Error(
107
+ 'GATEWAY_DEFAULT_ASSISTANT_ID is required when GATEWAY_UNMAPPED_POLICY is "default"',
108
+ );
109
+ }
110
+
111
+ const portRaw = process.env.GATEWAY_PORT || "7830";
112
+ const port = Number(portRaw);
113
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
114
+ throw new Error("GATEWAY_PORT must be a valid port number");
115
+ }
116
+
117
+ const proxyEnabledRaw = process.env.GATEWAY_RUNTIME_PROXY_ENABLED;
118
+ if (proxyEnabledRaw !== undefined && proxyEnabledRaw !== "true" && proxyEnabledRaw !== "false") {
119
+ throw new Error(
120
+ `GATEWAY_RUNTIME_PROXY_ENABLED must be "true" or "false", got "${proxyEnabledRaw}"`,
121
+ );
122
+ }
123
+ const runtimeProxyEnabled = proxyEnabledRaw === "true";
124
+
125
+ const proxyRequireAuthRaw = process.env.GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH;
126
+ if (proxyRequireAuthRaw !== undefined && proxyRequireAuthRaw !== "true" && proxyRequireAuthRaw !== "false") {
127
+ throw new Error(
128
+ `GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH must be "true" or "false", got "${proxyRequireAuthRaw}"`,
129
+ );
130
+ }
131
+ const runtimeProxyRequireAuth = proxyRequireAuthRaw !== "false";
132
+
133
+ const runtimeBearerToken =
134
+ process.env.RUNTIME_BEARER_TOKEN || undefined;
135
+
136
+ const runtimeProxyBearerToken =
137
+ process.env.RUNTIME_PROXY_BEARER_TOKEN || readHttpTokenFile() || undefined;
138
+
139
+ const MAX_TIMEOUT_MS = 2_147_483_647; // 2^31 - 1, max safe setTimeout delay
140
+
141
+ const shutdownDrainMsRaw = process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "5000";
142
+ const shutdownDrainMs = Number(shutdownDrainMsRaw);
143
+ if (!Number.isFinite(shutdownDrainMs) || shutdownDrainMs < 0) {
144
+ throw new Error("GATEWAY_SHUTDOWN_DRAIN_MS must be a non-negative number");
145
+ }
146
+ if (shutdownDrainMs > MAX_TIMEOUT_MS) {
147
+ throw new Error(
148
+ `GATEWAY_SHUTDOWN_DRAIN_MS must not exceed ${MAX_TIMEOUT_MS} (setTimeout max safe delay)`,
149
+ );
150
+ }
151
+
152
+ const runtimeTimeoutMs = Number(process.env.GATEWAY_RUNTIME_TIMEOUT_MS || "30000");
153
+ if (!Number.isFinite(runtimeTimeoutMs) || runtimeTimeoutMs <= 0) {
154
+ throw new Error("GATEWAY_RUNTIME_TIMEOUT_MS must be a positive number");
155
+ }
156
+
157
+ const runtimeMaxRetries = Number(process.env.GATEWAY_RUNTIME_MAX_RETRIES || "2");
158
+ if (!Number.isInteger(runtimeMaxRetries) || runtimeMaxRetries < 0) {
159
+ throw new Error("GATEWAY_RUNTIME_MAX_RETRIES must be a non-negative integer");
160
+ }
161
+
162
+ const runtimeInitialBackoffMs = Number(process.env.GATEWAY_RUNTIME_INITIAL_BACKOFF_MS || "500");
163
+ if (!Number.isFinite(runtimeInitialBackoffMs) || runtimeInitialBackoffMs <= 0) {
164
+ throw new Error("GATEWAY_RUNTIME_INITIAL_BACKOFF_MS must be a positive number");
165
+ }
166
+
167
+ const telegramTimeoutMs = Number(process.env.GATEWAY_TELEGRAM_TIMEOUT_MS || "15000");
168
+ if (!Number.isFinite(telegramTimeoutMs) || telegramTimeoutMs <= 0) {
169
+ throw new Error("GATEWAY_TELEGRAM_TIMEOUT_MS must be a positive number");
170
+ }
171
+
172
+ const telegramMaxRetries = Number(process.env.GATEWAY_TELEGRAM_MAX_RETRIES || "3");
173
+ if (!Number.isInteger(telegramMaxRetries) || telegramMaxRetries < 0) {
174
+ throw new Error("GATEWAY_TELEGRAM_MAX_RETRIES must be a non-negative integer");
175
+ }
176
+
177
+ const telegramInitialBackoffMs = Number(process.env.GATEWAY_TELEGRAM_INITIAL_BACKOFF_MS || "1000");
178
+ if (!Number.isFinite(telegramInitialBackoffMs) || telegramInitialBackoffMs <= 0) {
179
+ throw new Error("GATEWAY_TELEGRAM_INITIAL_BACKOFF_MS must be a positive number");
180
+ }
181
+
182
+ const maxWebhookPayloadBytes = Number(process.env.GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES || String(1024 * 1024));
183
+ if (!Number.isFinite(maxWebhookPayloadBytes) || maxWebhookPayloadBytes <= 0) {
184
+ throw new Error("GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES must be a positive number");
185
+ }
186
+
187
+ const maxAttachmentBytes = Number(process.env.GATEWAY_MAX_ATTACHMENT_BYTES || String(20 * 1024 * 1024));
188
+ if (!Number.isFinite(maxAttachmentBytes) || maxAttachmentBytes <= 0) {
189
+ throw new Error("GATEWAY_MAX_ATTACHMENT_BYTES must be a positive number");
190
+ }
191
+
192
+ const maxAttachmentConcurrency = Number(process.env.GATEWAY_MAX_ATTACHMENT_CONCURRENCY || "3");
193
+ if (!Number.isInteger(maxAttachmentConcurrency) || maxAttachmentConcurrency < 1) {
194
+ throw new Error("GATEWAY_MAX_ATTACHMENT_CONCURRENCY must be a positive integer");
195
+ }
196
+
197
+ if (runtimeProxyEnabled && runtimeProxyRequireAuth && !runtimeProxyBearerToken) {
198
+ throw new Error(
199
+ "RUNTIME_PROXY_BEARER_TOKEN is required when proxy is enabled with auth required",
200
+ );
201
+ }
202
+
203
+ const logFileDir = process.env.GATEWAY_LOG_DIR || undefined;
204
+
205
+ const logFileRetentionDays = Number(process.env.GATEWAY_LOG_RETENTION_DAYS || "30");
206
+ if (!Number.isInteger(logFileRetentionDays) || logFileRetentionDays < 1) {
207
+ throw new Error("GATEWAY_LOG_RETENTION_DAYS must be a positive integer");
208
+ }
209
+
210
+ const logFile: LogFileConfig = {
211
+ dir: logFileDir,
212
+ retentionDays: logFileRetentionDays,
213
+ };
214
+
215
+ log.info(
216
+ {
217
+ telegramApiBaseUrl,
218
+ assistantRuntimeBaseUrl,
219
+ routingEntryCount: routingEntries.length,
220
+ unmappedPolicy,
221
+ hasDefaultAssistant: !!defaultAssistantId,
222
+ port,
223
+ runtimeProxyEnabled,
224
+ runtimeProxyRequireAuth,
225
+ },
226
+ "Configuration loaded",
227
+ );
228
+
229
+ return {
230
+ assistantRuntimeBaseUrl,
231
+ defaultAssistantId,
232
+ logFile,
233
+ maxAttachmentBytes,
234
+ maxAttachmentConcurrency,
235
+ maxWebhookPayloadBytes,
236
+ port,
237
+ routingEntries,
238
+ runtimeBearerToken,
239
+ runtimeInitialBackoffMs,
240
+ runtimeMaxRetries,
241
+ runtimeProxyBearerToken,
242
+ runtimeProxyEnabled,
243
+ runtimeProxyRequireAuth,
244
+ runtimeTimeoutMs,
245
+ shutdownDrainMs,
246
+ telegramApiBaseUrl,
247
+ telegramBotToken,
248
+ telegramInitialBackoffMs,
249
+ telegramMaxRetries,
250
+ telegramTimeoutMs,
251
+ telegramWebhookSecret,
252
+ unmappedPolicy,
253
+ };
254
+ }
@@ -0,0 +1,104 @@
1
+ import { getLogger } from "./logger.js";
2
+
3
+ const log = getLogger("dedup-cache");
4
+
5
+ interface CacheEntry {
6
+ body: string;
7
+ status: number;
8
+ expiresAt: number;
9
+ /** When true, the first handler is still processing this update_id. */
10
+ processing?: boolean;
11
+ }
12
+
13
+ /**
14
+ * In-memory TTL cache for Telegram update_id deduplication.
15
+ * Prevents redundant normalization, routing, attachment downloads,
16
+ * and runtime forwarding when Telegram retries a webhook on timeout.
17
+ */
18
+ export class DedupCache {
19
+ private cache = new Map<number, CacheEntry>();
20
+ private readonly ttlMs: number;
21
+ private readonly maxSize: number;
22
+
23
+ constructor(ttlMs = 5 * 60_000, maxSize = 10_000) {
24
+ this.ttlMs = ttlMs;
25
+ this.maxSize = maxSize;
26
+ }
27
+
28
+ /** Returns the cached response body+status if the update_id was already seen. */
29
+ get(updateId: number): { body: string; status: number } | undefined {
30
+ const entry = this.cache.get(updateId);
31
+ if (!entry) return undefined;
32
+ if (Date.now() > entry.expiresAt) {
33
+ this.cache.delete(updateId);
34
+ return undefined;
35
+ }
36
+ return { body: entry.body, status: entry.status };
37
+ }
38
+
39
+ /**
40
+ * Checks whether this update_id is already reserved or cached.
41
+ * If not, immediately reserves it with a "processing" sentinel so that
42
+ * concurrent retries are blocked before the handler finishes.
43
+ * Returns true if a new reservation was created (caller should proceed),
44
+ * false if the update_id was already present (caller should short-circuit).
45
+ */
46
+ reserve(updateId: number): boolean {
47
+ const existing = this.cache.get(updateId);
48
+ if (existing && Date.now() <= existing.expiresAt) {
49
+ return false;
50
+ }
51
+ // Clean up expired entry if present
52
+ if (existing) this.cache.delete(updateId);
53
+ this.set(updateId, '{"ok":true}', 200);
54
+ this.cache.get(updateId)!.processing = true;
55
+ return true;
56
+ }
57
+
58
+ /** Remove a reserved entry so Telegram can retry. */
59
+ unreserve(updateId: number): void {
60
+ const entry = this.cache.get(updateId);
61
+ if (entry?.processing) {
62
+ this.cache.delete(updateId);
63
+ }
64
+ }
65
+
66
+ /** Store a response for the given update_id. */
67
+ set(updateId: number, body: string, status: number): void {
68
+ // Evict expired entries if we're at capacity
69
+ if (this.cache.size >= this.maxSize) {
70
+ this.evictExpired();
71
+ }
72
+ // If still at capacity after eviction, drop the oldest entry
73
+ if (this.cache.size >= this.maxSize) {
74
+ const oldest = this.cache.keys().next().value;
75
+ if (oldest !== undefined) {
76
+ this.cache.delete(oldest);
77
+ }
78
+ }
79
+
80
+ this.cache.set(updateId, {
81
+ body,
82
+ status,
83
+ expiresAt: Date.now() + this.ttlMs,
84
+ });
85
+ }
86
+
87
+ get size(): number {
88
+ return this.cache.size;
89
+ }
90
+
91
+ private evictExpired(): void {
92
+ const now = Date.now();
93
+ let evicted = 0;
94
+ for (const [key, entry] of this.cache) {
95
+ if (now > entry.expiresAt) {
96
+ this.cache.delete(key);
97
+ evicted++;
98
+ }
99
+ }
100
+ if (evicted > 0) {
101
+ log.debug({ evicted }, "Evicted expired dedup cache entries");
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,104 @@
1
+ import type { GatewayConfig } from "../config.js";
2
+ import { getLogger } from "../logger.js";
3
+ import { resolveAssistant, isRejection } from "../routing/resolve-assistant.js";
4
+ import { forwardToRuntime } from "../runtime/client.js";
5
+ import type { RuntimeInboundResponse } from "../runtime/client.js";
6
+ import type { GatewayInboundEventV1 } from "../types.js";
7
+
8
+ const log = getLogger("handle-inbound");
9
+
10
+ export type InboundResult = {
11
+ forwarded: boolean;
12
+ rejected: boolean;
13
+ runtimeResponse?: RuntimeInboundResponse;
14
+ rejectionReason?: string;
15
+ };
16
+
17
+ export type TransportMetadataOverrides = {
18
+ hints?: string[];
19
+ uxBrief?: string;
20
+ };
21
+
22
+ export type HandleInboundOptions = {
23
+ attachmentIds?: string[];
24
+ transportMetadata?: TransportMetadataOverrides;
25
+ };
26
+
27
+ function normalizeTransportHints(hints: string[] | undefined): string[] {
28
+ if (!hints || hints.length === 0) return [];
29
+ const seen = new Set<string>();
30
+ const normalized: string[] = [];
31
+ for (const raw of hints) {
32
+ const trimmed = raw.trim();
33
+ if (!trimmed || seen.has(trimmed)) continue;
34
+ seen.add(trimmed);
35
+ normalized.push(trimmed);
36
+ }
37
+ return normalized;
38
+ }
39
+
40
+ export async function handleInbound(
41
+ config: GatewayConfig,
42
+ event: Omit<GatewayInboundEventV1, "routing">,
43
+ options?: HandleInboundOptions,
44
+ ): Promise<InboundResult> {
45
+ const routing = resolveAssistant(
46
+ config,
47
+ event.message.externalChatId,
48
+ event.sender.externalUserId,
49
+ );
50
+
51
+ if (isRejection(routing)) {
52
+ log.info(
53
+ { externalChatId: event.message.externalChatId, reason: routing.reason },
54
+ "Inbound event rejected by routing",
55
+ );
56
+ return { forwarded: false, rejected: true, rejectionReason: routing.reason };
57
+ }
58
+
59
+ const displayName = event.sender.displayName || event.sender.username;
60
+ const transportHints = normalizeTransportHints(options?.transportMetadata?.hints);
61
+ const transportUxBrief = options?.transportMetadata?.uxBrief?.trim();
62
+
63
+ try {
64
+ const response = await forwardToRuntime(config, routing.assistantId, {
65
+ sourceChannel: event.sourceChannel,
66
+ externalChatId: event.message.externalChatId,
67
+ externalMessageId: event.message.externalMessageId,
68
+ content: event.message.content,
69
+ ...(event.message.isEdit ? { isEdit: true } : {}),
70
+ senderName: displayName,
71
+ senderExternalUserId: event.sender.externalUserId,
72
+ senderUsername: event.sender.username,
73
+ sourceMetadata: {
74
+ updateId: event.source.updateId,
75
+ messageId: event.source.messageId,
76
+ chatType: event.source.chatType,
77
+ languageCode: event.sender.languageCode,
78
+ isBot: event.sender.isBot,
79
+ ...(transportHints.length > 0 ? { hints: transportHints } : {}),
80
+ ...(transportUxBrief ? { uxBrief: transportUxBrief } : {}),
81
+ },
82
+ ...(options?.attachmentIds?.length ? { attachmentIds: options.attachmentIds } : {}),
83
+ });
84
+
85
+ log.info(
86
+ {
87
+ assistantId: routing.assistantId,
88
+ routeSource: routing.routeSource,
89
+ eventId: response.eventId,
90
+ duplicate: response.duplicate,
91
+ hasReply: !!response.assistantMessage,
92
+ },
93
+ "Inbound event forwarded to runtime",
94
+ );
95
+
96
+ return { forwarded: true, rejected: false, runtimeResponse: response };
97
+ } catch (err) {
98
+ log.error(
99
+ { err, assistantId: routing.assistantId },
100
+ "Failed to forward inbound event to runtime",
101
+ );
102
+ return { forwarded: false, rejected: false };
103
+ }
104
+ }
@@ -0,0 +1,34 @@
1
+ import { timingSafeEqual } from "crypto";
2
+
3
+ export type BearerAuthResult =
4
+ | { authorized: true }
5
+ | { authorized: false; reason: string };
6
+
7
+ export function validateBearerToken(
8
+ authorizationHeader: string | null,
9
+ expectedToken: string,
10
+ ): BearerAuthResult {
11
+ if (!authorizationHeader) {
12
+ return { authorized: false, reason: "Missing Authorization header" };
13
+ }
14
+
15
+ if (!authorizationHeader.slice(0, 7).toLowerCase().startsWith("bearer ")) {
16
+ return { authorized: false, reason: "Invalid authorization scheme" };
17
+ }
18
+
19
+ const token = authorizationHeader.slice(7);
20
+ if (!token) {
21
+ return { authorized: false, reason: "Empty bearer token" };
22
+ }
23
+
24
+ const a = Buffer.from(token);
25
+ const b = Buffer.from(expectedToken);
26
+ if (a.length !== b.length) {
27
+ return { authorized: false, reason: "Invalid bearer token" };
28
+ }
29
+ if (!timingSafeEqual(a, b)) {
30
+ return { authorized: false, reason: "Invalid bearer token" };
31
+ }
32
+
33
+ return { authorized: true };
34
+ }