talon-agent 1.0.0 → 1.2.0
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/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +15 -11
- package/prompts/dream.md +7 -3
- package/prompts/heartbeat.md +30 -0
- package/prompts/identity.md +1 -0
- package/prompts/teams.md +3 -0
- package/prompts/telegram.md +1 -0
- package/src/__tests__/chat-settings.test.ts +108 -2
- package/src/__tests__/cleanup-registry.test.ts +58 -0
- package/src/__tests__/config.test.ts +118 -52
- package/src/__tests__/cron-store-extended.test.ts +661 -0
- package/src/__tests__/cron-store.test.ts +145 -11
- package/src/__tests__/daily-log.test.ts +224 -13
- package/src/__tests__/dispatcher.test.ts +424 -23
- package/src/__tests__/dream.test.ts +1028 -0
- package/src/__tests__/errors-extended.test.ts +428 -0
- package/src/__tests__/errors.test.ts +95 -3
- package/src/__tests__/fuzz.test.ts +87 -15
- package/src/__tests__/gateway-actions.test.ts +1174 -433
- package/src/__tests__/gateway-http.test.ts +210 -19
- package/src/__tests__/gateway-retry.test.ts +359 -0
- package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
- package/src/__tests__/graph.test.ts +830 -0
- package/src/__tests__/handlers-stream.test.ts +208 -0
- package/src/__tests__/handlers.test.ts +2539 -70
- package/src/__tests__/heartbeat.test.ts +364 -0
- package/src/__tests__/history-extended.test.ts +775 -0
- package/src/__tests__/history-persistence.test.ts +74 -19
- package/src/__tests__/history.test.ts +113 -79
- package/src/__tests__/integration.test.ts +43 -8
- package/src/__tests__/log-init.test.ts +129 -0
- package/src/__tests__/log.test.ts +23 -5
- package/src/__tests__/media-index.test.ts +317 -35
- package/src/__tests__/plugin.test.ts +314 -0
- package/src/__tests__/prompt-builder-extended.test.ts +296 -0
- package/src/__tests__/prompt-builder.test.ts +44 -9
- package/src/__tests__/sessions.test.ts +258 -4
- package/src/__tests__/storage-save-errors.test.ts +342 -0
- package/src/__tests__/teams-frontend.test.ts +526 -31
- package/src/__tests__/telegram-formatting.test.ts +82 -0
- package/src/__tests__/terminal-commands.test.ts +208 -1
- package/src/__tests__/terminal-renderer.test.ts +223 -0
- package/src/__tests__/time.test.ts +107 -0
- package/src/__tests__/workspace-migrate.test.ts +256 -0
- package/src/__tests__/workspace.test.ts +63 -1
- package/src/backend/claude-sdk/tools.ts +64 -18
- package/src/bootstrap.ts +14 -14
- package/src/cli.ts +440 -125
- package/src/core/cron.ts +20 -5
- package/src/core/dispatcher.ts +27 -9
- package/src/core/dream.ts +79 -24
- package/src/core/errors.ts +12 -2
- package/src/core/gateway-actions.ts +182 -46
- package/src/core/gateway.ts +93 -41
- package/src/core/heartbeat.ts +515 -0
- package/src/core/plugin.ts +1 -1
- package/src/core/prompt-builder.ts +1 -4
- package/src/core/pulse.ts +4 -3
- package/src/frontend/teams/actions.ts +3 -1
- package/src/frontend/teams/formatting.ts +47 -8
- package/src/frontend/teams/graph.ts +35 -11
- package/src/frontend/teams/index.ts +155 -57
- package/src/frontend/teams/tools.ts +4 -6
- package/src/frontend/telegram/actions.ts +358 -82
- package/src/frontend/telegram/admin.ts +162 -72
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +37 -21
- package/src/frontend/telegram/formatting.ts +2 -4
- package/src/frontend/telegram/handlers.ts +262 -66
- package/src/frontend/telegram/index.ts +39 -14
- package/src/frontend/telegram/middleware.ts +14 -4
- package/src/frontend/telegram/userbot.ts +16 -4
- package/src/frontend/terminal/renderer.ts +1 -4
- package/src/index.ts +28 -4
- package/src/storage/chat-settings.ts +32 -9
- package/src/storage/cron-store.ts +53 -11
- package/src/storage/daily-log.ts +72 -19
- package/src/storage/history.ts +39 -21
- package/src/storage/media-index.ts +37 -12
- package/src/storage/sessions.ts +3 -2
- package/src/util/cleanup-registry.ts +34 -0
- package/src/util/config.ts +85 -23
- package/src/util/log.ts +47 -17
- package/src/util/paths.ts +10 -0
- package/src/util/time.ts +29 -6
- package/src/util/watchdog.ts +5 -1
- package/src/util/workspace.ts +51 -10
|
@@ -57,19 +57,35 @@ export async function handleSharedAction(
|
|
|
57
57
|
|
|
58
58
|
case "search_history": {
|
|
59
59
|
const limit = Math.min(100, Number(body.limit ?? 20));
|
|
60
|
-
return {
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
text: searchHistory(String(chatId), String(body.query ?? ""), limit),
|
|
63
|
+
};
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
case "get_user_messages": {
|
|
64
67
|
const limit = Math.min(50, Number(body.limit ?? 20));
|
|
65
|
-
return {
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
text: getMessagesByUser(
|
|
71
|
+
String(chatId),
|
|
72
|
+
String(body.user_name ?? ""),
|
|
73
|
+
limit,
|
|
74
|
+
),
|
|
75
|
+
};
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
case "list_known_users":
|
|
69
79
|
return { ok: true, text: getKnownUsers(String(chatId)) };
|
|
70
80
|
|
|
71
81
|
case "list_media":
|
|
72
|
-
return {
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
text: formatMediaIndex(
|
|
85
|
+
String(chatId),
|
|
86
|
+
Math.min(20, Number(body.limit ?? 10)),
|
|
87
|
+
),
|
|
88
|
+
};
|
|
73
89
|
|
|
74
90
|
// ── Web search (SearXNG) ────────────────────────────────────────────
|
|
75
91
|
|
|
@@ -91,14 +107,34 @@ export async function handleSharedAction(
|
|
|
91
107
|
try {
|
|
92
108
|
const resp = await fetch(
|
|
93
109
|
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${limit}`,
|
|
94
|
-
{
|
|
110
|
+
{
|
|
111
|
+
signal: AbortSignal.timeout(8_000),
|
|
112
|
+
headers: {
|
|
113
|
+
"X-Subscription-Token": braveKey,
|
|
114
|
+
Accept: "application/json",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
95
117
|
);
|
|
96
118
|
if (resp.ok) {
|
|
97
|
-
const data = await resp.json() as {
|
|
98
|
-
|
|
119
|
+
const data = (await resp.json()) as {
|
|
120
|
+
web?: {
|
|
121
|
+
results?: Array<{
|
|
122
|
+
title: string;
|
|
123
|
+
url: string;
|
|
124
|
+
description: string;
|
|
125
|
+
}>;
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
results = (data.web?.results ?? []).map((r) => ({
|
|
129
|
+
title: r.title,
|
|
130
|
+
url: r.url,
|
|
131
|
+
snippet: r.description ?? "",
|
|
132
|
+
}));
|
|
99
133
|
provider = "Brave";
|
|
100
134
|
}
|
|
101
|
-
} catch {
|
|
135
|
+
} catch {
|
|
136
|
+
/* fall through to SearXNG */
|
|
137
|
+
}
|
|
102
138
|
}
|
|
103
139
|
|
|
104
140
|
// SearXNG fallback
|
|
@@ -109,18 +145,33 @@ export async function handleSharedAction(
|
|
|
109
145
|
{ signal: AbortSignal.timeout(10_000) },
|
|
110
146
|
);
|
|
111
147
|
if (resp.ok) {
|
|
112
|
-
const data = await resp.json() as {
|
|
113
|
-
|
|
148
|
+
const data = (await resp.json()) as {
|
|
149
|
+
results?: Array<{ title: string; url: string; content: string }>;
|
|
150
|
+
};
|
|
151
|
+
results = (data.results ?? []).slice(0, limit).map((r) => ({
|
|
152
|
+
title: r.title,
|
|
153
|
+
url: r.url,
|
|
154
|
+
snippet: r.content ?? "",
|
|
155
|
+
}));
|
|
114
156
|
provider = "SearXNG";
|
|
115
157
|
}
|
|
116
|
-
} catch {
|
|
158
|
+
} catch {
|
|
159
|
+
/* both failed */
|
|
160
|
+
}
|
|
117
161
|
}
|
|
118
162
|
|
|
119
|
-
if (results.length === 0)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
163
|
+
if (results.length === 0)
|
|
164
|
+
return { ok: true, text: `No results for "${query}".` };
|
|
165
|
+
const formatted = results
|
|
166
|
+
.map(
|
|
167
|
+
(r, i) =>
|
|
168
|
+
`${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet.slice(0, 200)}`,
|
|
169
|
+
)
|
|
170
|
+
.join("\n\n");
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
text: `Search results for "${query}" (via ${provider}):\n\n${formatted}`,
|
|
174
|
+
};
|
|
124
175
|
}
|
|
125
176
|
|
|
126
177
|
// ── Web fetch ────────────────────────────────────────────────────────
|
|
@@ -145,46 +196,92 @@ export async function handleSharedAction(
|
|
|
145
196
|
if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
|
|
146
197
|
const ct = resp.headers.get("content-type") ?? "";
|
|
147
198
|
|
|
199
|
+
// Reject oversized responses before downloading the body.
|
|
200
|
+
// The Content-Length header is advisory but saves bandwidth when present.
|
|
201
|
+
const MAX_BYTES = 20 * 1024 * 1024; // 20 MB
|
|
202
|
+
const contentLength = resp.headers.get("content-length");
|
|
203
|
+
if (contentLength && Number(contentLength) > MAX_BYTES) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: `File too large (${(Number(contentLength) / 1024 / 1024).toFixed(0)}MB, max 20MB)`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
148
210
|
// Binary content: download and save to workspace
|
|
149
211
|
const mimeType = ct.split(";")[0].trim().toLowerCase();
|
|
150
|
-
const isText =
|
|
212
|
+
const isText =
|
|
213
|
+
mimeType.startsWith("text/") || mimeType === "application/json";
|
|
151
214
|
if (!isText) {
|
|
152
215
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
153
|
-
if (buffer.length >
|
|
154
|
-
|
|
216
|
+
if (buffer.length > MAX_BYTES)
|
|
217
|
+
return { ok: false, error: "File too large (max 20MB)" };
|
|
218
|
+
if (buffer.length === 0)
|
|
219
|
+
return { ok: false, error: "Empty response (0 bytes)" };
|
|
155
220
|
|
|
156
221
|
// Validate magic bytes — prevent saving HTML error pages as images
|
|
157
222
|
// (servers can return error pages with image content-type headers)
|
|
158
223
|
const magic = buffer.subarray(0, 16);
|
|
159
224
|
const isRealImage =
|
|
160
|
-
(magic[0] ===
|
|
161
|
-
(magic[0] === 0x89 &&
|
|
225
|
+
(magic[0] === 0xff && magic[1] === 0xd8) || // JPEG
|
|
226
|
+
(magic[0] === 0x89 &&
|
|
227
|
+
magic[1] === 0x50 &&
|
|
228
|
+
magic[2] === 0x4e &&
|
|
229
|
+
magic[3] === 0x47) || // PNG
|
|
162
230
|
(magic[0] === 0x47 && magic[1] === 0x49 && magic[2] === 0x46) || // GIF
|
|
163
|
-
(magic[0] === 0x52 &&
|
|
164
|
-
|
|
231
|
+
(magic[0] === 0x52 &&
|
|
232
|
+
magic[1] === 0x49 &&
|
|
233
|
+
magic[2] === 0x46 &&
|
|
234
|
+
magic[3] === 0x46 &&
|
|
235
|
+
magic[8] === 0x57 &&
|
|
236
|
+
magic[9] === 0x45 &&
|
|
237
|
+
magic[10] === 0x42 &&
|
|
238
|
+
magic[11] === 0x50); // WebP
|
|
165
239
|
|
|
166
240
|
// If content-type says image but bytes say otherwise, treat as text
|
|
167
241
|
if (ct.startsWith("image/") && !isRealImage) {
|
|
168
242
|
const text = extractText(buffer.toString("utf-8"), 500);
|
|
169
|
-
return {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
error: `Server returned an error page instead of an image. Content: ${text}`,
|
|
246
|
+
};
|
|
170
247
|
}
|
|
171
248
|
|
|
172
249
|
const ext = isRealImage
|
|
173
|
-
?
|
|
174
|
-
|
|
250
|
+
? magic[0] === 0xff
|
|
251
|
+
? "jpg"
|
|
252
|
+
: magic[0] === 0x89
|
|
253
|
+
? "png"
|
|
254
|
+
: magic[0] === 0x47
|
|
255
|
+
? "gif"
|
|
256
|
+
: "webp"
|
|
257
|
+
: ct.includes("pdf")
|
|
258
|
+
? "pdf"
|
|
259
|
+
: ct.includes("zip")
|
|
260
|
+
? "zip"
|
|
261
|
+
: "bin";
|
|
175
262
|
const uploadsDir = dirs.uploads;
|
|
176
|
-
if (!existsSync(uploadsDir))
|
|
263
|
+
if (!existsSync(uploadsDir))
|
|
264
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
177
265
|
const filePath = resolve(uploadsDir, `${Date.now()}-fetched.${ext}`);
|
|
178
266
|
writeFileSync(filePath, buffer);
|
|
179
|
-
const typeLabel = isRealImage
|
|
180
|
-
|
|
267
|
+
const typeLabel = isRealImage
|
|
268
|
+
? "image"
|
|
269
|
+
: (ct.split("/")[1]?.split(";")[0] ?? "file");
|
|
270
|
+
return {
|
|
271
|
+
ok: true,
|
|
272
|
+
text: `Downloaded ${typeLabel} (${(buffer.length / 1024).toFixed(0)}KB) to: ${filePath}\nRead it with the Read tool or send it with send(type="file", file_path="${filePath}").`,
|
|
273
|
+
};
|
|
181
274
|
}
|
|
182
275
|
const raw = await resp.text();
|
|
183
276
|
const text = extractText(raw);
|
|
184
|
-
if (text.length < 20)
|
|
277
|
+
if (text.length < 20)
|
|
278
|
+
return { ok: true, text: "(Page has no readable content)" };
|
|
185
279
|
return { ok: true, text };
|
|
186
280
|
} catch (err) {
|
|
187
|
-
return {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
error: `Fetch failed: ${err instanceof Error ? err.message : err}`,
|
|
284
|
+
};
|
|
188
285
|
}
|
|
189
286
|
}
|
|
190
287
|
|
|
@@ -199,33 +296,62 @@ export async function handleSharedAction(
|
|
|
199
296
|
|
|
200
297
|
if (!schedule) return { ok: false, error: "Missing schedule expression" };
|
|
201
298
|
if (!content) return { ok: false, error: "Missing content" };
|
|
202
|
-
if (content.length > 10_000)
|
|
299
|
+
if (content.length > 10_000)
|
|
300
|
+
return { ok: false, error: "Content too long (max 10,000 chars)" };
|
|
203
301
|
|
|
204
302
|
const validation = validateCronExpression(schedule, timezone);
|
|
205
|
-
if (!validation.valid)
|
|
303
|
+
if (!validation.valid)
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
error: `Invalid cron expression: ${validation.error}`,
|
|
307
|
+
};
|
|
206
308
|
|
|
207
309
|
const id = generateCronId();
|
|
208
|
-
addCronJob({
|
|
310
|
+
addCronJob({
|
|
311
|
+
id,
|
|
312
|
+
chatId: String(chatId),
|
|
313
|
+
schedule,
|
|
314
|
+
type: jobType,
|
|
315
|
+
content,
|
|
316
|
+
name,
|
|
317
|
+
enabled: true,
|
|
318
|
+
createdAt: Date.now(),
|
|
319
|
+
runCount: 0,
|
|
320
|
+
timezone,
|
|
321
|
+
});
|
|
209
322
|
log("gateway", `create_cron_job: "${name}" [${schedule}]`);
|
|
210
|
-
return {
|
|
323
|
+
return {
|
|
324
|
+
ok: true,
|
|
325
|
+
text: `Created cron job "${name}" (id: ${id})\nSchedule: ${schedule}\nType: ${jobType}\nNext run: ${validation.next ?? "unknown"}`,
|
|
326
|
+
};
|
|
211
327
|
}
|
|
212
328
|
|
|
213
329
|
case "list_cron_jobs": {
|
|
214
330
|
const jobs = getCronJobsForChat(String(chatId));
|
|
215
|
-
if (jobs.length === 0)
|
|
331
|
+
if (jobs.length === 0)
|
|
332
|
+
return { ok: true, text: "No cron jobs in this chat." };
|
|
216
333
|
const lines = jobs.map((j) => {
|
|
217
334
|
const status = j.enabled ? "enabled" : "disabled";
|
|
218
|
-
const lastRun = j.lastRunAt
|
|
335
|
+
const lastRun = j.lastRunAt
|
|
336
|
+
? new Date(j.lastRunAt).toISOString().slice(0, 16).replace("T", " ")
|
|
337
|
+
: "never";
|
|
219
338
|
const v = validateCronExpression(j.schedule, j.timezone);
|
|
220
|
-
const nextRun = v.next
|
|
339
|
+
const nextRun = v.next
|
|
340
|
+
? new Date(v.next).toISOString().slice(0, 16).replace("T", " ")
|
|
341
|
+
: "unknown";
|
|
221
342
|
return [
|
|
222
|
-
`- ${j.name} (${status})`,
|
|
343
|
+
`- ${j.name} (${status})`,
|
|
344
|
+
` ID: ${j.id}`,
|
|
223
345
|
` Schedule: ${j.schedule}${j.timezone ? ` (${j.timezone})` : ""}`,
|
|
224
|
-
` Type: ${j.type}`,
|
|
346
|
+
` Type: ${j.type}`,
|
|
347
|
+
` Content: ${j.content.slice(0, 100)}${j.content.length > 100 ? "..." : ""}`,
|
|
225
348
|
` Runs: ${j.runCount} | Last: ${lastRun} | Next: ${nextRun}`,
|
|
226
349
|
].join("\n");
|
|
227
350
|
});
|
|
228
|
-
return {
|
|
351
|
+
return {
|
|
352
|
+
ok: true,
|
|
353
|
+
text: `Cron jobs (${jobs.length}):\n\n${lines.join("\n\n")}`,
|
|
354
|
+
};
|
|
229
355
|
}
|
|
230
356
|
|
|
231
357
|
case "edit_cron_job": {
|
|
@@ -233,22 +359,31 @@ export async function handleSharedAction(
|
|
|
233
359
|
if (!jobId) return { ok: false, error: "Missing job_id" };
|
|
234
360
|
const job = getCronJob(jobId);
|
|
235
361
|
if (!job) return { ok: false, error: `Job ${jobId} not found` };
|
|
236
|
-
if (job.chatId !== String(chatId))
|
|
362
|
+
if (job.chatId !== String(chatId))
|
|
363
|
+
return { ok: false, error: "Job belongs to a different chat" };
|
|
237
364
|
|
|
238
365
|
const updates: Record<string, unknown> = {};
|
|
239
366
|
if (body.name !== undefined) updates.name = String(body.name);
|
|
240
367
|
if (body.content !== undefined) updates.content = String(body.content);
|
|
241
368
|
if (body.enabled !== undefined) updates.enabled = Boolean(body.enabled);
|
|
242
369
|
if (body.type !== undefined) updates.type = String(body.type);
|
|
243
|
-
if (body.timezone !== undefined)
|
|
370
|
+
if (body.timezone !== undefined)
|
|
371
|
+
updates.timezone = body.timezone ? String(body.timezone) : undefined;
|
|
244
372
|
if (body.schedule !== undefined) {
|
|
245
|
-
const v = validateCronExpression(
|
|
246
|
-
|
|
373
|
+
const v = validateCronExpression(
|
|
374
|
+
String(body.schedule),
|
|
375
|
+
(updates.timezone as string | undefined) ?? job.timezone,
|
|
376
|
+
);
|
|
377
|
+
if (!v.valid)
|
|
378
|
+
return { ok: false, error: `Invalid cron expression: ${v.error}` };
|
|
247
379
|
updates.schedule = String(body.schedule);
|
|
248
380
|
}
|
|
249
381
|
|
|
250
382
|
const updated = updateCronJob(jobId, updates);
|
|
251
|
-
return {
|
|
383
|
+
return {
|
|
384
|
+
ok: true,
|
|
385
|
+
text: `Updated job "${updated?.name ?? jobId}". Fields changed: ${Object.keys(updates).join(", ")}`,
|
|
386
|
+
};
|
|
252
387
|
}
|
|
253
388
|
|
|
254
389
|
case "delete_cron_job": {
|
|
@@ -256,7 +391,8 @@ export async function handleSharedAction(
|
|
|
256
391
|
if (!jobId) return { ok: false, error: "Missing job_id" };
|
|
257
392
|
const job = getCronJob(jobId);
|
|
258
393
|
if (!job) return { ok: false, error: `Job ${jobId} not found` };
|
|
259
|
-
if (job.chatId !== String(chatId))
|
|
394
|
+
if (job.chatId !== String(chatId))
|
|
395
|
+
return { ok: false, error: "Job belongs to a different chat" };
|
|
260
396
|
deleteCronJob(jobId);
|
|
261
397
|
return { ok: true, text: `Deleted cron job "${job.name}" (${jobId})` };
|
|
262
398
|
}
|
package/src/core/gateway.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type IncomingMessage,
|
|
14
14
|
type ServerResponse,
|
|
15
15
|
} from "node:http";
|
|
16
|
+
import pRetry, { AbortError } from "p-retry";
|
|
16
17
|
import { classify } from "./errors.js";
|
|
17
18
|
import { getActiveCount } from "./dispatcher.js";
|
|
18
19
|
import { getHealthStatus } from "../util/watchdog.js";
|
|
@@ -24,27 +25,51 @@ import type { FrontendActionHandler } from "./types.js";
|
|
|
24
25
|
|
|
25
26
|
// ── Per-chat context state ───────────────────────────────────────────────────
|
|
26
27
|
|
|
27
|
-
type ChatContext = {
|
|
28
|
+
type ChatContext = {
|
|
29
|
+
refCount: number;
|
|
30
|
+
messagesSent: number;
|
|
31
|
+
stringId?: string;
|
|
32
|
+
};
|
|
28
33
|
|
|
29
34
|
// ── Retry helper (stateless — standalone export) ─────────────────────────────
|
|
30
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Retry a function up to 3 times with classified error inspection.
|
|
38
|
+
* Non-retryable errors (auth, bad_request, context_length) are thrown immediately.
|
|
39
|
+
* Uses p-retry for proper exponential backoff with jitter.
|
|
40
|
+
*/
|
|
31
41
|
export async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
return pRetry(
|
|
43
|
+
async (attempt) => {
|
|
44
|
+
try {
|
|
45
|
+
return await fn();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const classified = classify(err);
|
|
48
|
+
if (!classified.retryable) {
|
|
49
|
+
// Wrap in AbortError to prevent further retries
|
|
50
|
+
throw new AbortError(classified);
|
|
51
|
+
}
|
|
52
|
+
const delayMs =
|
|
53
|
+
classified.retryAfterMs ?? 1000 * Math.pow(2, attempt - 1);
|
|
54
|
+
log(
|
|
55
|
+
"gateway",
|
|
56
|
+
`Retry ${attempt}/3 (${classified.reason}) after ${delayMs}ms`,
|
|
57
|
+
);
|
|
58
|
+
throw classified; // rethrow to trigger p-retry delay
|
|
44
59
|
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
retries: 2, // 3 total attempts
|
|
63
|
+
minTimeout: 1000,
|
|
64
|
+
maxTimeout: 60_000,
|
|
65
|
+
factor: 2,
|
|
66
|
+
onFailedAttempt: (err) => {
|
|
67
|
+
if (err.retriesLeft === 0) {
|
|
68
|
+
logError("gateway", `All retries exhausted: ${err.error.message}`);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
);
|
|
48
73
|
}
|
|
49
74
|
|
|
50
75
|
// ── Gateway class ────────────────────────────────────────────────────────────
|
|
@@ -57,7 +82,7 @@ export class Gateway {
|
|
|
57
82
|
|
|
58
83
|
// ── Frontend handler registration ────────────────────────────────────────
|
|
59
84
|
|
|
60
|
-
setFrontendHandler(handler: FrontendActionHandler): void {
|
|
85
|
+
setFrontendHandler(handler: FrontendActionHandler | null): void {
|
|
61
86
|
this.frontendHandler = handler;
|
|
62
87
|
}
|
|
63
88
|
|
|
@@ -67,10 +92,16 @@ export class Gateway {
|
|
|
67
92
|
const ctx = this.chatContexts.get(chatId);
|
|
68
93
|
if (ctx) {
|
|
69
94
|
ctx.refCount++;
|
|
70
|
-
log(
|
|
95
|
+
log(
|
|
96
|
+
"gateway",
|
|
97
|
+
`Context ref++ for chat ${chatId} (refCount=${ctx.refCount})`,
|
|
98
|
+
);
|
|
71
99
|
} else {
|
|
72
100
|
this.chatContexts.set(chatId, { refCount: 1, messagesSent: 0, stringId });
|
|
73
|
-
log(
|
|
101
|
+
log(
|
|
102
|
+
"gateway",
|
|
103
|
+
`Context acquired for chat ${chatId}${stringId ? ` (${stringId})` : ""}`,
|
|
104
|
+
);
|
|
74
105
|
}
|
|
75
106
|
}
|
|
76
107
|
|
|
@@ -87,7 +118,9 @@ export class Gateway {
|
|
|
87
118
|
if (chatId === undefined) return;
|
|
88
119
|
const parsed = typeof chatId === "number" ? chatId : Number(chatId);
|
|
89
120
|
// For non-numeric IDs (e.g. Teams "19:abc..."), Number() returns NaN — look up by string
|
|
90
|
-
const numId = !isNaN(parsed)
|
|
121
|
+
const numId = !isNaN(parsed)
|
|
122
|
+
? parsed
|
|
123
|
+
: this.findContextByStringId(String(chatId));
|
|
91
124
|
if (numId === null) return;
|
|
92
125
|
const ctx = this.chatContexts.get(numId);
|
|
93
126
|
if (!ctx) return;
|
|
@@ -128,9 +161,10 @@ export class Gateway {
|
|
|
128
161
|
// then fall back to searching active contexts.
|
|
129
162
|
const rawChatId = body._chatId ? String(body._chatId) : "";
|
|
130
163
|
const numericId = Number(rawChatId);
|
|
131
|
-
const chatId =
|
|
132
|
-
|
|
133
|
-
|
|
164
|
+
const chatId =
|
|
165
|
+
!isNaN(numericId) && this.chatContexts.has(numericId)
|
|
166
|
+
? numericId
|
|
167
|
+
: this.findContextByStringId(rawChatId);
|
|
134
168
|
if (!chatId) {
|
|
135
169
|
return { ok: false, error: "No active chat context" };
|
|
136
170
|
}
|
|
@@ -153,14 +187,20 @@ export class Gateway {
|
|
|
153
187
|
// Try plugin actions (loaded from external plugin packages)
|
|
154
188
|
const pluginResult = await handlePluginAction(body, String(chatId));
|
|
155
189
|
if (pluginResult) {
|
|
156
|
-
logDebug(
|
|
190
|
+
logDebug(
|
|
191
|
+
"gateway",
|
|
192
|
+
`${action} chat=${chatId} ${Date.now() - t0}ms (plugin)`,
|
|
193
|
+
);
|
|
157
194
|
return pluginResult;
|
|
158
195
|
}
|
|
159
196
|
|
|
160
197
|
// Shared actions last — provides in-memory fallbacks for history, cron, etc.
|
|
161
198
|
const shared = await handleSharedAction(body, chatId);
|
|
162
199
|
if (shared) {
|
|
163
|
-
logDebug(
|
|
200
|
+
logDebug(
|
|
201
|
+
"gateway",
|
|
202
|
+
`${action} chat=${chatId} ${Date.now() - t0}ms (shared)`,
|
|
203
|
+
);
|
|
164
204
|
return shared;
|
|
165
205
|
}
|
|
166
206
|
|
|
@@ -182,19 +222,22 @@ export class Gateway {
|
|
|
182
222
|
if (req.method === "GET" && req.url === "/health") {
|
|
183
223
|
const w = getHealthStatus();
|
|
184
224
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
185
|
-
res.end(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
225
|
+
res.end(
|
|
226
|
+
JSON.stringify({
|
|
227
|
+
ok: w.healthy,
|
|
228
|
+
uptime: Math.round(process.uptime()),
|
|
229
|
+
memory: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
|
230
|
+
bridge: { activeChats: this.chatContexts.size },
|
|
231
|
+
queue: getActiveCount(),
|
|
232
|
+
sessions: getActiveSessionCount(),
|
|
233
|
+
messages: w.totalMessagesProcessed,
|
|
234
|
+
errors: w.recentErrorCount,
|
|
235
|
+
lastActivity:
|
|
236
|
+
w.msSinceLastMessage < 60000
|
|
237
|
+
? "just now"
|
|
238
|
+
: `${Math.round(w.msSinceLastMessage / 60000)}m ago`,
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
198
241
|
return;
|
|
199
242
|
}
|
|
200
243
|
|
|
@@ -216,9 +259,11 @@ export class Gateway {
|
|
|
216
259
|
return;
|
|
217
260
|
}
|
|
218
261
|
const result = await this.handleAction(body);
|
|
262
|
+
const json = JSON.stringify(result);
|
|
219
263
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
220
|
-
res.end(
|
|
264
|
+
res.end(json);
|
|
221
265
|
} catch (err) {
|
|
266
|
+
if (res.headersSent) return;
|
|
222
267
|
const msg = err instanceof Error ? err.message : String(err);
|
|
223
268
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
224
269
|
res.end(JSON.stringify({ ok: false, error: msg }));
|
|
@@ -251,8 +296,15 @@ export class Gateway {
|
|
|
251
296
|
|
|
252
297
|
async stop(): Promise<void> {
|
|
253
298
|
return new Promise((resolve) => {
|
|
254
|
-
if (!this.server) {
|
|
255
|
-
|
|
299
|
+
if (!this.server) {
|
|
300
|
+
resolve();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
this.server.close(() => {
|
|
304
|
+
this.server = null;
|
|
305
|
+
this.port = 0;
|
|
306
|
+
resolve();
|
|
307
|
+
});
|
|
256
308
|
});
|
|
257
309
|
}
|
|
258
310
|
}
|