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
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
: replyMsg.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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)
|
|
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) =>
|
|
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] ===
|
|
208
|
-
(m[0] === 0x89 && m[1] === 0x50 && m[2] ===
|
|
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 &&
|
|
211
|
-
|
|
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(
|
|
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
|
-
{
|
|
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(
|
|
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 = {
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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 =
|
|
501
|
-
|
|
502
|
-
|
|
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,
|
|
542
|
-
|
|
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(() => {
|
|
715
|
+
const streamTimer = setTimeout(() => {
|
|
716
|
+
stream.started = true;
|
|
717
|
+
}, 1000);
|
|
553
718
|
|
|
554
719
|
try {
|
|
555
720
|
const { onStreamDelta, onTextBlock } = createStreamCallbacks(
|
|
556
|
-
bot,
|
|
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 (
|
|
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(
|
|
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
|
|
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
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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(
|
|
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 {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
catch (err) {
|
|
126
|
-
|
|
127
|
-
|
|
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
|
}
|