@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,143 @@
|
|
|
1
|
+
import type { GatewayConfig } from "../../config.js";
|
|
2
|
+
import { getLogger } from "../../logger.js";
|
|
3
|
+
import { validateBearerToken } from "../auth/bearer.js";
|
|
4
|
+
|
|
5
|
+
const log = getLogger("runtime-proxy");
|
|
6
|
+
|
|
7
|
+
const HOP_BY_HOP_HEADERS = [
|
|
8
|
+
"connection",
|
|
9
|
+
"keep-alive",
|
|
10
|
+
"proxy-authenticate",
|
|
11
|
+
"proxy-authorization",
|
|
12
|
+
"te",
|
|
13
|
+
"trailer",
|
|
14
|
+
"transfer-encoding",
|
|
15
|
+
"upgrade",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function stripHopByHop(headers: Headers): Headers {
|
|
19
|
+
const cleaned = new Headers(headers);
|
|
20
|
+
|
|
21
|
+
// Also strip any headers listed in the Connection header value
|
|
22
|
+
const connectionValue = cleaned.get("connection");
|
|
23
|
+
if (connectionValue) {
|
|
24
|
+
for (const name of connectionValue.split(",")) {
|
|
25
|
+
const trimmed = name.trim().toLowerCase();
|
|
26
|
+
if (trimmed) {
|
|
27
|
+
try {
|
|
28
|
+
cleaned.delete(trimmed);
|
|
29
|
+
} catch {
|
|
30
|
+
// Ignore invalid header names (e.g., malformed Connection tokens like "@@@")
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const h of HOP_BY_HOP_HEADERS) {
|
|
37
|
+
cleaned.delete(h);
|
|
38
|
+
}
|
|
39
|
+
return cleaned;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createRuntimeProxyHandler(config: GatewayConfig) {
|
|
43
|
+
return async (req: Request): Promise<Response> => {
|
|
44
|
+
const start = performance.now();
|
|
45
|
+
const url = new URL(req.url);
|
|
46
|
+
|
|
47
|
+
if (config.runtimeProxyRequireAuth && req.method !== "OPTIONS") {
|
|
48
|
+
if (!config.runtimeProxyBearerToken) {
|
|
49
|
+
return Response.json({ error: "Server misconfigured" }, { status: 500 });
|
|
50
|
+
}
|
|
51
|
+
const authResult = validateBearerToken(
|
|
52
|
+
req.headers.get("authorization"),
|
|
53
|
+
config.runtimeProxyBearerToken,
|
|
54
|
+
);
|
|
55
|
+
if (!authResult.authorized) {
|
|
56
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const upstream = `${config.assistantRuntimeBaseUrl}${url.pathname}${url.search}`;
|
|
61
|
+
|
|
62
|
+
const reqHeaders = stripHopByHop(new Headers(req.headers));
|
|
63
|
+
reqHeaders.delete("host");
|
|
64
|
+
// Only strip the authorization header when the gateway consumed it for its
|
|
65
|
+
// own auth check. When auth is not required, the header may be intended for
|
|
66
|
+
// the upstream service and must be forwarded.
|
|
67
|
+
if (config.runtimeProxyRequireAuth) {
|
|
68
|
+
reqHeaders.delete("authorization");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Add the runtime's bearer token so the upstream accepts the request
|
|
72
|
+
if (config.runtimeBearerToken) {
|
|
73
|
+
reqHeaders.set("authorization", `Bearer ${config.runtimeBearerToken}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (config.runtimeProxyBearerToken) {
|
|
77
|
+
reqHeaders.set("authorization", `Bearer ${config.runtimeProxyBearerToken}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Use a manual AbortController so the timeout only covers the connection
|
|
81
|
+
// phase (waiting for response headers). Once headers arrive, the timeout is
|
|
82
|
+
// cleared so streaming responses (SSE, chunked) can run indefinitely.
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
const timeoutId = setTimeout(() => {
|
|
85
|
+
controller.abort(new DOMException("The operation was aborted due to timeout", "TimeoutError"));
|
|
86
|
+
}, config.runtimeTimeoutMs);
|
|
87
|
+
|
|
88
|
+
let response: Response;
|
|
89
|
+
try {
|
|
90
|
+
response = await fetch(upstream, {
|
|
91
|
+
method: req.method,
|
|
92
|
+
headers: reqHeaders,
|
|
93
|
+
body: req.body,
|
|
94
|
+
// @ts-expect-error Bun supports duplex on Request
|
|
95
|
+
duplex: "half",
|
|
96
|
+
signal: controller.signal,
|
|
97
|
+
});
|
|
98
|
+
clearTimeout(timeoutId);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
clearTimeout(timeoutId);
|
|
101
|
+
const duration = Math.round(performance.now() - start);
|
|
102
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
103
|
+
log.error(
|
|
104
|
+
{ method: req.method, path: url.pathname, duration, timeoutMs: config.runtimeTimeoutMs },
|
|
105
|
+
"Upstream request timed out",
|
|
106
|
+
);
|
|
107
|
+
return Response.json({ error: "Gateway Timeout" }, { status: 504 });
|
|
108
|
+
}
|
|
109
|
+
log.error(
|
|
110
|
+
{ err, method: req.method, path: url.pathname, duration },
|
|
111
|
+
"Upstream connection failed",
|
|
112
|
+
);
|
|
113
|
+
return Response.json({ error: "Bad Gateway" }, { status: 502 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const resHeaders = stripHopByHop(new Headers(response.headers));
|
|
117
|
+
const duration = Math.round(performance.now() - start);
|
|
118
|
+
|
|
119
|
+
if (response.status >= 400) {
|
|
120
|
+
const body = await response.text();
|
|
121
|
+
const level = response.status >= 500 ? "error" : "warn";
|
|
122
|
+
const bodySnippet = body.length > 256 ? body.slice(0, 256) + "…[truncated]" : body;
|
|
123
|
+
log[level](
|
|
124
|
+
{ method: req.method, path: url.pathname, status: response.status, duration, body: bodySnippet },
|
|
125
|
+
"Upstream returned error",
|
|
126
|
+
);
|
|
127
|
+
return new Response(body, {
|
|
128
|
+
status: response.status,
|
|
129
|
+
headers: resHeaders,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
log.info(
|
|
134
|
+
{ method: req.method, path: url.pathname, status: response.status, duration },
|
|
135
|
+
"Proxy request completed",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
return new Response(response.body, {
|
|
139
|
+
status: response.status,
|
|
140
|
+
headers: resHeaders,
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { GatewayConfig } from "../../config.js";
|
|
2
|
+
import { DedupCache } from "../../dedup-cache.js";
|
|
3
|
+
import { handleInbound, type InboundResult } from "../../handlers/handle-inbound.js";
|
|
4
|
+
import { getLogger } from "../../logger.js";
|
|
5
|
+
import { resolveAssistant, isRejection } from "../../routing/resolve-assistant.js";
|
|
6
|
+
import { AttachmentValidationError, resetConversation, uploadAttachment } from "../../runtime/client.js";
|
|
7
|
+
import { downloadTelegramFile } from "../../telegram/download.js";
|
|
8
|
+
import { normalizeTelegramUpdate } from "../../telegram/normalize.js";
|
|
9
|
+
import { sendTelegramReply, sendTypingIndicator } from "../../telegram/send.js";
|
|
10
|
+
import { verifyWebhookSecret } from "../../telegram/verify.js";
|
|
11
|
+
|
|
12
|
+
const log = getLogger("telegram-webhook");
|
|
13
|
+
|
|
14
|
+
const MAX_TYPING_DURATION_MS = 60_000;
|
|
15
|
+
const MAX_TYPING_FAILURES = 3;
|
|
16
|
+
export const TELEGRAM_CHANNEL_TRANSPORT_HINTS = [
|
|
17
|
+
"chat-first-medium",
|
|
18
|
+
"channel-safe-onboarding",
|
|
19
|
+
"defer-dashboard-only-tasks",
|
|
20
|
+
] as const;
|
|
21
|
+
export const TELEGRAM_CHANNEL_TRANSPORT_UX_BRIEF =
|
|
22
|
+
"Telegram is chat-only. Complete channel-safe steps in-channel and defer dashboard-only Home Base tasks to desktop.";
|
|
23
|
+
|
|
24
|
+
export function buildTelegramTransportMetadata(): { hints: string[]; uxBrief: string } {
|
|
25
|
+
return {
|
|
26
|
+
hints: [...TELEGRAM_CHANNEL_TRANSPORT_HINTS],
|
|
27
|
+
uxBrief: TELEGRAM_CHANNEL_TRANSPORT_UX_BRIEF,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type OnReply = (
|
|
32
|
+
chatId: string,
|
|
33
|
+
result: InboundResult,
|
|
34
|
+
assistantId: string,
|
|
35
|
+
) => Promise<void>;
|
|
36
|
+
|
|
37
|
+
export function createTelegramWebhookHandler(
|
|
38
|
+
config: GatewayConfig,
|
|
39
|
+
onReply?: OnReply,
|
|
40
|
+
) {
|
|
41
|
+
const dedupCache = new DedupCache();
|
|
42
|
+
|
|
43
|
+
return async (req: Request): Promise<Response> => {
|
|
44
|
+
if (req.method !== "POST") {
|
|
45
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Payload size guard
|
|
49
|
+
const contentLength = req.headers.get("content-length");
|
|
50
|
+
if (contentLength && Number(contentLength) > config.maxWebhookPayloadBytes) {
|
|
51
|
+
log.warn({ contentLength }, "Webhook payload too large");
|
|
52
|
+
return Response.json({ error: "Payload too large" }, { status: 413 });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Verify webhook secret
|
|
56
|
+
if (!config.telegramWebhookSecret || !verifyWebhookSecret(req.headers, config.telegramWebhookSecret)) {
|
|
57
|
+
log.warn("Telegram webhook request failed secret verification");
|
|
58
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let rawBody: string;
|
|
62
|
+
try {
|
|
63
|
+
rawBody = await req.text();
|
|
64
|
+
} catch {
|
|
65
|
+
return Response.json({ error: "Failed to read body" }, { status: 400 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (Buffer.byteLength(rawBody) > config.maxWebhookPayloadBytes) {
|
|
69
|
+
log.warn({ bodyLength: Buffer.byteLength(rawBody) }, "Webhook payload too large");
|
|
70
|
+
return Response.json({ error: "Payload too large" }, { status: 413 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let payload: Record<string, unknown>;
|
|
74
|
+
try {
|
|
75
|
+
payload = JSON.parse(rawBody) as Record<string, unknown>;
|
|
76
|
+
} catch {
|
|
77
|
+
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Dedup check — reserve the update_id immediately so concurrent retries
|
|
81
|
+
// are blocked even while the first request is still processing.
|
|
82
|
+
const updateId = typeof payload.update_id === "number" ? payload.update_id : undefined;
|
|
83
|
+
if (updateId !== undefined) {
|
|
84
|
+
const reserved = dedupCache.reserve(updateId);
|
|
85
|
+
if (!reserved) {
|
|
86
|
+
log.info({ updateId }, "Duplicate update_id, returning cached response");
|
|
87
|
+
const cached = dedupCache.get(updateId)!;
|
|
88
|
+
return new Response(cached.body, {
|
|
89
|
+
status: cached.status,
|
|
90
|
+
headers: { "content-type": "application/json" },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Helper: build a JSON response and update the cache with the final result
|
|
96
|
+
const respond = (body: Record<string, unknown>, status = 200): Response => {
|
|
97
|
+
const json = JSON.stringify(body);
|
|
98
|
+
if (updateId !== undefined) {
|
|
99
|
+
dedupCache.set(updateId, json, status);
|
|
100
|
+
}
|
|
101
|
+
return new Response(json, {
|
|
102
|
+
status,
|
|
103
|
+
headers: { "content-type": "application/json" },
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Normalize the update
|
|
108
|
+
const normalized = normalizeTelegramUpdate(payload);
|
|
109
|
+
if (!normalized) {
|
|
110
|
+
return respond({ ok: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Handle /new command — reset conversation before it reaches the runtime
|
|
114
|
+
if (normalized.message.content.trim() === "/new") {
|
|
115
|
+
const routing = resolveAssistant(
|
|
116
|
+
config,
|
|
117
|
+
normalized.message.externalChatId,
|
|
118
|
+
normalized.sender.externalUserId,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (!isRejection(routing)) {
|
|
122
|
+
try {
|
|
123
|
+
await resetConversation(
|
|
124
|
+
config,
|
|
125
|
+
routing.assistantId,
|
|
126
|
+
normalized.sourceChannel,
|
|
127
|
+
normalized.message.externalChatId,
|
|
128
|
+
);
|
|
129
|
+
sendTelegramReply(config, normalized.message.externalChatId, "Starting a new conversation!").catch((err) => {
|
|
130
|
+
log.error({ err }, "Failed to send /new confirmation");
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
log.error({ err }, "Failed to reset conversation");
|
|
134
|
+
sendTelegramReply(config, normalized.message.externalChatId, "Failed to reset conversation. Please try again.").catch((replyErr) => {
|
|
135
|
+
log.error({ err: replyErr }, "Failed to send /new error reply");
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return respond({ ok: true });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Edits don't produce a new reply, so skip typing indicator and attachments
|
|
144
|
+
const isEdit = !!normalized.message.isEdit;
|
|
145
|
+
|
|
146
|
+
// Check routing early so we can gate attachments and typing indicator
|
|
147
|
+
const chatId = normalized.message.externalChatId;
|
|
148
|
+
const routing = resolveAssistant(
|
|
149
|
+
config,
|
|
150
|
+
chatId,
|
|
151
|
+
normalized.sender.externalUserId,
|
|
152
|
+
);
|
|
153
|
+
const routable = !isRejection(routing);
|
|
154
|
+
|
|
155
|
+
// Download and upload attachments if present (skip for edits — the runtime
|
|
156
|
+
// edit path only updates text content and doesn't link new attachments)
|
|
157
|
+
let attachmentIds: string[] | undefined;
|
|
158
|
+
const eventAttachments = normalized.message.attachments;
|
|
159
|
+
if (eventAttachments && eventAttachments.length > 0 && routable && !isEdit) {
|
|
160
|
+
try {
|
|
161
|
+
attachmentIds = [];
|
|
162
|
+
|
|
163
|
+
// Filter oversized attachments
|
|
164
|
+
const eligible = eventAttachments.filter((att) => {
|
|
165
|
+
if (att.fileSize !== undefined && att.fileSize > config.maxAttachmentBytes) {
|
|
166
|
+
log.warn(
|
|
167
|
+
{ fileId: att.fileId, fileSize: att.fileSize, limit: config.maxAttachmentBytes },
|
|
168
|
+
"Skipping oversized attachment",
|
|
169
|
+
);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Process with bounded concurrency. Validation errors (unsupported
|
|
176
|
+
// MIME type, dangerous extension) are skipped so that a bad attachment
|
|
177
|
+
// doesn't drop the user's message. Transient errors (download timeout,
|
|
178
|
+
// upload 5xx, network failures) are propagated so that Telegram retries
|
|
179
|
+
// the webhook delivery.
|
|
180
|
+
for (let i = 0; i < eligible.length; i += config.maxAttachmentConcurrency) {
|
|
181
|
+
const batch = eligible.slice(i, i + config.maxAttachmentConcurrency);
|
|
182
|
+
const results = await Promise.allSettled(
|
|
183
|
+
batch.map(async (att) => {
|
|
184
|
+
const downloaded = await downloadTelegramFile(config, att.fileId, {
|
|
185
|
+
fileName: att.fileName,
|
|
186
|
+
mimeType: att.mimeType,
|
|
187
|
+
});
|
|
188
|
+
return uploadAttachment(config, routing.assistantId, downloaded);
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
for (const result of results) {
|
|
192
|
+
if (result.status === 'fulfilled') {
|
|
193
|
+
attachmentIds.push(result.value.id);
|
|
194
|
+
} else if (result.reason instanceof AttachmentValidationError) {
|
|
195
|
+
log.warn({ err: result.reason }, "Skipping attachment with validation error");
|
|
196
|
+
} else {
|
|
197
|
+
// Transient failure — propagate so the webhook returns 500 and
|
|
198
|
+
// Telegram retries the update delivery.
|
|
199
|
+
throw result.reason;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
// Transient attachment failure — return 500 so Telegram retries.
|
|
205
|
+
// Use Response.json() instead of respond() to bypass the dedup cache,
|
|
206
|
+
// otherwise the cached 500 prevents Telegram retries from being processed.
|
|
207
|
+
log.error({ err }, "Attachment processing failed with transient error");
|
|
208
|
+
if (updateId !== undefined) dedupCache.unreserve(updateId);
|
|
209
|
+
return Response.json({ error: "Attachment processing failed" }, { status: 500 });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Start typing indicator only for routable chats with new messages.
|
|
214
|
+
// A safety timeout ensures the interval is cleared even if handleInbound hangs.
|
|
215
|
+
// Cancel early if the Telegram API fails repeatedly (MAX_TYPING_FAILURES consecutive).
|
|
216
|
+
let typingInterval: ReturnType<typeof setInterval> | undefined;
|
|
217
|
+
let typingTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
218
|
+
const clearTyping = () => {
|
|
219
|
+
clearInterval(typingInterval);
|
|
220
|
+
clearTimeout(typingTimeout);
|
|
221
|
+
};
|
|
222
|
+
if (routable && !isEdit) {
|
|
223
|
+
let consecutiveFailures = 0;
|
|
224
|
+
// Fire-and-forget: don't track the initial call's result to avoid
|
|
225
|
+
// race conditions with the interval's consecutiveFailures counter.
|
|
226
|
+
sendTypingIndicator(config, chatId);
|
|
227
|
+
typingInterval = setInterval(async () => {
|
|
228
|
+
const ok = await sendTypingIndicator(config, chatId);
|
|
229
|
+
if (ok) {
|
|
230
|
+
consecutiveFailures = 0;
|
|
231
|
+
} else {
|
|
232
|
+
consecutiveFailures++;
|
|
233
|
+
if (consecutiveFailures >= MAX_TYPING_FAILURES) {
|
|
234
|
+
log.warn({ chatId, consecutiveFailures }, "Typing indicator cancelled after repeated failures");
|
|
235
|
+
clearTyping();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}, 5000);
|
|
239
|
+
typingTimeout = setTimeout(clearTyping, MAX_TYPING_DURATION_MS);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Process inbound and only acknowledge after successful delivery
|
|
243
|
+
let result: InboundResult;
|
|
244
|
+
try {
|
|
245
|
+
result = await handleInbound(config, normalized, {
|
|
246
|
+
attachmentIds,
|
|
247
|
+
transportMetadata: buildTelegramTransportMetadata(),
|
|
248
|
+
});
|
|
249
|
+
} catch (err) {
|
|
250
|
+
log.error({ err, updateId: payload.update_id }, "Failed to process inbound event");
|
|
251
|
+
if (updateId !== undefined) dedupCache.unreserve(updateId);
|
|
252
|
+
return Response.json({ error: "Internal error" }, { status: 500 });
|
|
253
|
+
} finally {
|
|
254
|
+
clearTyping();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!result.forwarded && !result.rejected) {
|
|
258
|
+
log.error({ updateId: payload.update_id }, "Failed to forward inbound event");
|
|
259
|
+
if (updateId !== undefined) dedupCache.unreserve(updateId);
|
|
260
|
+
return Response.json({ error: "Internal error" }, { status: 500 });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Fire reply asynchronously so webhook ack is not blocked by outbound send
|
|
264
|
+
if (onReply && !isRejection(routing) && !result.rejected && result.runtimeResponse?.assistantMessage) {
|
|
265
|
+
onReply(normalized.message.externalChatId, result, routing.assistantId).catch((err) => {
|
|
266
|
+
log.error({ err, updateId: payload.update_id }, "Failed to send reply");
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return respond({ ok: true });
|
|
271
|
+
};
|
|
272
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
2
|
+
import { createRuntimeProxyHandler } from "./http/routes/runtime-proxy.js";
|
|
3
|
+
import { createTelegramWebhookHandler } from "./http/routes/telegram-webhook.js";
|
|
4
|
+
import { getLogger, initLogger } from "./logger.js";
|
|
5
|
+
import { buildSchema } from "./schema.js";
|
|
6
|
+
import { callTelegramApi } from "./telegram/api.js";
|
|
7
|
+
import { sendTelegramAttachments, sendTelegramReply } from "./telegram/send.js";
|
|
8
|
+
|
|
9
|
+
const log = getLogger("main");
|
|
10
|
+
|
|
11
|
+
let draining = false;
|
|
12
|
+
|
|
13
|
+
function main() {
|
|
14
|
+
const config = loadConfig();
|
|
15
|
+
initLogger(config.logFile);
|
|
16
|
+
|
|
17
|
+
log.info("Starting Vellum Gateway...");
|
|
18
|
+
|
|
19
|
+
const telegramConfigured = !!(config.telegramBotToken && config.telegramWebhookSecret);
|
|
20
|
+
|
|
21
|
+
const handleTelegramWebhook = telegramConfigured
|
|
22
|
+
? createTelegramWebhookHandler(
|
|
23
|
+
config,
|
|
24
|
+
async (chatId, result, assistantId) => {
|
|
25
|
+
const msg = result.runtimeResponse?.assistantMessage;
|
|
26
|
+
const content = msg?.content;
|
|
27
|
+
const attachments = msg?.attachments ?? [];
|
|
28
|
+
|
|
29
|
+
if (!content && attachments.length === 0) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
if (content) {
|
|
35
|
+
await sendTelegramReply(config, chatId, content);
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
log.error({ err, chatId }, "Failed to send Telegram reply");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (attachments.length > 0) {
|
|
42
|
+
try {
|
|
43
|
+
await sendTelegramAttachments(config, chatId, assistantId, attachments);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
log.error({ err, chatId }, "Failed to send Telegram attachments");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
: null;
|
|
51
|
+
|
|
52
|
+
const handleRuntimeProxy = config.runtimeProxyEnabled
|
|
53
|
+
? createRuntimeProxyHandler(config)
|
|
54
|
+
: null;
|
|
55
|
+
|
|
56
|
+
const server = Bun.serve({
|
|
57
|
+
port: config.port,
|
|
58
|
+
async fetch(req) {
|
|
59
|
+
const url = new URL(req.url);
|
|
60
|
+
|
|
61
|
+
if (url.pathname === "/healthz") {
|
|
62
|
+
return Response.json({ status: "ok" });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (url.pathname === "/schema") {
|
|
66
|
+
return Response.json(buildSchema());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (url.pathname === "/readyz") {
|
|
70
|
+
if (draining) {
|
|
71
|
+
return Response.json({ status: "draining" }, { status: 503 });
|
|
72
|
+
}
|
|
73
|
+
return Response.json({ status: "ok" });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (url.pathname === "/webhooks/telegram") {
|
|
77
|
+
if (!handleTelegramWebhook) {
|
|
78
|
+
return Response.json(
|
|
79
|
+
{ error: "Telegram integration not configured" },
|
|
80
|
+
{ status: 503 },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return handleTelegramWebhook(req);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (handleRuntimeProxy) {
|
|
87
|
+
return handleRuntimeProxy(req);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return Response.json({ error: "Not found", source: "gateway" }, { status: 404 });
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
log.info({ port: server.port }, "Gateway HTTP server listening");
|
|
95
|
+
|
|
96
|
+
if (telegramConfigured) {
|
|
97
|
+
callTelegramApi(config, "setMyCommands", {
|
|
98
|
+
commands: [{ command: "new", description: "Start a new conversation" }],
|
|
99
|
+
}).catch((err) => {
|
|
100
|
+
log.error({ err }, "Failed to register Telegram bot commands");
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const drainMs = config.shutdownDrainMs;
|
|
105
|
+
|
|
106
|
+
process.on("SIGTERM", () => {
|
|
107
|
+
log.info("SIGTERM received, starting graceful shutdown");
|
|
108
|
+
draining = true;
|
|
109
|
+
setTimeout(() => {
|
|
110
|
+
log.info("Drain window elapsed, stopping server");
|
|
111
|
+
server.stop(true);
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}, drainMs);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
main();
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import pino from "pino";
|
|
4
|
+
|
|
5
|
+
export type LogFileConfig = {
|
|
6
|
+
dir: string | undefined;
|
|
7
|
+
retentionDays: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const LOG_FILE_PREFIX = "gateway-";
|
|
11
|
+
const LOG_FILE_SUFFIX = ".log";
|
|
12
|
+
const LOG_FILE_PATTERN = /^gateway-(\d{4}-\d{2}-\d{2})\.log$/;
|
|
13
|
+
|
|
14
|
+
function formatDate(date: Date): string {
|
|
15
|
+
const y = date.getUTCFullYear();
|
|
16
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
17
|
+
const d = String(date.getUTCDate()).padStart(2, "0");
|
|
18
|
+
return `${y}-${m}-${d}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function logFilePathForDate(dir: string, date: Date): string {
|
|
22
|
+
return join(dir, `${LOG_FILE_PREFIX}${formatDate(date)}${LOG_FILE_SUFFIX}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function pruneOldLogFiles(dir: string, retentionDays: number): number {
|
|
26
|
+
if (!existsSync(dir)) return 0;
|
|
27
|
+
|
|
28
|
+
const cutoff = new Date();
|
|
29
|
+
cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays);
|
|
30
|
+
cutoff.setUTCHours(0, 0, 0, 0);
|
|
31
|
+
|
|
32
|
+
let removed = 0;
|
|
33
|
+
for (const name of readdirSync(dir)) {
|
|
34
|
+
const match = LOG_FILE_PATTERN.exec(name);
|
|
35
|
+
if (!match) continue;
|
|
36
|
+
const fileDate = new Date(match[1] + "T00:00:00Z");
|
|
37
|
+
if (fileDate < cutoff) {
|
|
38
|
+
try {
|
|
39
|
+
unlinkSync(join(dir, name));
|
|
40
|
+
removed++;
|
|
41
|
+
} catch {
|
|
42
|
+
// best-effort
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return removed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let rootLogger: pino.Logger | null = null;
|
|
50
|
+
let activeLogDate: string | null = null;
|
|
51
|
+
let activeConfig: LogFileConfig | null = null;
|
|
52
|
+
|
|
53
|
+
function buildLogger(config: LogFileConfig | null): pino.Logger {
|
|
54
|
+
if (!config?.dir) {
|
|
55
|
+
return pino({ name: "gateway" });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!existsSync(config.dir)) {
|
|
59
|
+
mkdirSync(config.dir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const today = formatDate(new Date());
|
|
63
|
+
const filePath = logFilePathForDate(config.dir, new Date());
|
|
64
|
+
const fileStream = pino.destination({ dest: filePath, sync: false, mkdir: true });
|
|
65
|
+
|
|
66
|
+
activeLogDate = today;
|
|
67
|
+
activeConfig = config;
|
|
68
|
+
|
|
69
|
+
return pino(
|
|
70
|
+
{ name: "gateway" },
|
|
71
|
+
pino.multistream([
|
|
72
|
+
{ stream: fileStream, level: "info" as const },
|
|
73
|
+
{ stream: pino.destination(1), level: "info" as const },
|
|
74
|
+
]),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ensureCurrentDate(): void {
|
|
79
|
+
if (!activeConfig?.dir || !activeLogDate) return;
|
|
80
|
+
const today = formatDate(new Date());
|
|
81
|
+
if (today !== activeLogDate) {
|
|
82
|
+
rootLogger = buildLogger(activeConfig);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function initLogger(config: LogFileConfig): void {
|
|
87
|
+
rootLogger = buildLogger(config);
|
|
88
|
+
|
|
89
|
+
if (config.dir && config.retentionDays > 0) {
|
|
90
|
+
const removed = pruneOldLogFiles(config.dir, config.retentionDays);
|
|
91
|
+
if (removed > 0) {
|
|
92
|
+
rootLogger.info({ removed, retentionDays: config.retentionDays }, "Pruned old log files");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getLogger(name: string): pino.Logger {
|
|
98
|
+
ensureCurrentDate();
|
|
99
|
+
if (!rootLogger) {
|
|
100
|
+
rootLogger = buildLogger(null);
|
|
101
|
+
}
|
|
102
|
+
return rootLogger.child({ module: name });
|
|
103
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { GatewayConfig } from "../config.js";
|
|
2
|
+
import { getLogger } from "../logger.js";
|
|
3
|
+
import type { RoutingOutcome } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const log = getLogger("routing");
|
|
6
|
+
|
|
7
|
+
export function resolveAssistant(
|
|
8
|
+
config: GatewayConfig,
|
|
9
|
+
chatId: string,
|
|
10
|
+
userId: string,
|
|
11
|
+
): RoutingOutcome {
|
|
12
|
+
// Priority 1: explicit chat_id route
|
|
13
|
+
for (const entry of config.routingEntries) {
|
|
14
|
+
if (entry.type === "chat_id" && entry.key === chatId) {
|
|
15
|
+
log.debug({ chatId, assistantId: entry.assistantId }, "Resolved by chat_id");
|
|
16
|
+
return { assistantId: entry.assistantId, routeSource: "chat_id" };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Priority 2: explicit user_id route
|
|
21
|
+
for (const entry of config.routingEntries) {
|
|
22
|
+
if (entry.type === "user_id" && entry.key === userId) {
|
|
23
|
+
log.debug({ userId, assistantId: entry.assistantId }, "Resolved by user_id");
|
|
24
|
+
return { assistantId: entry.assistantId, routeSource: "user_id" };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Priority 3: apply unmapped policy
|
|
29
|
+
if (config.unmappedPolicy === "default" && config.defaultAssistantId) {
|
|
30
|
+
log.debug(
|
|
31
|
+
{ chatId, userId, assistantId: config.defaultAssistantId },
|
|
32
|
+
"Resolved by default policy",
|
|
33
|
+
);
|
|
34
|
+
return { assistantId: config.defaultAssistantId, routeSource: "default" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
log.info({ chatId, userId }, "No route matched, rejecting");
|
|
38
|
+
return { rejected: true, reason: "No route configured for this chat" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isRejection(
|
|
42
|
+
outcome: RoutingOutcome,
|
|
43
|
+
): outcome is { rejected: true; reason: string } {
|
|
44
|
+
return "rejected" in outcome && outcome.rejected === true;
|
|
45
|
+
}
|