@vellumai/vellum-gateway 0.6.0 → 0.6.2
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/Dockerfile +3 -1
- package/package.json +1 -1
- package/src/__tests__/config.test.ts +2 -1
- package/src/__tests__/feature-flags-route.test.ts +38 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +130 -0
- package/src/channels/inbound-event.ts +4 -2
- package/src/channels/transport-hints.ts +18 -0
- package/src/config.ts +4 -1
- package/src/download-validation.test.ts +96 -0
- package/src/download-validation.ts +92 -0
- package/src/email/normalize.test.ts +129 -0
- package/src/email/normalize.ts +94 -0
- package/src/email/verify.test.ts +96 -0
- package/src/email/verify.ts +41 -0
- package/src/feature-flag-registry.json +17 -1
- package/src/feature-flag-remote-store.ts +19 -0
- package/src/feature-flag-watcher.ts +38 -12
- package/src/http/routes/email-webhook.test.ts +393 -0
- package/src/http/routes/email-webhook.ts +243 -0
- package/src/http/routes/log-export.test.ts +530 -0
- package/src/http/routes/log-export.ts +494 -0
- package/src/http/routes/telegram-webhook.ts +21 -1
- package/src/http/routes/whatsapp-webhook.ts +28 -1
- package/src/index.ts +37 -1
- package/src/logger.ts +21 -6
- package/src/remote-feature-flag-sync.ts +91 -21
- package/src/schema.ts +149 -0
- package/src/slack/download.test.ts +81 -10
- package/src/slack/download.ts +23 -1
- package/src/slack/socket-mode.ts +10 -0
- package/src/telegram/download.ts +3 -0
- package/src/types.ts +1 -0
- package/src/whatsapp/download.ts +3 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway log export route.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates parallel collection from three sources:
|
|
5
|
+
* 1. Gateway's own log files (filtered by date range)
|
|
6
|
+
* 2. Daemon's POST /v1/export (forwarded with service token)
|
|
7
|
+
* 3. CES GET /v1/logs/export (when CES_CREDENTIAL_URL is set)
|
|
8
|
+
*
|
|
9
|
+
* All three collections run via Promise.allSettled so individual failures
|
|
10
|
+
* don't block the others. The result is a tar.gz archive containing the
|
|
11
|
+
* collected files plus an export-manifest.json documenting what succeeded.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
renameSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
statSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { randomBytes } from "node:crypto";
|
|
27
|
+
|
|
28
|
+
import { mintServiceToken } from "../../auth/token-exchange.js";
|
|
29
|
+
import type { GatewayConfig } from "../../config.js";
|
|
30
|
+
import { fetchImpl } from "../../fetch.js";
|
|
31
|
+
import { getLogger, LOG_FILE_PATTERN } from "../../logger.js";
|
|
32
|
+
|
|
33
|
+
const log = getLogger("log-export");
|
|
34
|
+
|
|
35
|
+
/** Maximum total bytes to copy from gateway log files. */
|
|
36
|
+
const GATEWAY_LOG_CAP_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
37
|
+
|
|
38
|
+
/** Timeout for daemon and CES export requests. */
|
|
39
|
+
const EXPORT_TIMEOUT_MS = 120_000;
|
|
40
|
+
|
|
41
|
+
type ServiceStatus = "ok" | "error" | "skipped";
|
|
42
|
+
|
|
43
|
+
interface ExportManifest {
|
|
44
|
+
type: "multi-service-export";
|
|
45
|
+
exportedAt: string;
|
|
46
|
+
startTime?: number;
|
|
47
|
+
endTime?: number;
|
|
48
|
+
services: {
|
|
49
|
+
gateway: ServiceStatus;
|
|
50
|
+
daemon: ServiceStatus;
|
|
51
|
+
ces: ServiceStatus;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ExportRequestBody {
|
|
56
|
+
startTime?: number;
|
|
57
|
+
endTime?: number;
|
|
58
|
+
auditLimit?: number;
|
|
59
|
+
conversationId?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createLogExportHandler(config: GatewayConfig) {
|
|
63
|
+
return async (
|
|
64
|
+
req: Request,
|
|
65
|
+
_params: string[],
|
|
66
|
+
_getClientIp: () => string,
|
|
67
|
+
): Promise<Response> => {
|
|
68
|
+
const start = performance.now();
|
|
69
|
+
|
|
70
|
+
// Parse optional JSON body
|
|
71
|
+
let body: ExportRequestBody = {};
|
|
72
|
+
try {
|
|
73
|
+
const text = await req.text();
|
|
74
|
+
if (text.trim()) {
|
|
75
|
+
const parsed = JSON.parse(text);
|
|
76
|
+
if (
|
|
77
|
+
typeof parsed !== "object" ||
|
|
78
|
+
parsed === null ||
|
|
79
|
+
Array.isArray(parsed)
|
|
80
|
+
) {
|
|
81
|
+
return Response.json(
|
|
82
|
+
{ error: "Body must be a JSON object" },
|
|
83
|
+
{ status: 400 },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
body = parsed;
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { startTime, endTime } = body;
|
|
93
|
+
|
|
94
|
+
// Create a temporary staging directory
|
|
95
|
+
const stagingDir = join(
|
|
96
|
+
tmpdir(),
|
|
97
|
+
`vellum-log-export-${randomBytes(8).toString("hex")}`,
|
|
98
|
+
);
|
|
99
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// Run all three collections in parallel
|
|
103
|
+
const [gatewayResult, daemonResult, cesResult] = await Promise.allSettled(
|
|
104
|
+
[
|
|
105
|
+
collectGatewayLogs(config, stagingDir, startTime, endTime),
|
|
106
|
+
collectDaemonExport(config, stagingDir, body),
|
|
107
|
+
collectCesExport(stagingDir, startTime, endTime),
|
|
108
|
+
],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Process results and write error files for failures
|
|
112
|
+
const gatewayStatus = processResult(gatewayResult, "gateway", stagingDir);
|
|
113
|
+
const daemonStatus = processResult(
|
|
114
|
+
daemonResult,
|
|
115
|
+
"daemon-export",
|
|
116
|
+
stagingDir,
|
|
117
|
+
);
|
|
118
|
+
const cesStatus = processResult(cesResult, "ces-export", stagingDir);
|
|
119
|
+
|
|
120
|
+
// Write export manifest
|
|
121
|
+
const manifest: ExportManifest = {
|
|
122
|
+
type: "multi-service-export",
|
|
123
|
+
exportedAt: new Date().toISOString(),
|
|
124
|
+
...(startTime !== undefined ? { startTime } : {}),
|
|
125
|
+
...(endTime !== undefined ? { endTime } : {}),
|
|
126
|
+
services: {
|
|
127
|
+
gateway: gatewayStatus,
|
|
128
|
+
daemon: daemonStatus,
|
|
129
|
+
ces: cesStatus,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await Bun.write(
|
|
134
|
+
join(stagingDir, "export-manifest.json"),
|
|
135
|
+
JSON.stringify(manifest, null, 2),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Build final tar.gz archive
|
|
139
|
+
const archivePath = `${stagingDir}.tar.gz`;
|
|
140
|
+
const tarProc = Bun.spawn(
|
|
141
|
+
["/usr/bin/tar", "czf", archivePath, "-C", stagingDir, "."],
|
|
142
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
143
|
+
);
|
|
144
|
+
const tarExit = await tarProc.exited;
|
|
145
|
+
if (tarExit !== 0) {
|
|
146
|
+
const stderr = await new Response(tarProc.stderr).text();
|
|
147
|
+
log.error(
|
|
148
|
+
{ exitCode: tarExit, stderr },
|
|
149
|
+
"Failed to create tar.gz archive",
|
|
150
|
+
);
|
|
151
|
+
return Response.json(
|
|
152
|
+
{ error: "Failed to create export archive" },
|
|
153
|
+
{ status: 500 },
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const archiveData = await Bun.file(archivePath).arrayBuffer();
|
|
158
|
+
const duration = Math.round(performance.now() - start);
|
|
159
|
+
log.info(
|
|
160
|
+
{
|
|
161
|
+
duration,
|
|
162
|
+
archiveBytes: archiveData.byteLength,
|
|
163
|
+
gateway: gatewayStatus,
|
|
164
|
+
daemon: daemonStatus,
|
|
165
|
+
ces: cesStatus,
|
|
166
|
+
},
|
|
167
|
+
"Log export completed",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Clean up the archive file (staging dir cleaned in finally)
|
|
171
|
+
try {
|
|
172
|
+
rmSync(archivePath, { force: true });
|
|
173
|
+
} catch {
|
|
174
|
+
// best-effort
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return new Response(archiveData, {
|
|
178
|
+
status: 200,
|
|
179
|
+
headers: {
|
|
180
|
+
"Content-Type": "application/gzip",
|
|
181
|
+
"Content-Disposition":
|
|
182
|
+
"attachment; filename=vellum-logs-export.tar.gz",
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
} finally {
|
|
186
|
+
// Clean up staging directory and archive file (which is a sibling, not inside staging)
|
|
187
|
+
try {
|
|
188
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
189
|
+
} catch {
|
|
190
|
+
// best-effort
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
rmSync(`${stagingDir}.tar.gz`, { force: true });
|
|
194
|
+
} catch {
|
|
195
|
+
// best-effort
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Result processing
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
function processResult(
|
|
206
|
+
result: PromiseSettledResult<ServiceStatus>,
|
|
207
|
+
label: string,
|
|
208
|
+
stagingDir: string,
|
|
209
|
+
): ServiceStatus {
|
|
210
|
+
if (result.status === "fulfilled") {
|
|
211
|
+
return result.value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const errMsg =
|
|
215
|
+
result.reason instanceof Error
|
|
216
|
+
? result.reason.message
|
|
217
|
+
: String(result.reason);
|
|
218
|
+
log.warn({ error: errMsg }, `${label} collection failed`);
|
|
219
|
+
|
|
220
|
+
// Write error file to staging so it's included in the archive
|
|
221
|
+
try {
|
|
222
|
+
const errorFilePath = join(stagingDir, `${label}-error.log`);
|
|
223
|
+
const errorContent = `Collection failed at ${new Date().toISOString()}\n${errMsg}\n`;
|
|
224
|
+
writeFileSync(errorFilePath, errorContent);
|
|
225
|
+
} catch {
|
|
226
|
+
// best-effort
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return "error";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Gateway log collection
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
async function collectGatewayLogs(
|
|
237
|
+
config: GatewayConfig,
|
|
238
|
+
stagingDir: string,
|
|
239
|
+
startTime?: number,
|
|
240
|
+
endTime?: number,
|
|
241
|
+
): Promise<ServiceStatus> {
|
|
242
|
+
const logDir = config.logFile.dir;
|
|
243
|
+
if (!logDir || !existsSync(logDir)) {
|
|
244
|
+
log.info("No gateway log directory configured or found — skipping");
|
|
245
|
+
return "ok";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const destDir = join(stagingDir, "gateway-logs");
|
|
249
|
+
mkdirSync(destDir, { recursive: true });
|
|
250
|
+
|
|
251
|
+
const startDate = startTime ? new Date(startTime) : undefined;
|
|
252
|
+
const endDate = endTime ? new Date(endTime) : undefined;
|
|
253
|
+
|
|
254
|
+
const entries = readdirSync(logDir);
|
|
255
|
+
let totalBytes = 0;
|
|
256
|
+
|
|
257
|
+
for (const name of entries) {
|
|
258
|
+
const match = LOG_FILE_PATTERN.exec(name);
|
|
259
|
+
if (!match) continue;
|
|
260
|
+
|
|
261
|
+
const fileDateStr = match[1];
|
|
262
|
+
const fileDate = new Date(fileDateStr + "T00:00:00Z");
|
|
263
|
+
|
|
264
|
+
// Filter by date range when provided
|
|
265
|
+
if (startDate) {
|
|
266
|
+
// The log file covers a full day — skip if the file's day ends before startTime
|
|
267
|
+
const fileDayEnd = new Date(fileDateStr + "T23:59:59.999Z");
|
|
268
|
+
if (fileDayEnd < startDate) continue;
|
|
269
|
+
}
|
|
270
|
+
if (endDate) {
|
|
271
|
+
// Skip if the file's day starts after endTime
|
|
272
|
+
if (fileDate > endDate) continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const srcPath = join(logDir, name);
|
|
276
|
+
const size = statSync(srcPath).size;
|
|
277
|
+
|
|
278
|
+
// Enforce the 10 MB cap
|
|
279
|
+
if (totalBytes + size > GATEWAY_LOG_CAP_BYTES) {
|
|
280
|
+
log.info(
|
|
281
|
+
{ totalBytes, fileSize: size, cap: GATEWAY_LOG_CAP_BYTES },
|
|
282
|
+
"Gateway log cap reached — skipping remaining files",
|
|
283
|
+
);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Copy the file to staging
|
|
288
|
+
const srcFile = Bun.file(srcPath);
|
|
289
|
+
await Bun.write(join(destDir, name), srcFile);
|
|
290
|
+
totalBytes += size;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
log.info(
|
|
294
|
+
{ fileCount: readdirSync(destDir).length, totalBytes },
|
|
295
|
+
"Gateway logs collected",
|
|
296
|
+
);
|
|
297
|
+
return "ok";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Daemon export collection
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
async function collectDaemonExport(
|
|
305
|
+
config: GatewayConfig,
|
|
306
|
+
stagingDir: string,
|
|
307
|
+
requestBody: ExportRequestBody,
|
|
308
|
+
): Promise<ServiceStatus> {
|
|
309
|
+
const destDir = join(stagingDir, "daemon-exports");
|
|
310
|
+
mkdirSync(destDir, { recursive: true });
|
|
311
|
+
|
|
312
|
+
const serviceToken = mintServiceToken();
|
|
313
|
+
const upstream = `${config.assistantRuntimeBaseUrl}/v1/export`;
|
|
314
|
+
|
|
315
|
+
const controller = new AbortController();
|
|
316
|
+
const timeoutId = setTimeout(() => {
|
|
317
|
+
controller.abort(
|
|
318
|
+
new DOMException(
|
|
319
|
+
"The operation was aborted due to timeout",
|
|
320
|
+
"TimeoutError",
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
}, EXPORT_TIMEOUT_MS);
|
|
324
|
+
|
|
325
|
+
let response: Response;
|
|
326
|
+
try {
|
|
327
|
+
response = await fetchImpl(upstream, {
|
|
328
|
+
method: "POST",
|
|
329
|
+
headers: {
|
|
330
|
+
"Content-Type": "application/json",
|
|
331
|
+
Authorization: `Bearer ${serviceToken}`,
|
|
332
|
+
},
|
|
333
|
+
body: JSON.stringify(requestBody),
|
|
334
|
+
signal: controller.signal,
|
|
335
|
+
});
|
|
336
|
+
clearTimeout(timeoutId);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
clearTimeout(timeoutId);
|
|
339
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
340
|
+
throw new Error("Daemon export request timed out");
|
|
341
|
+
}
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Daemon export connection failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!response.ok) {
|
|
348
|
+
const body = await response.text().catch(() => "(unreadable)");
|
|
349
|
+
throw new Error(
|
|
350
|
+
`Daemon export returned ${response.status}: ${body.slice(0, 256)}`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Write the daemon's tar.gz response to a temp file and extract
|
|
355
|
+
const tarGzPath = join(stagingDir, "daemon-export.tar.gz");
|
|
356
|
+
const data = await response.arrayBuffer();
|
|
357
|
+
await Bun.write(tarGzPath, data);
|
|
358
|
+
|
|
359
|
+
const extractProc = Bun.spawn(
|
|
360
|
+
["/usr/bin/tar", "xzf", tarGzPath, "-C", destDir],
|
|
361
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
362
|
+
);
|
|
363
|
+
const extractExit = await extractProc.exited;
|
|
364
|
+
if (extractExit !== 0) {
|
|
365
|
+
const stderr = await new Response(extractProc.stderr).text();
|
|
366
|
+
log.warn(
|
|
367
|
+
{ exitCode: extractExit, stderr },
|
|
368
|
+
"Failed to extract daemon export tar.gz — including raw archive",
|
|
369
|
+
);
|
|
370
|
+
// Move the raw tar.gz into the dest dir so we still have something
|
|
371
|
+
try {
|
|
372
|
+
renameSync(tarGzPath, join(destDir, "daemon-export.tar.gz"));
|
|
373
|
+
} catch {
|
|
374
|
+
// best-effort — clean up below will handle it
|
|
375
|
+
}
|
|
376
|
+
return "error";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Clean up the temp tar.gz
|
|
380
|
+
try {
|
|
381
|
+
unlinkSync(tarGzPath);
|
|
382
|
+
} catch {
|
|
383
|
+
// best-effort
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
log.info("Daemon export collected");
|
|
387
|
+
return "ok";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// CES export collection
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
async function collectCesExport(
|
|
395
|
+
stagingDir: string,
|
|
396
|
+
startTime?: number,
|
|
397
|
+
endTime?: number,
|
|
398
|
+
): Promise<ServiceStatus> {
|
|
399
|
+
const cesBaseUrl = process.env.CES_CREDENTIAL_URL?.trim();
|
|
400
|
+
if (!cesBaseUrl) {
|
|
401
|
+
log.info("CES_CREDENTIAL_URL not set — skipping CES export");
|
|
402
|
+
return "skipped";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const cesServiceToken = process.env.CES_SERVICE_TOKEN?.trim();
|
|
406
|
+
if (!cesServiceToken) {
|
|
407
|
+
log.warn("CES_CREDENTIAL_URL is set but CES_SERVICE_TOKEN is missing");
|
|
408
|
+
throw new Error(
|
|
409
|
+
"CES_SERVICE_TOKEN is required when CES_CREDENTIAL_URL is set",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const destDir = join(stagingDir, "ces-exports");
|
|
414
|
+
mkdirSync(destDir, { recursive: true });
|
|
415
|
+
|
|
416
|
+
// Build query params
|
|
417
|
+
const params = new URLSearchParams();
|
|
418
|
+
if (startTime !== undefined) params.set("startTime", String(startTime));
|
|
419
|
+
if (endTime !== undefined) params.set("endTime", String(endTime));
|
|
420
|
+
const queryString = params.toString();
|
|
421
|
+
const url = `${cesBaseUrl}/v1/logs/export${queryString ? `?${queryString}` : ""}`;
|
|
422
|
+
|
|
423
|
+
const controller = new AbortController();
|
|
424
|
+
const timeoutId = setTimeout(() => {
|
|
425
|
+
controller.abort(
|
|
426
|
+
new DOMException(
|
|
427
|
+
"The operation was aborted due to timeout",
|
|
428
|
+
"TimeoutError",
|
|
429
|
+
),
|
|
430
|
+
);
|
|
431
|
+
}, EXPORT_TIMEOUT_MS);
|
|
432
|
+
|
|
433
|
+
let response: Response;
|
|
434
|
+
try {
|
|
435
|
+
response = await fetchImpl(url, {
|
|
436
|
+
method: "GET",
|
|
437
|
+
headers: {
|
|
438
|
+
Authorization: `Bearer ${cesServiceToken}`,
|
|
439
|
+
},
|
|
440
|
+
signal: controller.signal,
|
|
441
|
+
});
|
|
442
|
+
clearTimeout(timeoutId);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
clearTimeout(timeoutId);
|
|
445
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
446
|
+
throw new Error("CES export request timed out");
|
|
447
|
+
}
|
|
448
|
+
throw new Error(
|
|
449
|
+
`CES export connection failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!response.ok) {
|
|
454
|
+
const body = await response.text().catch(() => "(unreadable)");
|
|
455
|
+
throw new Error(
|
|
456
|
+
`CES export returned ${response.status}: ${body.slice(0, 256)}`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Write the CES tar.gz response to a temp file and extract
|
|
461
|
+
const tarGzPath = join(stagingDir, "ces-export.tar.gz");
|
|
462
|
+
const data = await response.arrayBuffer();
|
|
463
|
+
await Bun.write(tarGzPath, data);
|
|
464
|
+
|
|
465
|
+
const extractProc = Bun.spawn(
|
|
466
|
+
["/usr/bin/tar", "xzf", tarGzPath, "-C", destDir],
|
|
467
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
468
|
+
);
|
|
469
|
+
const extractExit = await extractProc.exited;
|
|
470
|
+
if (extractExit !== 0) {
|
|
471
|
+
const stderr = await new Response(extractProc.stderr).text();
|
|
472
|
+
log.warn(
|
|
473
|
+
{ exitCode: extractExit, stderr },
|
|
474
|
+
"Failed to extract CES export tar.gz — including raw archive",
|
|
475
|
+
);
|
|
476
|
+
// Move the raw tar.gz into the dest dir so we still have something
|
|
477
|
+
try {
|
|
478
|
+
renameSync(tarGzPath, join(destDir, "ces-export.tar.gz"));
|
|
479
|
+
} catch {
|
|
480
|
+
// best-effort
|
|
481
|
+
}
|
|
482
|
+
return "error";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Clean up the temp tar.gz
|
|
486
|
+
try {
|
|
487
|
+
unlinkSync(tarGzPath);
|
|
488
|
+
} catch {
|
|
489
|
+
// best-effort
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
log.info("CES export collected");
|
|
493
|
+
return "ok";
|
|
494
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { GatewayConfig } from "../../config.js";
|
|
|
4
4
|
import type { CredentialCache } from "../../credential-cache.js";
|
|
5
5
|
import { credentialKey } from "../../credential-key.js";
|
|
6
6
|
import { DedupCache } from "../../dedup-cache.js";
|
|
7
|
+
import { ContentMismatchError } from "../../download-validation.js";
|
|
7
8
|
import { handleInbound } from "../../handlers/handle-inbound.js";
|
|
8
9
|
import { getLogger } from "../../logger.js";
|
|
9
10
|
import { RejectionRateLimiter } from "../../rejection-rate-limiter.js";
|
|
@@ -575,6 +576,7 @@ export function createTelegramWebhookHandler(
|
|
|
575
576
|
// Download and upload attachments if present (skip for edits and callback
|
|
576
577
|
// queries — edits only update text, callbacks have no media to process)
|
|
577
578
|
let attachmentIds: string[] | undefined;
|
|
579
|
+
const failedAttachmentNames: string[] = [];
|
|
578
580
|
const eventAttachments = normalized.message.attachments;
|
|
579
581
|
if (
|
|
580
582
|
eventAttachments &&
|
|
@@ -636,7 +638,8 @@ export function createTelegramWebhookHandler(
|
|
|
636
638
|
return uploadAttachment(config, downloaded);
|
|
637
639
|
}),
|
|
638
640
|
);
|
|
639
|
-
for (
|
|
641
|
+
for (let j = 0; j < results.length; j++) {
|
|
642
|
+
const result = results[j];
|
|
640
643
|
if (result.status === "fulfilled") {
|
|
641
644
|
attachmentIds.push(result.value.id);
|
|
642
645
|
} else if (result.reason instanceof AttachmentValidationError) {
|
|
@@ -644,6 +647,13 @@ export function createTelegramWebhookHandler(
|
|
|
644
647
|
{ err: result.reason },
|
|
645
648
|
"Skipping attachment with validation error",
|
|
646
649
|
);
|
|
650
|
+
failedAttachmentNames.push(batch[j].fileName || batch[j].fileId);
|
|
651
|
+
} else if (result.reason instanceof ContentMismatchError) {
|
|
652
|
+
tlog.warn(
|
|
653
|
+
{ err: result.reason },
|
|
654
|
+
"Skipping attachment with content mismatch",
|
|
655
|
+
);
|
|
656
|
+
failedAttachmentNames.push(batch[j].fileName || batch[j].fileId);
|
|
647
657
|
} else {
|
|
648
658
|
// Transient failure — propagate so the webhook returns 500 and
|
|
649
659
|
// Telegram retries the update delivery.
|
|
@@ -667,6 +677,16 @@ export function createTelegramWebhookHandler(
|
|
|
667
677
|
}
|
|
668
678
|
}
|
|
669
679
|
|
|
680
|
+
// Inject context about failed attachments into the message
|
|
681
|
+
if (failedAttachmentNames.length > 0) {
|
|
682
|
+
const failureNotice = `[The user attached file(s) that could not be retrieved: ${failedAttachmentNames.map((n) => `"${n}"`).join(", ")}. Ask them to re-send if the content is important.]`;
|
|
683
|
+
if (normalized.message.content.length > 0) {
|
|
684
|
+
normalized.message.content += `\n\n${failureNotice}`;
|
|
685
|
+
} else {
|
|
686
|
+
normalized.message.content = failureNotice;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
670
690
|
// Forward message to the runtime. The runtime processes the message
|
|
671
691
|
// in its own loop and delivers the reply to Telegram asynchronously.
|
|
672
692
|
try {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
AttachmentValidationError,
|
|
17
17
|
uploadAttachment,
|
|
18
18
|
} from "../../runtime/client.js";
|
|
19
|
+
import { ContentMismatchError } from "../../download-validation.js";
|
|
19
20
|
import {
|
|
20
21
|
handleCircuitBreakerError,
|
|
21
22
|
handleNewCommand,
|
|
@@ -287,6 +288,7 @@ export function createWhatsAppWebhookHandler(
|
|
|
287
288
|
|
|
288
289
|
// Download and upload attachments if present
|
|
289
290
|
let attachmentIds: string[] | undefined;
|
|
291
|
+
const failedAttachmentNames: string[] = [];
|
|
290
292
|
const eventAttachments = event.message.attachments;
|
|
291
293
|
if (eventAttachments && eventAttachments.length > 0) {
|
|
292
294
|
try {
|
|
@@ -343,7 +345,8 @@ export function createWhatsAppWebhookHandler(
|
|
|
343
345
|
return uploadAttachment(config, downloaded);
|
|
344
346
|
}),
|
|
345
347
|
);
|
|
346
|
-
for (
|
|
348
|
+
for (let j = 0; j < results.length; j++) {
|
|
349
|
+
const result = results[j];
|
|
347
350
|
if (result.status === "fulfilled") {
|
|
348
351
|
attachmentIds.push(result.value.id);
|
|
349
352
|
} else if (result.reason instanceof AttachmentValidationError) {
|
|
@@ -351,11 +354,25 @@ export function createWhatsAppWebhookHandler(
|
|
|
351
354
|
{ err: result.reason },
|
|
352
355
|
"Skipping WhatsApp attachment with validation error",
|
|
353
356
|
);
|
|
357
|
+
failedAttachmentNames.push(
|
|
358
|
+
batch[j].fileName || batch[j].fileId,
|
|
359
|
+
);
|
|
360
|
+
} else if (result.reason instanceof ContentMismatchError) {
|
|
361
|
+
tlog.warn(
|
|
362
|
+
{ err: result.reason },
|
|
363
|
+
"Skipping WhatsApp attachment with content mismatch",
|
|
364
|
+
);
|
|
365
|
+
failedAttachmentNames.push(
|
|
366
|
+
batch[j].fileName || batch[j].fileId,
|
|
367
|
+
);
|
|
354
368
|
} else if (result.reason instanceof WhatsAppNonRetryableError) {
|
|
355
369
|
tlog.warn(
|
|
356
370
|
{ err: result.reason },
|
|
357
371
|
"Skipping WhatsApp attachment with non-retryable error",
|
|
358
372
|
);
|
|
373
|
+
failedAttachmentNames.push(
|
|
374
|
+
batch[j].fileName || batch[j].fileId,
|
|
375
|
+
);
|
|
359
376
|
} else {
|
|
360
377
|
// Transient failure — propagate so the webhook returns 500 and
|
|
361
378
|
// Meta retries the update delivery.
|
|
@@ -375,6 +392,16 @@ export function createWhatsAppWebhookHandler(
|
|
|
375
392
|
}
|
|
376
393
|
}
|
|
377
394
|
|
|
395
|
+
// Inject context about failed attachments into the message
|
|
396
|
+
if (failedAttachmentNames.length > 0) {
|
|
397
|
+
const failureNotice = `[The user attached file(s) that could not be retrieved: ${failedAttachmentNames.map((n) => `"${n}"`).join(", ")}. Ask them to re-send if the content is important.]`;
|
|
398
|
+
if (event.message.content.length > 0) {
|
|
399
|
+
event.message.content += `\n\n${failureNotice}`;
|
|
400
|
+
} else {
|
|
401
|
+
event.message.content = failureNotice;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
378
405
|
// Media-only messages with no successfully uploaded attachments have
|
|
379
406
|
// nothing to forward — skip silently.
|
|
380
407
|
if (
|