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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. package/src/util/workspace.ts +51 -10
@@ -18,7 +18,10 @@ import {
18
18
  } from "../../core/prompt-builder.js";
19
19
  import { writeFileSync, mkdirSync, existsSync } from "node:fs";
20
20
  import { resolve } from "node:path";
21
- import { appendDailyLog, appendDailyLogResponse } from "../../storage/daily-log.js";
21
+ import {
22
+ appendDailyLog,
23
+ appendDailyLogResponse,
24
+ } from "../../storage/daily-log.js";
22
25
  import { setMessageFilePath } from "../../storage/history.js";
23
26
  import { addMedia } from "../../storage/media-index.js";
24
27
  import { recordMessageProcessed, recordError } from "../../util/watchdog.js";
@@ -40,9 +43,7 @@ function trackDmUser(
40
43
  const evictCount = Math.floor(KNOWN_DM_USERS_CAP * 0.1);
41
44
  const iter = knownDmUsers.values();
42
45
  for (let i = 0; i < evictCount; i++) {
43
- const val = iter.next();
44
- if (val.done) break;
45
- knownDmUsers.delete(val.value);
46
+ knownDmUsers.delete(iter.next().value as number);
46
47
  }
47
48
  }
48
49
  knownDmUsers.add(senderId);
@@ -51,6 +52,51 @@ function trackDmUser(
51
52
  appendDailyLog("System", `New DM user: ${senderName}${tag} [id:${senderId}]`);
52
53
  }
53
54
 
55
+ // ── Access control ──────────────────────────────────────────────────────────
56
+
57
+ let allowedUserIds: Set<number> | null = null; // null = no whitelist (allow all)
58
+ let adminId = 0;
59
+ const verifiedGroups = new Map<number, boolean>(); // chatId → admin is member
60
+
61
+ export function setAccessControl(cfg: {
62
+ allowedUsers?: number[];
63
+ adminUserId?: number;
64
+ }): void {
65
+ allowedUserIds = cfg.allowedUsers?.length ? new Set(cfg.allowedUsers) : null;
66
+ adminId = cfg.adminUserId ?? 0;
67
+ }
68
+
69
+ /**
70
+ * Check if a DM user is allowed. Returns true if no whitelist is set.
71
+ */
72
+ function isDmAllowed(senderId: number | undefined): boolean {
73
+ if (!allowedUserIds) return true;
74
+ return senderId !== undefined && allowedUserIds.has(senderId);
75
+ }
76
+
77
+ /**
78
+ * Check if the admin is a member of a group. Caches results for 10 minutes.
79
+ */
80
+ async function isAdminInGroup(bot: Bot, chatId: number): Promise<boolean> {
81
+ if (!adminId) return true; // no admin configured, allow all groups
82
+ const cached = verifiedGroups.get(chatId);
83
+ if (cached !== undefined) return cached;
84
+
85
+ try {
86
+ const member = await bot.api.getChatMember(chatId, adminId);
87
+ const isMember = !["left", "kicked"].includes(member.status);
88
+ verifiedGroups.set(chatId, isMember);
89
+ // Expire cache after 10 minutes
90
+ setTimeout(() => verifiedGroups.delete(chatId), 10 * 60 * 1000);
91
+ return isMember;
92
+ } catch {
93
+ // API error (e.g. bot can't query members) — deny by default
94
+ verifiedGroups.set(chatId, false);
95
+ setTimeout(() => verifiedGroups.delete(chatId), 10 * 60 * 1000);
96
+ return false;
97
+ }
98
+ }
99
+
54
100
  // ── Shared utilities ─────────────────────────────────────────────────────────
55
101
 
