talon-agent 1.0.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.
Files changed (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,1008 @@
1
+ /**
2
+ * Message handlers extracted from index.ts.
3
+ * Each handler processes a specific message type and delegates to processAndReply.
4
+ */
5
+
6
+ import type { Bot, Context } from "grammy";
7
+ import type { TalonConfig } from "../../util/config.js";
8
+ import {
9
+ splitMessage,
10
+ markdownToTelegramHtml,
11
+ escapeHtml,
12
+ } from "./formatting.js";
13
+ import { execute } from "../../core/dispatcher.js";
14
+ import { classify, friendlyMessage } from "../../core/errors.js";
15
+ import {
16
+ enrichDMPrompt,
17
+ enrichGroupPrompt,
18
+ } from "../../core/prompt-builder.js";
19
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
20
+ import { resolve } from "node:path";
21
+ import { appendDailyLog, appendDailyLogResponse } from "../../storage/daily-log.js";
22
+ import { setMessageFilePath } from "../../storage/history.js";
23
+ import { addMedia } from "../../storage/media-index.js";
24
+ import { recordMessageProcessed, recordError } from "../../util/watchdog.js";
25
+ import { log, logError, logWarn } from "../../util/log.js";
26
+
27
+ // ── First-time DM user tracking ──────────────────────────────────────────────
28
+
29
+ const knownDmUsers = new Set<number>();
30
+ const KNOWN_DM_USERS_CAP = 10_000;
31
+
32
+ function trackDmUser(
33
+ senderId: number,
34
+ senderName: string,
35
+ senderUsername?: string,
36
+ ): void {
37
+ if (knownDmUsers.has(senderId)) return;
38
+ // Evict oldest 10% when cap reached (Set maintains insertion order)
39
+ if (knownDmUsers.size >= KNOWN_DM_USERS_CAP) {
40
+ const evictCount = Math.floor(KNOWN_DM_USERS_CAP * 0.1);
41
+ const iter = knownDmUsers.values();
42
+ for (let i = 0; i < evictCount; i++) {
43
+ const val = iter.next();
44
+ if (val.done) break;
45
+ knownDmUsers.delete(val.value);
46
+ }
47
+ }
48
+ knownDmUsers.add(senderId);
49
+ const tag = senderUsername ? ` (@${senderUsername})` : "";
50
+ log("users", `New DM user: ${senderName}${tag} [id:${senderId}]`);
51
+ appendDailyLog("System", `New DM user: ${senderName}${tag} [id:${senderId}]`);
52
+ }
53
+
54
+ // ── Shared utilities ─────────────────────────────────────────────────────────
55
+
56
+ export function shouldHandleInGroup(ctx: Context): boolean {
57
+ if (!ctx.chat || !ctx.message) return false;
58
+ const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
59
+ if (!isGroup) return true;
60
+ const text = ctx.message.text || ctx.message.caption || "";
61
+ const botUser = ctx.me.username;
62
+ // Word-boundary match — @botname must not be followed by alphanumeric/underscore
63
+ const mentioned =
64
+ botUser &&
65
+ new RegExp(`@${botUser}(?![a-zA-Z0-9_])`, "i").test(text);
66
+ const repliedToBot = ctx.message.reply_to_message?.from?.id === ctx.me.id;
67
+ return !!(mentioned || repliedToBot);
68
+ }
69
+
70
+ export function getSenderName(
71
+ from: { first_name?: string; last_name?: string } | undefined,
72
+ ): string {
73
+ return (
74
+ [from?.first_name, from?.last_name].filter(Boolean).join(" ") || "User"
75
+ );
76
+ }
77
+
78
+ export function getReplyContext(
79
+ replyMsg:
80
+ | {
81
+ message_id?: number;
82
+ from?: { id: number; first_name?: string; last_name?: string };
83
+ text?: string;
84
+ caption?: string;
85
+ photo?: unknown[];
86
+ document?: unknown;
87
+ video?: unknown;
88
+ voice?: unknown;
89
+ audio?: unknown;
90
+ sticker?: unknown;
91
+ animation?: unknown;
92
+ }
93
+ | undefined,
94
+ botId: number,
95
+ ): string {
96
+ if (!replyMsg) return "";
97
+
98
+ const author = replyMsg.from?.id === botId
99
+ ? "bot"
100
+ : [replyMsg.from?.first_name, replyMsg.from?.last_name]
101
+ .filter(Boolean)
102
+ .join(" ") || "User";
103
+ const text = replyMsg.text || replyMsg.caption || "";
104
+ const msgIdTag = replyMsg.message_id ? ` msg_id:${replyMsg.message_id}` : "";
105
+
106
+ // Detect media type
107
+ const mediaType = replyMsg.photo ? "photo"
108
+ : replyMsg.video ? "video"
109
+ : replyMsg.document ? "document"
110
+ : replyMsg.voice ? "voice"
111
+ : replyMsg.audio ? "audio"
112
+ : replyMsg.sticker ? "sticker"
113
+ : replyMsg.animation ? "animation"
114
+ : null;
115
+ const mediaPart = mediaType ? ` [${mediaType}]` : "";
116
+
117
+ // Build context — always include if there's a message_id (even if no text)
118
+ const textPart = text ? `: "${text.slice(0, 500)}"` : "";
119
+
120
+ if (!textPart && !mediaPart && !msgIdTag) return "";
121
+
122
+ return `[Replying to ${author}${textPart}${mediaPart}${msgIdTag}]\n\n`;
123
+ }
124
+
125
+ /**
126
+ * If the replied-to message contains a photo, download it and return a prompt
127
+ * line pointing to the saved file so Claude can see it. Returns "" if no photo.
128
+ */
129
+ async function downloadReplyPhoto(
130
+ replyMsg: { photo?: { file_id: string; file_unique_id: string; width?: number; height?: number }[] } | undefined,
131
+ bot: Bot,
132
+ config: TalonConfig,
133
+ ): Promise<string> {
134
+ if (!replyMsg?.photo?.length) return "";
135
+ try {
136
+ // Pick the largest photo size (last in array)
137
+ const bestPhoto = replyMsg.photo[replyMsg.photo.length - 1];
138
+ const savedPath = await downloadTelegramFile(
139
+ bot,
140
+ config,
141
+ bestPhoto.file_id,
142
+ `reply_photo_${bestPhoto.file_unique_id}.jpg`,
143
+ );
144
+ return `[Replied-to message contains a photo saved to: ${savedPath} — read it to view]\n`;
145
+ } catch (err) {
146
+ logWarn("bot", `Failed to download reply photo: ${err instanceof Error ? err.message : err}`);
147
+ return "";
148
+ }
149
+ }
150
+
151
+ export function getForwardContext(msg: {
152
+ forward_origin?: {
153
+ type: string;
154
+ sender_user?: { first_name?: string; last_name?: string };
155
+ sender_user_name?: string;
156
+ chat?: { title?: string };
157
+ };
158
+ }): string {
159
+ const origin = msg.forward_origin;
160
+ if (!origin) return "";
161
+ let from = "someone";
162
+ if (origin.type === "user" && origin.sender_user) {
163
+ from = [origin.sender_user.first_name, origin.sender_user.last_name]
164
+ .filter(Boolean)
165
+ .join(" ");
166
+ } else if (origin.type === "hidden_user" && origin.sender_user_name) {
167
+ from = origin.sender_user_name;
168
+ } else if (
169
+ (origin.type === "channel" || origin.type === "chat") &&
170
+ origin.chat
171
+ ) {
172
+ from = origin.chat.title || "a chat";
173
+ }
174
+ return `[Forwarded from ${from}]\n`;
175
+ }
176
+
177
+ async function downloadTelegramFile(
178
+ bot: Bot,
179
+ config: TalonConfig,
180
+ fileId: string,
181
+ fileName: string,
182
+ ): Promise<string> {
183
+ const file = await bot.api.getFile(fileId);
184
+ if (!file.file_path) throw new Error("Could not get file path from Telegram");
185
+
186
+ const url = `https://api.telegram.org/file/bot${config.botToken}/${file.file_path}`;
187
+ const resp = await fetch(url);
188
+ if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
189
+
190
+ // Guard against excessively large files (50MB limit)
191
+ const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
192
+ const contentLength = resp.headers.get("content-length");
193
+ if (contentLength && parseInt(contentLength, 10) > MAX_DOWNLOAD_BYTES) {
194
+ throw new Error(`File too large (${Math.round(parseInt(contentLength, 10) / 1024 / 1024)}MB, max 50MB)`);
195
+ }
196
+
197
+ const buffer = Buffer.from(await resp.arrayBuffer());
198
+ if (buffer.length === 0) throw new Error("Downloaded file is empty (0 bytes)");
199
+
200
+ // Validate image files — prevent saving HTML/garbage as .jpg/.png
201
+ // (corrupt "images" poison the Claude session permanently on resume)
202
+ const imageExts = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
203
+ const isImageExt = imageExts.some((ext) => fileName.toLowerCase().endsWith(ext));
204
+ if (isImageExt) {
205
+ const m = buffer.subarray(0, 16);
206
+ const validImage =
207
+ (m[0] === 0xFF && m[1] === 0xD8) || // JPEG
208
+ (m[0] === 0x89 && m[1] === 0x50 && m[2] === 0x4E && m[3] === 0x47) || // PNG
209
+ (m[0] === 0x47 && m[1] === 0x49 && m[2] === 0x46) || // GIF
210
+ (m[0] === 0x52 && m[1] === 0x49 && m[2] === 0x46 && m[3] === 0x46 &&
211
+ m[8] === 0x57 && m[9] === 0x45 && m[10] === 0x42 && m[11] === 0x50); // WebP
212
+ if (!validImage) {
213
+ throw new Error(`File "${fileName}" has image extension but invalid content — not saving to prevent session corruption`);
214
+ }
215
+ }
216
+
217
+ const uploadsDir = resolve(config.workspace, "uploads");
218
+ if (!existsSync(uploadsDir)) mkdirSync(uploadsDir, { recursive: true });
219
+
220
+ const safeName = `${Date.now()}-${fileName.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
221
+ const destPath = resolve(uploadsDir, safeName);
222
+ // Prevent path traversal — ensure resolved path stays within uploads dir
223
+ if (!destPath.startsWith(resolve(uploadsDir))) {
224
+ throw new Error("Invalid file name");
225
+ }
226
+ writeFileSync(destPath, buffer);
227
+ return destPath;
228
+ }
229
+
230
+ // ── Message queue (debounce rapid-fire messages per chat) ─────────────────────
231
+
232
+ type QueuedMessage = {
233
+ prompt: string;
234
+ replyToId: number;
235
+ messageId: number;
236
+ senderName: string;
237
+ senderUsername?: string;
238
+ senderId?: number;
239
+ isGroup: boolean;
240
+ chatTitle?: string;
241
+ };
242
+
243
+ const messageQueues = new Map<
244
+ string,
245
+ {
246
+ messages: QueuedMessage[];
247
+ timer: ReturnType<typeof setTimeout>;
248
+ bot: Bot;
249
+ config: TalonConfig;
250
+ numericChatId: number;
251
+ queuedReactionMsgIds: number[];
252
+ }
253
+ >();
254
+
255
+ const DEBOUNCE_MS = 500;
256
+ const MAX_QUEUED_PER_CHAT = 20;
257
+
258
+ // ── Per-user rate limiting ──────────────────────────────────────────────────
259
+
260
+ const userMessageTimestamps = new Map<number, number[]>();
261
+ const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute window
262
+ const RATE_LIMIT_MAX_MESSAGES = 15; // max 15 messages per minute per user
263
+
264
+ function isUserRateLimited(senderId: number): boolean {
265
+ const now = Date.now();
266
+ let timestamps = userMessageTimestamps.get(senderId);
267
+ if (!timestamps) {
268
+ timestamps = [];
269
+ userMessageTimestamps.set(senderId, timestamps);
270
+ }
271
+
272
+ // Remove old entries outside the window
273
+ while (timestamps.length > 0 && timestamps[0] < now - RATE_LIMIT_WINDOW_MS) {
274
+ timestamps.shift();
275
+ }
276
+
277
+ if (timestamps.length >= RATE_LIMIT_MAX_MESSAGES) {
278
+ return true;
279
+ }
280
+
281
+ timestamps.push(now);
282
+
283
+ // Evict stale entries — remove users who haven't messaged in 10+ minutes
284
+ if (userMessageTimestamps.size > 5_000) {
285
+ const cutoff = now - 10 * 60_000;
286
+ for (const [userId, ts] of userMessageTimestamps) {
287
+ if (ts.length === 0 || ts[ts.length - 1] < cutoff) {
288
+ userMessageTimestamps.delete(userId);
289
+ }
290
+ if (userMessageTimestamps.size <= 2_500) break; // evict down to half
291
+ }
292
+ }
293
+
294
+ return false;
295
+ }
296
+
297
+ /**
298
+ * Enqueue a message for processing. If another message arrives within DEBOUNCE_MS,
299
+ * they are concatenated and sent as a single query to avoid duplicate SDK spawns.
300
+ * Queued messages get a hourglass reaction to indicate they've been seen.
301
+ */
302
+ function enqueueMessage(
303
+ bot: Bot,
304
+ config: TalonConfig,
305
+ chatId: string,
306
+ numericChatId: number,
307
+ msg: QueuedMessage,
308
+ ): void {
309
+ const existing = messageQueues.get(chatId);
310
+ if (existing) {
311
+ if (existing.messages.length >= MAX_QUEUED_PER_CHAT) return; // drop excess
312
+ existing.messages.push(msg);
313
+ // Show hourglass reaction on the queued message to indicate it's been seen
314
+ bot.api
315
+ .setMessageReaction(numericChatId, msg.messageId, [
316
+ { type: "emoji", emoji: "\u23F3" as "\uD83D\uDC4D" /* grammY wants union type */ },
317
+ ])
318
+ .catch(() => {});
319
+ existing.queuedReactionMsgIds.push(msg.messageId);
320
+ clearTimeout(existing.timer);
321
+ existing.timer = setTimeout(() => flushQueue(chatId), DEBOUNCE_MS);
322
+ return;
323
+ }
324
+
325
+ const entry = {
326
+ messages: [msg],
327
+ timer: setTimeout(() => flushQueue(chatId), DEBOUNCE_MS),
328
+ bot,
329
+ config,
330
+ numericChatId,
331
+ queuedReactionMsgIds: [] as number[],
332
+ };
333
+ messageQueues.set(chatId, entry);
334
+ }
335
+
336
+ async function flushQueue(chatId: string): Promise<void> {
337
+ const entry = messageQueues.get(chatId);
338
+ if (!entry) return;
339
+ messageQueues.delete(chatId);
340
+
341
+ const { messages, bot, config, numericChatId, queuedReactionMsgIds } = entry;
342
+ if (messages.length === 0) return;
343
+
344
+ // Clear hourglass reactions on queued messages now that we're processing
345
+ for (const msgId of queuedReactionMsgIds) {
346
+ bot.api.setMessageReaction(numericChatId, msgId, []).catch((err) => {
347
+ logWarn("bot", `Failed to clear reaction on msg ${msgId}: ${err instanceof Error ? err.message : err}`);
348
+ });
349
+ }
350
+
351
+ // Use last message's metadata for reply context
352
+ const last = messages[messages.length - 1];
353
+
354
+ // Concatenate prompts (with newlines between them if multiple)
355
+ const combinedPrompt =
356
+ messages.length === 1
357
+ ? messages[0].prompt
358
+ : messages.map((m) => m.prompt).join("\n\n");
359
+
360
+ const chatContext = { chatTitle: last.chatTitle, username: last.senderUsername };
361
+ appendDailyLog(last.senderName, combinedPrompt, chatContext);
362
+
363
+ try {
364
+ await processAndReply({
365
+ bot, config, chatId, numericChatId,
366
+ replyToId: last.replyToId,
367
+ messageId: last.messageId,
368
+ prompt: combinedPrompt,
369
+ senderName: last.senderName,
370
+ isGroup: last.isGroup,
371
+ senderUsername: last.senderUsername,
372
+ senderId: last.senderId,
373
+ chatTitle: last.chatTitle,
374
+ });
375
+ recordMessageProcessed();
376
+ } catch (err) {
377
+ const classified = classify(err);
378
+ const chatType = last.isGroup ? "group" : "DM";
379
+ const promptPreview = combinedPrompt.slice(0, 100).replace(/\n/g, " ");
380
+ logError(
381
+ "bot",
382
+ `[${chatId}] [${chatType}] [${last.senderName}] ${classified.reason}: ${classified.message} | prompt: "${promptPreview}"`,
383
+ );
384
+ recordError(classified.message);
385
+
386
+ // Retry once for transient errors (rate_limit, overloaded, network)
387
+ if (classified.retryable) {
388
+ const delayMs = classified.retryAfterMs ?? 2000;
389
+ log("bot", `[${chatId}] Retrying after ${classified.reason} (${delayMs}ms)...`);
390
+ try {
391
+ await new Promise((r) => setTimeout(r, delayMs));
392
+ await processAndReply({
393
+ bot, config, chatId, numericChatId,
394
+ replyToId: last.replyToId,
395
+ messageId: last.messageId,
396
+ prompt: combinedPrompt,
397
+ senderName: last.senderName,
398
+ isGroup: last.isGroup,
399
+ senderUsername: last.senderUsername,
400
+ senderId: last.senderId,
401
+ chatTitle: last.chatTitle,
402
+ });
403
+ return;
404
+ } catch (retryErr) {
405
+ const retryClassified = classify(retryErr);
406
+ logError(
407
+ "bot",
408
+ `[${chatId}] [${chatType}] Retry failed: ${retryClassified.message}`,
409
+ );
410
+ await sendHtml(
411
+ bot,
412
+ numericChatId,
413
+ escapeHtml(friendlyMessage(retryClassified)),
414
+ last.replyToId,
415
+ );
416
+ return;
417
+ }
418
+ }
419
+
420
+ await sendHtml(
421
+ bot,
422
+ numericChatId,
423
+ escapeHtml(friendlyMessage(classified)),
424
+ last.replyToId,
425
+ );
426
+ }
427
+ }
428
+
429
+ // ── Response delivery ────────────────────────────────────────────────────────
430
+
431
+ async function sendHtml(
432
+ bot: Bot,
433
+ chatId: number,
434
+ html: string,
435
+ replyToId?: number,
436
+ ): Promise<number> {
437
+ const params = {
438
+ parse_mode: "HTML" as const,
439
+ reply_parameters: replyToId ? { message_id: replyToId } : undefined,
440
+ };
441
+ try {
442
+ const sent = await bot.api.sendMessage(chatId, html, params);
443
+ return sent.message_id;
444
+ } catch (err) {
445
+ logWarn("bot", `HTML send failed, falling back to plain text: ${err instanceof Error ? err.message : err}`);
446
+ const plain = html.replace(/<[^>]+>/g, "");
447
+ const sent = await bot.api.sendMessage(chatId, plain, {
448
+ reply_parameters: replyToId ? { message_id: replyToId } : undefined,
449
+ });
450
+ return sent.message_id;
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Run the agent and deliver responses with streaming + multi-message support.
456
+ */
457
+ type ProcessAndReplyParams = {
458
+ bot: Bot;
459
+ config: TalonConfig;
460
+ chatId: string | number;
461
+ numericChatId: number;
462
+ replyToId: number;
463
+ messageId: number;
464
+ prompt: string;
465
+ senderName: string;
466
+ isGroup: boolean;
467
+ senderUsername?: string;
468
+ senderId?: number;
469
+ chatTitle?: string;
470
+ };
471
+
472
+ // ── Streaming state for Telegram message edits ──────────────────────────────
473
+
474
+ type StreamState = {
475
+ draftId: number;
476
+ lastSentLength: number;
477
+ started: boolean;
478
+ editing: boolean;
479
+ };
480
+
481
+ // Probe once at startup whether sendMessageDraft is supported
482
+ let draftsSupported: boolean | null = null;
483
+
484
+ function createStreamCallbacks(
485
+ bot: Bot,
486
+ chatId: number,
487
+ _replyToId: number,
488
+ state: StreamState,
489
+ ) {
490
+ const onStreamDelta = async (
491
+ accumulated: string,
492
+ _phase?: "thinking" | "text",
493
+ ) => {
494
+ // Skip if drafts not supported or not ready
495
+ if (draftsSupported === false || !state.started || state.editing) return;
496
+ if (accumulated.length - state.lastSentLength < 40) return;
497
+
498
+ state.editing = true;
499
+ try {
500
+ const display = accumulated.length > 3900
501
+ ? accumulated.slice(0, 3900) + "\u2026"
502
+ : accumulated;
503
+
504
+ await bot.api.sendMessageDraft(chatId, state.draftId, display);
505
+ if (draftsSupported === null) draftsSupported = true;
506
+ state.lastSentLength = accumulated.length;
507
+ } catch {
508
+ // If first attempt fails, disable drafts entirely
509
+ if (draftsSupported === null) {
510
+ draftsSupported = false;
511
+ logWarn("bot", "sendMessageDraft not supported — streaming disabled");
512
+ }
513
+ } finally {
514
+ state.editing = false;
515
+ }
516
+ };
517
+
518
+ const onTextBlock = async (text: string) => {
519
+ await sendHtml(bot, chatId, markdownToTelegramHtml(text), _replyToId);
520
+ state.lastSentLength = 0;
521
+ };
522
+
523
+ return { onStreamDelta, onTextBlock };
524
+ }
525
+
526
+ async function deliverFinalText(
527
+ bot: Bot,
528
+ chatId: number,
529
+ text: string,
530
+ replyToId: number,
531
+ maxLen: number,
532
+ ): Promise<void> {
533
+ const chunks = splitMessage(text, maxLen);
534
+ for (const chunk of chunks) {
535
+ await sendHtml(bot, chatId, markdownToTelegramHtml(chunk), replyToId);
536
+ }
537
+ }
538
+
539
+ async function processAndReply(params: ProcessAndReplyParams): Promise<void> {
540
+ const {
541
+ bot, config, chatId, numericChatId, replyToId, messageId,
542
+ prompt, senderName, isGroup, senderUsername, senderId, chatTitle,
543
+ } = params;
544
+
545
+ const stream: StreamState = {
546
+ draftId: crypto.getRandomValues(new Uint32Array(1))[0] || 1,
547
+ lastSentLength: 0,
548
+ started: false,
549
+ editing: false,
550
+ };
551
+ // Wait 1s before starting streaming — avoids flickering on fast responses
552
+ const streamTimer = setTimeout(() => { stream.started = true; }, 1000);
553
+
554
+ try {
555
+ const { onStreamDelta, onTextBlock } = createStreamCallbacks(
556
+ bot, numericChatId, replyToId, stream,
557
+ );
558
+
559
+ // Enrich prompt with sender context
560
+ let enrichedPrompt = prompt;
561
+ if (!isGroup && senderName) {
562
+ enrichedPrompt = enrichDMPrompt(prompt, senderName, senderUsername);
563
+ if (senderId) trackDmUser(senderId, senderName, senderUsername);
564
+ } else if (isGroup && senderId) {
565
+ enrichedPrompt = enrichGroupPrompt(prompt, String(chatId), senderId);
566
+ }
567
+
568
+ const result = await execute({
569
+ chatId: String(chatId),
570
+ numericChatId,
571
+ prompt: enrichedPrompt,
572
+ senderName,
573
+ isGroup,
574
+ messageId,
575
+ source: "message",
576
+ onStreamDelta,
577
+ onTextBlock,
578
+ onToolUse: (toolName, input) => {
579
+ if (toolName === "send" && input.type === "text" && typeof input.text === "string") {
580
+ appendDailyLogResponse("Talon", input.text, { chatTitle });
581
+ }
582
+ },
583
+ });
584
+
585
+ // Only deliver messages sent via the send tool.
586
+ // Do NOT send fallback text — if Claude chose not to use send,
587
+ // it's either choosing not to respond or outputting internal reasoning.
588
+ if (result.bridgeMessageCount === 0 && result.text?.trim()) {
589
+ log("bot", `Suppressed fallback text (${result.text.length} chars) — no send tool used`);
590
+ }
591
+ } finally {
592
+ clearTimeout(streamTimer);
593
+ }
594
+ }
595
+
596
+ // ── Shared media handler ──────────────────────────────────────────────────────
597
+
598
+ type MediaDescriptor = {
599
+ /** Human-readable media type for prompt (e.g. "photo", "video", "voice message"). */
600
+ type: string;
601
+ /** File ID to download from Telegram. */
602
+ fileId: string;
603
+ /** File name for saving locally. */
604
+ fileName: string;
605
+ /** Extra prompt lines describing the media. */
606
+ promptLines: string[];
607
+ /** Caption from the message, if any. */
608
+ caption?: string;
609
+ /** Optional file size check (reject if too large). */
610
+ fileSize?: number;
611
+ };
612
+
613
+ /**
614
+ * Shared handler for all downloadable media types (photo, document, voice, video, animation).
615
+ * Extracts forward/reply context, downloads the file, builds a prompt, and enqueues.
616
+ */
617
+ async function handleMediaMessage(
618
+ ctx: Context,
619
+ bot: Bot,
620
+ config: TalonConfig,
621
+ media: MediaDescriptor,
622
+ ): Promise<void> {
623
+ if (!ctx.message || !ctx.chat) return;
624
+ if (ctx.from?.id && isUserRateLimited(ctx.from.id)) return;
625
+
626
+ const chatId = String(ctx.chat.id);
627
+ const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
628
+ const sender = getSenderName(ctx.from);
629
+ const senderUsername = ctx.from?.username;
630
+
631
+ try {
632
+ // File size check
633
+ if (media.fileSize && media.fileSize > 20 * 1024 * 1024) {
634
+ await sendHtml(
635
+ bot,
636
+ ctx.chat.id,
637
+ "File too large (max 20MB).",
638
+ ctx.message.message_id,
639
+ );
640
+ return;
641
+ }
642
+
643
+ const savedPath = await downloadTelegramFile(
644
+ bot,
645
+ config,
646
+ media.fileId,
647
+ media.fileName,
648
+ );
649
+
650
+ // Store file path in history + media index
651
+ setMessageFilePath(chatId, ctx.message.message_id, savedPath);
652
+ addMedia({
653
+ chatId,
654
+ msgId: ctx.message.message_id,
655
+ senderName: sender,
656
+ type: media.type as "photo" | "document" | "voice" | "video" | "animation" | "audio" | "sticker",
657
+ filePath: savedPath,
658
+ caption: media.caption,
659
+ timestamp: Date.now(),
660
+ });
661
+
662
+ const fwdCtx = getForwardContext(
663
+ ctx.message as Parameters<typeof getForwardContext>[0],
664
+ );
665
+ const replyCtx = getReplyContext(
666
+ ctx.message.reply_to_message as Parameters<typeof getReplyContext>[0],
667
+ ctx.me.id,
668
+ );
669
+ const replyPhotoCtx = await downloadReplyPhoto(
670
+ ctx.message.reply_to_message as Parameters<typeof downloadReplyPhoto>[0],
671
+ bot,
672
+ config,
673
+ );
674
+
675
+ const promptParts = [
676
+ fwdCtx,
677
+ replyCtx,
678
+ replyPhotoCtx,
679
+ ...media.promptLines.map((l) => l.replace("${savedPath}", savedPath)),
680
+ media.caption ? `Caption: ${media.caption}` : "",
681
+ ].filter(Boolean);
682
+
683
+ const prompt = promptParts.join("\n");
684
+
685
+ enqueueMessage(bot, config, chatId, ctx.chat.id, {
686
+ prompt,
687
+ replyToId: ctx.message.message_id,
688
+ messageId: ctx.message.message_id,
689
+ senderName: sender,
690
+ senderUsername,
691
+ senderId: ctx.from?.id,
692
+ isGroup,
693
+ chatTitle: isGroup ? (ctx.chat as { title?: string }).title : undefined,
694
+ });
695
+ } catch (err) {
696
+ logError(
697
+ "bot",
698
+ `[${chatId}] ${media.type} error (${sender}): ${err instanceof Error ? err.message : err}`,
699
+ );
700
+ await sendHtml(
701
+ bot,
702
+ ctx.chat.id,
703
+ escapeHtml(friendlyMessage(err)),
704
+ ctx.message.message_id,
705
+ );
706
+ }
707
+ }
708
+
709
+ // ── Message handlers ─────────────────────────────────────────────────────────
710
+
711
+ // ── Text message handler ────────────────────────────────────────────────────
712
+
713
+ export async function handleTextMessage(
714
+ ctx: Context,
715
+ bot: Bot,
716
+ config: TalonConfig,
717
+ ): Promise<void> {
718
+ if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
719
+ if (ctx.from?.id && isUserRateLimited(ctx.from.id)) return;
720
+
721
+ const chatId = String(ctx.chat.id);
722
+ const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
723
+ const sender = getSenderName(ctx.from);
724
+ const senderUsername = ctx.from?.username;
725
+
726
+ const replyCtx = getReplyContext(
727
+ ctx.message.reply_to_message as Parameters<typeof getReplyContext>[0],
728
+ ctx.me.id,
729
+ );
730
+ const replyPhotoCtx = await downloadReplyPhoto(
731
+ ctx.message.reply_to_message as Parameters<typeof downloadReplyPhoto>[0],
732
+ bot,
733
+ config,
734
+ );
735
+ const fwdCtx = getForwardContext(
736
+ ctx.message as Parameters<typeof getForwardContext>[0],
737
+ );
738
+ const prompt = fwdCtx + replyCtx + replyPhotoCtx + (ctx.message.text ?? "");
739
+
740
+ enqueueMessage(bot, config, chatId, ctx.chat.id, {
741
+ prompt,
742
+ replyToId: ctx.message.message_id,
743
+ messageId: ctx.message.message_id,
744
+ senderName: sender,
745
+ senderUsername,
746
+ senderId: ctx.from?.id,
747
+ isGroup,
748
+ chatTitle: isGroup ? (ctx.chat as { title?: string }).title : undefined,
749
+ });
750
+ }
751
+
752
+ export async function handlePhotoMessage(
753
+ ctx: Context,
754
+ bot: Bot,
755
+ config: TalonConfig,
756
+ ): Promise<void> {
757
+ if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
758
+
759
+ const photos = ctx.message.photo;
760
+ if (!photos?.length) return;
761
+ const bestPhoto = photos[photos.length - 1];
762
+ const caption = ctx.message.caption || "";
763
+
764
+ await handleMediaMessage(ctx, bot, config, {
765
+ type: "photo",
766
+ fileId: bestPhoto.file_id,
767
+ fileName: `photo_${bestPhoto.file_unique_id}.jpg`,
768
+ promptLines: [
769
+ "User sent a photo saved to: ${savedPath}",
770
+ "Read this file to view it. If you need to reference this image in future turns, re-read the file — image data does not persist between turns.",
771
+ ],
772
+ caption,
773
+ });
774
+ }
775
+
776
+ export async function handleDocumentMessage(
777
+ ctx: Context,
778
+ bot: Bot,
779
+ config: TalonConfig,
780
+ ): Promise<void> {
781
+ if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
782
+
783
+ const doc = ctx.message.document;
784
+ if (!doc) return;
785
+
786
+ const fileName = doc.file_name || `doc_${doc.file_unique_id}`;
787
+ const caption = ctx.message.caption || "";
788
+
789
+ await handleMediaMessage(ctx, bot, config, {
790
+ type: "document",
791
+ fileId: doc.file_id,
792
+ fileName,
793
+ fileSize: doc.file_size,
794
+ promptLines: [
795
+ `User sent a document: "${fileName}" (${doc.mime_type || "unknown"}).`,
796
+ "Saved to: ${savedPath}",
797
+ "Read and process this file.",
798
+ ],
799
+ caption,
800
+ });
801
+ }
802
+
803
+ export async function handleVoiceMessage(
804
+ ctx: Context,
805
+ bot: Bot,
806
+ config: TalonConfig,
807
+ ): Promise<void> {
808
+ if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
809
+
810
+ const voice = ctx.message.voice;
811
+ if (!voice) return;
812
+
813
+ await handleMediaMessage(ctx, bot, config, {
814
+ type: "voice",
815
+ fileId: voice.file_id,
816
+ fileName: `voice_${voice.file_unique_id}.ogg`,
817
+ promptLines: [
818
+ `User sent a voice message (${voice.duration}s).`,
819
+ "Audio saved to: ${savedPath}. You cannot transcribe audio — acknowledge it and respond based on context.",
820
+ ],
821
+ });
822
+ }
823
+
824
+ export async function handleStickerMessage(
825
+ ctx: Context,
826
+ bot: Bot,
827
+ config: TalonConfig,
828
+ ): Promise<void> {
829
+ if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
830
+
831
+ const chatId = String(ctx.chat.id);
832
+ const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
833
+ const sender = getSenderName(ctx.from);
834
+ const senderUsername = ctx.from?.username;
835
+
836
+ const sticker = ctx.message.sticker;
837
+ if (!sticker) return;
838
+
839
+ const emoji = sticker.emoji || "";
840
+ const setName = sticker.set_name || "";
841
+
842
+ const prompt = [
843
+ `User sent a sticker: ${emoji}`,
844
+ `Sticker file_id: ${sticker.file_id}`,
845
+ setName ? `Sticker set: ${setName}` : "",
846
+ sticker.is_animated
847
+ ? "(animated)"
848
+ : sticker.is_video
849
+ ? "(video sticker)"
850
+ : "",
851
+ "You can send this sticker back using the send_sticker tool with the file_id above.",
852
+ ]
853
+ .filter(Boolean)
854
+ .join("\n");
855
+
856
+ enqueueMessage(bot, config, chatId, ctx.chat.id, {
857
+ prompt,
858
+ replyToId: ctx.message.message_id,
859
+ messageId: ctx.message.message_id,
860
+ senderName: sender,
861
+ senderUsername,
862
+ senderId: ctx.from?.id,
863
+ isGroup,
864
+ chatTitle: isGroup ? (ctx.chat as { title?: string }).title : undefined,
865
+ });
866
+ }
867
+
868
+ export async function handleVideoMessage(
869
+ ctx: Context,
870
+ bot: Bot,
871
+ config: TalonConfig,
872
+ ): Promise<void> {
873
+ if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
874
+
875
+ const video = ctx.message.video;
876
+ if (!video) return;
877
+
878
+ const fileName = video.file_name || `video_${video.file_unique_id}.mp4`;
879
+ const caption = ctx.message.caption || "";
880
+
881
+ await handleMediaMessage(ctx, bot, config, {
882
+ type: "video",
883
+ fileId: video.file_id,
884
+ fileName,
885
+ promptLines: [
886
+ `User sent a video: "${fileName}" (${video.duration}s, ${video.width}x${video.height}).`,
887
+ "Saved to: ${savedPath}",
888
+ ],
889
+ caption,
890
+ });
891
+ }
892
+
893
+ export async function handleAnimationMessage(
894
+ ctx: Context,
895
+ bot: Bot,
896
+ config: TalonConfig,
897
+ ): Promise<void> {
898
+ if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
899
+
900
+ const anim = ctx.message.animation;
901
+ if (!anim) return;
902
+
903
+ const fileName = anim.file_name || `animation_${anim.file_unique_id}.mp4`;
904
+ const caption = ctx.message.caption || "";
905
+
906
+ await handleMediaMessage(ctx, bot, config, {
907
+ type: "animation",
908
+ fileId: anim.file_id,
909
+ fileName,
910
+ promptLines: [
911
+ `User sent a GIF/animation: "${fileName}" (${anim.duration}s).`,
912
+ "Saved to: ${savedPath}",
913
+ ],
914
+ caption,
915
+ });
916
+ }
917
+
918
+ export async function handleAudioMessage(
919
+ ctx: Context,
920
+ bot: Bot,
921
+ config: TalonConfig,
922
+ ): Promise<void> {
923
+ if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
924
+ if (ctx.from?.id && isUserRateLimited(ctx.from.id)) return;
925
+
926
+ const audio = ctx.message.audio;
927
+ if (!audio) return;
928
+
929
+ const title = audio.title || audio.file_name || "audio";
930
+ const performer = audio.performer ? ` by ${audio.performer}` : "";
931
+ const fileName = audio.file_name || `audio_${audio.file_unique_id}.mp3`;
932
+ const caption = ctx.message.caption || "";
933
+
934
+ await handleMediaMessage(ctx, bot, config, {
935
+ type: "audio",
936
+ fileId: audio.file_id,
937
+ fileName,
938
+ fileSize: audio.file_size,
939
+ promptLines: [
940
+ `User sent an audio file: "${title}"${performer} (${audio.duration}s).`,
941
+ "Saved to: ${savedPath}",
942
+ ],
943
+ caption,
944
+ });
945
+ }
946
+
947
+ export async function handleVideoNoteMessage(
948
+ ctx: Context,
949
+ bot: Bot,
950
+ config: TalonConfig,
951
+ ): Promise<void> {
952
+ if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
953
+ if (ctx.from?.id && isUserRateLimited(ctx.from.id)) return;
954
+
955
+ const videoNote = ctx.message.video_note;
956
+ if (!videoNote) return;
957
+
958
+ await handleMediaMessage(ctx, bot, config, {
959
+ type: "video note",
960
+ fileId: videoNote.file_id,
961
+ fileName: `videonote_${videoNote.file_unique_id}.mp4`,
962
+ fileSize: videoNote.file_size,
963
+ promptLines: [
964
+ `User sent a round video note (${videoNote.duration}s).`,
965
+ "Saved to: ${savedPath}",
966
+ ],
967
+ });
968
+ }
969
+
970
+ export async function handleCallbackQuery(
971
+ ctx: Context,
972
+ bot: Bot,
973
+ config: TalonConfig,
974
+ ): Promise<void> {
975
+ if (!ctx.callbackQuery || !("data" in ctx.callbackQuery)) return;
976
+
977
+ const chatId = String(ctx.chat?.id ?? ctx.from?.id);
978
+ const numericChatId = ctx.chat?.id ?? ctx.from?.id ?? 0;
979
+ const isGroup = ctx.chat?.type === "group" || ctx.chat?.type === "supergroup";
980
+ const sender = getSenderName(ctx.from);
981
+ const callbackData = ctx.callbackQuery.data;
982
+
983
+ // Acknowledge the callback immediately
984
+ await ctx.answerCallbackQuery().catch(() => {});
985
+
986
+ try {
987
+ const prompt = `[Button pressed] User clicked inline button with callback data: "${callbackData}"`;
988
+ const replyToId = ctx.callbackQuery.message?.message_id ?? 0;
989
+
990
+ const chatTitle = isGroup ? (ctx.chat as { title?: string })?.title : undefined;
991
+ appendDailyLog(sender, `Button: ${callbackData}`, { chatTitle });
992
+
993
+ await processAndReply({
994
+ bot, config, chatId, numericChatId,
995
+ replyToId,
996
+ messageId: replyToId,
997
+ prompt,
998
+ senderName: sender,
999
+ isGroup,
1000
+ chatTitle,
1001
+ });
1002
+ } catch (err) {
1003
+ logError(
1004
+ "bot",
1005
+ `[${chatId}] Callback error (${sender}): ${err instanceof Error ? err.message : err}`,
1006
+ );
1007
+ }
1008
+ }