@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.
- package/.dockerignore +7 -0
- package/.env.example +59 -0
- package/Dockerfile +44 -0
- package/README.md +186 -0
- package/bun.lock +391 -0
- package/eslint.config.mjs +23 -0
- package/knip.json +8 -0
- package/package.json +27 -0
- package/src/__tests__/bearer-auth.test.ts +40 -0
- package/src/__tests__/config.test.ts +236 -0
- package/src/__tests__/dedup-cache.test.ts +101 -0
- package/src/__tests__/load-guards.test.ts +86 -0
- package/src/__tests__/probes.test.ts +94 -0
- package/src/__tests__/reply-path.test.ts +51 -0
- package/src/__tests__/resolve-assistant.test.ts +118 -0
- package/src/__tests__/runtime-client.test.ts +228 -0
- package/src/__tests__/runtime-proxy-auth.test.ts +127 -0
- package/src/__tests__/runtime-proxy.test.ts +262 -0
- package/src/__tests__/schema.test.ts +128 -0
- package/src/__tests__/telegram-normalize.test.ts +303 -0
- package/src/__tests__/telegram-only-default.test.ts +134 -0
- package/src/__tests__/telegram-send-attachments.test.ts +185 -0
- package/src/cli/schema.ts +8 -0
- package/src/config.ts +254 -0
- package/src/dedup-cache.ts +104 -0
- package/src/handlers/handle-inbound.ts +104 -0
- package/src/http/auth/bearer.ts +34 -0
- package/src/http/routes/runtime-proxy.ts +143 -0
- package/src/http/routes/telegram-webhook.ts +272 -0
- package/src/index.ts +117 -0
- package/src/logger.ts +103 -0
- package/src/routing/resolve-assistant.ts +45 -0
- package/src/routing/types.ts +11 -0
- package/src/runtime/client.ts +212 -0
- package/src/schema.ts +383 -0
- package/src/telegram/api.ts +153 -0
- package/src/telegram/download.ts +63 -0
- package/src/telegram/normalize.ts +118 -0
- package/src/telegram/send.ts +107 -0
- package/src/telegram/verify.ts +17 -0
- package/src/types.ts +37 -0
- 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
|
+
}
|