56
102
  export function shouldHandleInGroup(ctx: Context): boolean {
@@ -61,12 +107,82 @@ export function shouldHandleInGroup(ctx: Context): boolean {
61
107
  const botUser = ctx.me.username;
62
108
  // Word-boundary match — @botname must not be followed by alphanumeric/underscore
63
109
  const mentioned =
64
- botUser &&
65
- new RegExp(`@${botUser}(?![a-zA-Z0-9_])`, "i").test(text);
110
+ botUser && new RegExp(`@${botUser}(?![a-zA-Z0-9_])`, "i").test(text);
66
111
  const repliedToBot = ctx.message.reply_to_message?.from?.id === ctx.me.id;
67
112
  return !!(mentioned || repliedToBot);
68
113
  }
69
114
 
115
+ // Rate-limit unauthorized access warnings (one per user/group per 10 minutes)
116
+ const unauthorizedCooldown = new Map<string, number>();
117
+ const UNAUTHORIZED_COOLDOWN_MS = 10 * 60 * 1000;
118
+
119
+ /**
120
+ * Full access check: DM whitelist + group admin membership.
121
+ * Returns true if the message should be processed.
122
+ * Warns unauthorized users and notifies the admin.
123
+ */
124
+ export async function isAccessAllowed(
125
+ ctx: Context,
126
+ bot: Bot,
127
+ ): Promise<boolean> {
128
+ if (!ctx.chat) return false;
129
+ const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
130
+
131
+ if (!isGroup) {
132
+ if (isDmAllowed(ctx.from?.id)) return true;
133
+ await notifyUnauthorized(bot, ctx, "dm");
134
+ return false;
135
+ }
136
+
137
+ if (await isAdminInGroup(bot, ctx.chat.id)) return true;
138
+ await notifyUnauthorized(bot, ctx, "group");
139
+ return false;
140
+ }
141
+
142
+ async function notifyUnauthorized(
143
+ bot: Bot,
144
+ ctx: Context,
145
+ type: "dm" | "group",
146
+ ): Promise<void> {
147
+ const key = type === "dm" ? `dm:${ctx.from?.id}` : `group:${ctx.chat?.id}`;
148
+ const now = Date.now();
149
+ const lastWarned = unauthorizedCooldown.get(key);
150
+ if (lastWarned && now - lastWarned < UNAUTHORIZED_COOLDOWN_MS) return;
151
+ unauthorizedCooldown.set(key, now);
152
+
153
+ const sender = getSenderName(ctx.from);
154
+ const username = ctx.from?.username ? ` (@${ctx.from.username})` : "";
155
+ const userId = ctx.from?.id ?? "unknown";
156
+
157
+ // Warn the user
158
+ try {
159
+ await bot.api.sendMessage(
160
+ ctx.chat!.id,
161
+ "⚠️ Unauthorized access. This bot is private. This attempt has been reported to the bot owner.",
162
+ );
163
+ } catch {
164
+ /* can't send — ignore */
165
+ }
166
+
167
+ // Notify admin
168
+ if (adminId) {
169
+ const detail =
170
+ type === "dm"
171
+ ? `🚨 Unauthorized DM from ${sender}${username} [id:${userId}]`
172
+ : `🚨 Unauthorized group access: "${(ctx.chat as { title?: string })?.title ?? ctx.chat!.id}" [id:${ctx.chat!.id}] by ${sender}${username}`;
173
+ try {
174
+ await bot.api.sendMessage(adminId, detail);
175
+ } catch {
176
+ /* admin unreachable — ignore */
177
+ }
178
+ }
179
+
180
+ logWarn(
181
+ "access",
182
+ `Unauthorized ${type}: ${sender}${username} [id:${userId}] in chat ${ctx.chat!.id}`,
183
+ );
184
+ }
185
+
70
186
  export function getSenderName(
71
187
  from: { first_name?: string; last_name?: string } | undefined,
72
188
  ): string {
@@ -95,23 +211,31 @@ export function getReplyContext(
95
211
  ): string {
96
212
  if (!replyMsg) return "";
97
213
 
98
- const author = replyMsg.from?.id === botId
99
- ? "bot"
100
- : [replyMsg.from?.first_name, replyMsg.from?.last_name]
101
- .filter(Boolean)
102
- .join(" ") || "User";
214
+ const author =
215
+ replyMsg.from?.id === botId
216
+ ? "bot"
217
+ : [replyMsg.from?.first_name, replyMsg.from?.last_name]
218
+ .filter(Boolean)
219
+ .join(" ") || "User";
103
220
  const text = replyMsg.text || replyMsg.caption || "";
104
221
  const msgIdTag = replyMsg.message_id ? ` msg_id:${replyMsg.message_id}` : "";
105
222
 
106
223
  // 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;
224
+ const mediaType = replyMsg.photo
225
+ ? "photo"
226
+ : replyMsg.video
227
+ ? "video"
228
+ : replyMsg.document
229
+ ? "document"
230
+ : replyMsg.voice
231
+ ? "voice"
232
+ : replyMsg.audio
233
+ ? "audio"
234
+ : replyMsg.sticker
235
+ ? "sticker"
236
+ : replyMsg.animation
237
+ ? "animation"
238
+ : null;
115
239
  const mediaPart = mediaType ? ` [${mediaType}]` : "";
116
240
 
117
241
  // Build context — always include if there's a message_id (even if no text)
@@ -127,7 +251,16 @@ export function getReplyContext(
127
251
  * line pointing to the saved file so Claude can see it. Returns "" if no photo.
128
252
  */
129
253
  async function downloadReplyPhoto(
130
- replyMsg: { photo?: { file_id: string; file_unique_id: string; width?: number; height?: number }[] } | undefined,
254
+ replyMsg:
255
+ | {
256
+ photo?: {
257
+ file_id: string;
258
+ file_unique_id: string;
259
+ width?: number;
260
+ height?: number;
261
+ }[];
262
+ }
263
+ | undefined,
131
264
  bot: Bot,
132
265
  config: TalonConfig,
133
266
  ): Promise<string> {
@@ -143,7 +276,10 @@ async function downloadReplyPhoto(
143
276
  );
144
277
  return `[Replied-to message contains a photo saved to: ${savedPath} — read it to view]\n`;
145
278
  } catch (err) {
146
- logWarn("bot", `Failed to download reply photo: ${err instanceof Error ? err.message : err}`);
279
+ logWarn(
280
+ "bot",
281
+ `Failed to download reply photo: ${err instanceof Error ? err.message : err}`,
282
+ );
147
283
  return "";
148
284
  }
149
285
  }
@@ -191,26 +327,39 @@ async function downloadTelegramFile(
191
327
  const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
192
328
  const contentLength = resp.headers.get("content-length");
193
329
  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)`);
330
+ throw new Error(
331
+ `File too large (${Math.round(parseInt(contentLength, 10) / 1024 / 1024)}MB, max 50MB)`,
332
+ );
195
333
  }
196
334
 
197
335
  const buffer = Buffer.from(await resp.arrayBuffer());
198
- if (buffer.length === 0) throw new Error("Downloaded file is empty (0 bytes)");
336
+ if (buffer.length === 0)
337
+ throw new Error("Downloaded file is empty (0 bytes)");
199
338
 
200
339
  // Validate image files — prevent saving HTML/garbage as .jpg/.png
201
340
  // (corrupt "images" poison the Claude session permanently on resume)
202
341
  const imageExts = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
203
- const isImageExt = imageExts.some((ext) => fileName.toLowerCase().endsWith(ext));
342
+ const isImageExt = imageExts.some((ext) =>
343
+ fileName.toLowerCase().endsWith(ext),
344
+ );
204
345
  if (isImageExt) {
205
346
  const m = buffer.subarray(0, 16);
206
347
  const validImage =
207
- (m[0] === 0xFF && m[1] === 0xD8) || // JPEG
208
- (m[0] === 0x89 && m[1] === 0x50 && m[2] === 0x4E && m[3] === 0x47) || // PNG
348
+ (m[0] === 0xff && m[1] === 0xd8) || // JPEG
349
+ (m[0] === 0x89 && m[1] === 0x50 && m[2] === 0x4e && m[3] === 0x47) || // PNG
209
350
  (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
351
+ (m[0] === 0x52 &&
352
+ m[1] === 0x49 &&
353
+ m[2] === 0x46 &&
354
+ m[3] === 0x46 &&
355
+ m[8] === 0x57 &&
356
+ m[9] === 0x45 &&
357
+ m[10] === 0x42 &&
358
+ m[11] === 0x50); // WebP
212
359
  if (!validImage) {
213
- throw new Error(`File "${fileName}" has image extension but invalid content — not saving to prevent session corruption`);
360
+ throw new Error(
361
+ `File "${fileName}" has image extension but invalid content — not saving to prevent session corruption`,
362
+ );
214
363
  }
215
364
  }
216
365
 
@@ -219,10 +368,6 @@ async function downloadTelegramFile(
219
368
 
220
369
  const safeName = `${Date.now()}-${fileName.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
221
370
  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
371
  writeFileSync(destPath, buffer);
227
372
  return destPath;
228
373
  }
@@ -313,7 +458,10 @@ function enqueueMessage(
313
458
  // Show hourglass reaction on the queued message to indicate it's been seen
314
459
  bot.api
315
460
  .setMessageReaction(numericChatId, msg.messageId, [
316
- { type: "emoji", emoji: "\u23F3" as "\uD83D\uDC4D" /* grammY wants union type */ },
461
+ {
462
+ type: "emoji",
463
+ emoji: "\u23F3" as "\uD83D\uDC4D" /* grammY wants union type */,
464
+ },
317
465
  ])
318
466
  .catch(() => {});
319
467
  existing.queuedReactionMsgIds.push(msg.messageId);
@@ -339,12 +487,14 @@ async function flushQueue(chatId: string): Promise<void> {
339
487
  messageQueues.delete(chatId);
340
488
 
341
489
  const { messages, bot, config, numericChatId, queuedReactionMsgIds } = entry;
342
- if (messages.length === 0) return;
343
490
 
344
491
  // Clear hourglass reactions on queued messages now that we're processing
345
492
  for (const msgId of queuedReactionMsgIds) {
346
493
  bot.api.setMessageReaction(numericChatId, msgId, []).catch((err) => {
347
- logWarn("bot", `Failed to clear reaction on msg ${msgId}: ${err instanceof Error ? err.message : err}`);
494
+ logWarn(
495
+ "bot",
496
+ `Failed to clear reaction on msg ${msgId}: ${err instanceof Error ? err.message : err}`,
497
+ );
348
498
  });
349
499
  }
350
500
 
@@ -357,12 +507,18 @@ async function flushQueue(chatId: string): Promise<void> {
357
507
  ? messages[0].prompt
358
508
  : messages.map((m) => m.prompt).join("\n\n");
359
509
 
360
- const chatContext = { chatTitle: last.chatTitle, username: last.senderUsername };
510
+ const chatContext = {
511
+ chatTitle: last.chatTitle,
512
+ username: last.senderUsername,
513
+ };
361
514
  appendDailyLog(last.senderName, combinedPrompt, chatContext);
362
515
 
363
516
  try {
364
517
  await processAndReply({
365
- bot, config, chatId, numericChatId,
518
+ bot,
519
+ config,
520
+ chatId,
521
+ numericChatId,
366
522
  replyToId: last.replyToId,
367
523
  messageId: last.messageId,
368
524
  prompt: combinedPrompt,
@@ -386,11 +542,17 @@ async function flushQueue(chatId: string): Promise<void> {
386
542
  // Retry once for transient errors (rate_limit, overloaded, network)
387
543
  if (classified.retryable) {
388
544
  const delayMs = classified.retryAfterMs ?? 2000;
389
- log("bot", `[${chatId}] Retrying after ${classified.reason} (${delayMs}ms)...`);
545
+ log(
546
+ "bot",
547
+ `[${chatId}] Retrying after ${classified.reason} (${delayMs}ms)...`,
548
+ );
390
549
  try {
391
550
  await new Promise((r) => setTimeout(r, delayMs));
392
551
  await processAndReply({
393
- bot, config, chatId, numericChatId,
552
+ bot,
553
+ config,
554
+ chatId,
555
+ numericChatId,
394
556
  replyToId: last.replyToId,
395
557
  messageId: last.messageId,
396
558
  prompt: combinedPrompt,
@@ -442,7 +604,10 @@ async function sendHtml(
442
604
  const sent = await bot.api.sendMessage(chatId, html, params);
443
605
  return sent.message_id;
444
606
  } catch (err) {
445
- logWarn("bot", `HTML send failed, falling back to plain text: ${err instanceof Error ? err.message : err}`);
607
+ logWarn(
608
+ "bot",
609
+ `HTML send failed, falling back to plain text: ${err instanceof Error ? err.message : err}`,
610
+ );
446
611
  const plain = html.replace(/<[^>]+>/g, "");
447
612
  const sent = await bot.api.sendMessage(chatId, plain, {
448
613
  reply_parameters: replyToId ? { message_id: replyToId } : undefined,
@@ -497,9 +662,10 @@ function createStreamCallbacks(
497
662
 
498
663
  state.editing = true;
499
664
  try {
500
- const display = accumulated.length > 3900
501
- ? accumulated.slice(0, 3900) + "\u2026"
502
- : accumulated;
665
+ const display =
666
+ accumulated.length > 3900
667
+ ? accumulated.slice(0, 3900) + "\u2026"
668
+ : accumulated;
503
669
 
504
670
  await bot.api.sendMessageDraft(chatId, state.draftId, display);
505
671
  if (draftsSupported === null) draftsSupported = true;
@@ -523,23 +689,20 @@ function createStreamCallbacks(
523
689
  return { onStreamDelta, onTextBlock };
524
690
  }
525
691
 
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
692
  async function processAndReply(params: ProcessAndReplyParams): Promise<void> {
540
693
  const {
541
- bot, config, chatId, numericChatId, replyToId, messageId,
542
- prompt, senderName, isGroup, senderUsername, senderId, chatTitle,
694
+ bot,
695
+ config,
696
+ chatId,
697
+ numericChatId,
698
+ replyToId,
699
+ messageId,
700
+ prompt,
701
+ senderName,
702
+ isGroup,
703
+ senderUsername,
704
+ senderId,
705
+ chatTitle,
543
706
  } = params;
544
707
 
545
708
  const stream: StreamState = {
@@ -549,11 +712,16 @@ async function processAndReply(params: ProcessAndReplyParams): Promise<void> {
549
712
  editing: false,
550
713
  };
551
714
  // Wait 1s before starting streaming — avoids flickering on fast responses
552
- const streamTimer = setTimeout(() => { stream.started = true; }, 1000);
715
+ const streamTimer = setTimeout(() => {
716
+ stream.started = true;
717
+ }, 1000);
553
718
 
554
719
  try {
555
720
  const { onStreamDelta, onTextBlock } = createStreamCallbacks(
556
- bot, numericChatId, replyToId, stream,
721
+ bot,
722
+ numericChatId,
723
+ replyToId,
724
+ stream,
557
725
  );
558
726
 
559
727
  // Enrich prompt with sender context
@@ -576,7 +744,11 @@ async function processAndReply(params: ProcessAndReplyParams): Promise<void> {
576
744
  onStreamDelta,
577
745
  onTextBlock,
578
746
  onToolUse: (toolName, input) => {
579
- if (toolName === "send" && input.type === "text" && typeof input.text === "string") {
747
+ if (
748
+ toolName === "send" &&
749
+ input.type === "text" &&
750
+ typeof input.text === "string"
751
+ ) {
580
752
  appendDailyLogResponse("Talon", input.text, { chatTitle });
581
753
  }
582
754
  },
@@ -586,7 +758,10 @@ async function processAndReply(params: ProcessAndReplyParams): Promise<void> {
586
758
  // Do NOT send fallback text — if Claude chose not to use send,
587
759
  // it's either choosing not to respond or outputting internal reasoning.
588
760
  if (result.bridgeMessageCount === 0 && result.text?.trim()) {
589
- log("bot", `Suppressed fallback text (${result.text.length} chars) — no send tool used`);
761
+ log(
762
+ "bot",
763
+ `Suppressed fallback text (${result.text.length} chars) — no send tool used`,
764
+ );
590
765
  }
591
766
  } finally {
592
767
  clearTimeout(streamTimer);
@@ -653,7 +828,14 @@ async function handleMediaMessage(
653
828
  chatId,
654
829
  msgId: ctx.message.message_id,
655
830
  senderName: sender,
656
- type: media.type as "photo" | "document" | "voice" | "video" | "animation" | "audio" | "sticker",
831
+ type: media.type as
832
+ | "photo"
833
+ | "document"
834
+ | "voice"
835
+ | "video"
836
+ | "animation"
837
+ | "audio"
838
+ | "sticker",
657
839
  filePath: savedPath,
658
840
  caption: media.caption,
659
841
  timestamp: Date.now(),
@@ -716,6 +898,7 @@ export async function handleTextMessage(
716
898
  config: TalonConfig,
717
899
  ): Promise<void> {
718
900
  if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
901
+ if (!(await isAccessAllowed(ctx, bot))) return;
719
902
  if (ctx.from?.id && isUserRateLimited(ctx.from.id)) return;
720
903
 
721
904
  const chatId = String(ctx.chat.id);
@@ -755,6 +938,7 @@ export async function handlePhotoMessage(
755
938
  config: TalonConfig,
756
939
  ): Promise<void> {
757
940
  if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
941
+ if (!(await isAccessAllowed(ctx, bot))) return;
758
942
 
759
943
  const photos = ctx.message.photo;
760
944
  if (!photos?.length) return;
@@ -779,6 +963,7 @@ export async function handleDocumentMessage(
779
963
  config: TalonConfig,
780
964
  ): Promise<void> {
781
965
  if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
966
+ if (!(await isAccessAllowed(ctx, bot))) return;
782
967
 
783
968
  const doc = ctx.message.document;
784
969
  if (!doc) return;
@@ -806,6 +991,7 @@ export async function handleVoiceMessage(
806
991
  config: TalonConfig,
807
992
  ): Promise<void> {
808
993
  if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
994
+ if (!(await isAccessAllowed(ctx, bot))) return;
809
995
 
810
996
  const voice = ctx.message.voice;
811
997
  if (!voice) return;
@@ -827,6 +1013,7 @@ export async function handleStickerMessage(
827
1013
  config: TalonConfig,
828
1014
  ): Promise<void> {
829
1015
  if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
1016
+ if (!(await isAccessAllowed(ctx, bot))) return;
830
1017
 
831
1018
  const chatId = String(ctx.chat.id);
832
1019
  const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
@@ -871,6 +1058,7 @@ export async function handleVideoMessage(
871
1058
  config: TalonConfig,
872
1059
  ): Promise<void> {
873
1060
  if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
1061
+ if (!(await isAccessAllowed(ctx, bot))) return;
874
1062
 
875
1063
  const video = ctx.message.video;
876
1064
  if (!video) return;
@@ -896,6 +1084,7 @@ export async function handleAnimationMessage(
896
1084
  config: TalonConfig,
897
1085
  ): Promise<void> {
898
1086
  if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
1087
+ if (!(await isAccessAllowed(ctx, bot))) return;
899
1088
 
900
1089
  const anim = ctx.message.animation;
901
1090
  if (!anim) return;
@@ -921,6 +1110,7 @@ export async function handleAudioMessage(
921
1110
  config: TalonConfig,
922
1111
  ): Promise<void> {
923
1112
  if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
1113
+ if (!(await isAccessAllowed(ctx, bot))) return;
924
1114
  if (ctx.from?.id && isUserRateLimited(ctx.from.id)) return;
925
1115
 
926
1116
  const audio = ctx.message.audio;
@@ -950,6 +1140,7 @@ export async function handleVideoNoteMessage(
950
1140
  config: TalonConfig,
951
1141
  ): Promise<void> {
952
1142
  if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
1143
+ if (!(await isAccessAllowed(ctx, bot))) return;
953
1144
  if (ctx.from?.id && isUserRateLimited(ctx.from.id)) return;
954
1145
 
955
1146
  const videoNote = ctx.message.video_note;
@@ -987,11 +1178,16 @@ export async function handleCallbackQuery(
987
1178
  const prompt = `[Button pressed] User clicked inline button with callback data: "${callbackData}"`;
988
1179
  const replyToId = ctx.callbackQuery.message?.message_id ?? 0;
989
1180
 
990
- const chatTitle = isGroup ? (ctx.chat as { title?: string })?.title : undefined;
1181
+ const chatTitle = isGroup
1182
+ ? (ctx.chat as { title?: string })?.title
1183
+ : undefined;
991
1184
  appendDailyLog(sender, `Button: ${callbackData}`, { chatTitle });
992
1185
 
993
1186
  await processAndReply({
994
- bot, config, chatId, numericChatId,
1187
+ bot,
1188
+ config,
1189
+ chatId,
1190
+ numericChatId,
995
1191
  replyToId,
996
1192
  messageId: replyToId,
997
1193
  prompt,
@@ -13,11 +13,9 @@ import type { TalonConfig } from "../../util/config.js";
13
13
  import type { ContextManager } from "../../core/types.js";
14
14
  import type { Gateway } from "../../core/gateway.js";
15
15
  import { createTelegramActionHandler, sendText } from "./actions.js";
16
- import {
17
- initUserClient,
18
- disconnectUserClient,
19
- } from "./userbot.js";
16
+ import { initUserClient, disconnectUserClient } from "./userbot.js";
20
17
  import { registerCommands, setAdminUserId } from "./commands.js";
18
+ import { setAccessControl } from "./handlers.js";
21
19
  import { registerMiddleware } from "./middleware.js";
22
20
  import { registerCallbacks } from "./callbacks.js";
23
21
  import { log, logError } from "../../util/log.js";
@@ -36,7 +34,10 @@ export type TelegramFrontend = {
36
34
 
37
35
  // ── Factory ─────────────────────────────────────────────────────────────────
38
36
 
39
- export function createTelegramFrontend(config: TalonConfig, gateway: Gateway): TelegramFrontend {
37
+ export function createTelegramFrontend(
38
+ config: TalonConfig,
39
+ gateway: Gateway,
40
+ ): TelegramFrontend {
40
41
  const bot = new Bot(config.botToken!);
41
42
  bot.api.config.use(apiThrottler());
42
43
  bot.api.config.use(autoRetry({ maxRetryAttempts: 3, maxDelaySeconds: 60 }));
@@ -61,12 +62,18 @@ export function createTelegramFrontend(config: TalonConfig, gateway: Gateway): T
61
62
 
62
63
  async init() {
63
64
  // Register Telegram action handler with the core gateway
64
- gateway.setFrontendHandler(createTelegramActionHandler(bot, InputFile, config.botToken!, gateway));
65
+ gateway.setFrontendHandler(
66
+ createTelegramActionHandler(bot, InputFile, config.botToken!, gateway),
67
+ );
65
68
 
66
69
  const port = await gateway.start(19876);
67
70
  log("bot", `Gateway started on port ${port}`);
68
71
 
69
72
  setAdminUserId(config.adminUserId);
73
+ setAccessControl({
74
+ allowedUsers: config.allowedUsers,
75
+ adminUserId: config.adminUserId,
76
+ });
70
77
 
71
78
  registerCommands(bot, config);
72
79
  registerMiddleware(bot, config);
@@ -75,7 +82,10 @@ export function createTelegramFrontend(config: TalonConfig, gateway: Gateway): T
75
82
  await bot.api.deleteMyCommands();
76
83
  await bot.api.setMyCommands([
77
84
  { command: "start", description: "Introduction" },
78
- { command: "settings", description: "View and change all chat settings" },
85
+ {
86
+ command: "settings",
87
+ description: "View and change all chat settings",
88
+ },
79
89
  { command: "memory", description: "View what Talon remembers" },
80
90
  { command: "status", description: "Session info, usage, and stats" },
81
91
  { command: "ping", description: "Health check with latency" },
@@ -100,7 +110,10 @@ export function createTelegramFrontend(config: TalonConfig, gateway: Gateway): T
100
110
  })
101
111
  .catch((err) => logError("userbot", "Init failed", err));
102
112
  } else {
103
- log("userbot", "TALON_API_ID/TALON_API_HASH not set -- using in-memory history only.");
113
+ log(
114
+ "userbot",
115
+ "TALON_API_ID/TALON_API_HASH not set -- using in-memory history only.",
116
+ );
104
117
  }
105
118
  },
106
119
 
@@ -119,12 +132,24 @@ export function createTelegramFrontend(config: TalonConfig, gateway: Gateway): T
119
132
  },
120
133
 
121
134
  async stop() {
122
- try { await bot.stop(); log("shutdown", "Bot disconnected"); }
123
- catch (err) { logError("shutdown", "Bot stop error", err); }
124
- try { await disconnectUserClient(); log("shutdown", "User client disconnected"); }
125
- catch (err) { logError("shutdown", "User client disconnect error", err); }
126
- try { await gateway.stop(); log("shutdown", "Gateway stopped"); }
127
- catch (err) { logError("shutdown", "Gateway stop error", err); }
135
+ try {
136
+ await bot.stop();
137
+ log("shutdown", "Bot disconnected");
138
+ } catch (err) {
139
+ logError("shutdown", "Bot stop error", err);
140
+ }
141
+ try {
142
+ await disconnectUserClient();
143
+ log("shutdown", "User client disconnected");
144
+ } catch (err) {
145
+ logError("shutdown", "User client disconnect error", err);
146
+ }
147
+ try {
148
+ await gateway.stop();
149
+ log("shutdown", "Gateway stopped");
150
+ } catch (err) {
151
+ logError("shutdown", "Gateway stop error", err);
152
+ }
128
153
  },
129
154
  };
130
155
  }