polygram 0.4.1 → 0.4.6
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/.claude-plugin/plugin.json +1 -1
- package/lib/media-group-buffer.js +73 -0
- package/lib/process-manager.js +40 -15
- package/lib/telegram.js +12 -1
- package/package.json +1 -1
- package/polygram.js +151 -58
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.6",
|
|
5
5
|
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buffer Telegram messages that share a `media_group_id` so they can be
|
|
3
|
+
* dispatched as ONE logical turn to Claude.
|
|
4
|
+
*
|
|
5
|
+
* Why: when a user uploads N photos "in one message," Telegram delivers N
|
|
6
|
+
* distinct Message updates (one per photo, all tagged with the same
|
|
7
|
+
* `media_group_id`). Without this buffer, polygram sees each photo as a
|
|
8
|
+
* separate turn — Claude answers the first and the others either queue
|
|
9
|
+
* behind it (consuming warm-process capacity) or fire their own turns
|
|
10
|
+
* with no text.
|
|
11
|
+
*
|
|
12
|
+
* Pattern (matches OpenClaw's `MEDIA_GROUP_TIMEOUT_MS`):
|
|
13
|
+
* - Messages arriving faster than `flushMs` apart stay in the same
|
|
14
|
+
* group; timer resets on each arrival.
|
|
15
|
+
* - Group flushes `flushMs` after the LAST sibling arrives.
|
|
16
|
+
* - Stragglers arriving after a flush create a new group (new turn).
|
|
17
|
+
* Telegram usually ships all siblings within ~100ms, so 500ms of
|
|
18
|
+
* headroom catches virtually everything.
|
|
19
|
+
*
|
|
20
|
+
* I/O-pure: accepts `timerFn`/`clearTimerFn` for test injection.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULT_FLUSH_MS = 500;
|
|
24
|
+
|
|
25
|
+
function createMediaGroupBuffer({
|
|
26
|
+
flushMs = DEFAULT_FLUSH_MS,
|
|
27
|
+
onFlush,
|
|
28
|
+
timerFn = setTimeout,
|
|
29
|
+
clearTimerFn = clearTimeout,
|
|
30
|
+
} = {}) {
|
|
31
|
+
if (typeof onFlush !== 'function') throw new Error('onFlush required');
|
|
32
|
+
const entries = new Map(); // key → { messages, timer }
|
|
33
|
+
|
|
34
|
+
const flushKey = (key) => {
|
|
35
|
+
const entry = entries.get(key);
|
|
36
|
+
if (!entry) return;
|
|
37
|
+
entries.delete(key);
|
|
38
|
+
// Defensive: onFlush errors must not break future group buffering.
|
|
39
|
+
try { onFlush(entry.messages, key); }
|
|
40
|
+
catch { /* caller can log if it cares */ }
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const add = (key, msg) => {
|
|
44
|
+
let entry = entries.get(key);
|
|
45
|
+
if (!entry) {
|
|
46
|
+
entry = { messages: [], timer: null };
|
|
47
|
+
entries.set(key, entry);
|
|
48
|
+
}
|
|
49
|
+
entry.messages.push(msg);
|
|
50
|
+
if (entry.timer) clearTimerFn(entry.timer);
|
|
51
|
+
const t = timerFn(() => flushKey(key), flushMs);
|
|
52
|
+
// Don't keep the node event loop alive waiting for a buffered group
|
|
53
|
+
// that never grew further — especially in tests.
|
|
54
|
+
t?.unref?.();
|
|
55
|
+
entry.timer = t;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const flushAll = () => {
|
|
59
|
+
for (const key of Array.from(entries.keys())) {
|
|
60
|
+
const entry = entries.get(key);
|
|
61
|
+
if (entry?.timer) clearTimerFn(entry.timer);
|
|
62
|
+
flushKey(key);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
add,
|
|
68
|
+
flushAll,
|
|
69
|
+
get size() { return entries.size; },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { createMediaGroupBuffer, DEFAULT_FLUSH_MS };
|
package/lib/process-manager.js
CHANGED
|
@@ -23,6 +23,15 @@ const DEFAULT_KILL_TIMEOUT_MS = 3000;
|
|
|
23
23
|
* (they count as Claude activity) but are NOT rendered to Telegram.
|
|
24
24
|
* Streaming every tool call to chat produces a noisy "_Calling X_"
|
|
25
25
|
* ladder that adds no information users can act on.
|
|
26
|
+
*
|
|
27
|
+
* Trailing-colon normalisation: Claude writes preambles like "Checking
|
|
28
|
+
* this:" followed by a tool_use. Because we hide tool_use in the stream,
|
|
29
|
+
* the colon becomes an orphan pointing at invisible work. Replace a
|
|
30
|
+
* trailing `:` with `…` — the ellipsis reads as "doing it now" and
|
|
31
|
+
* preserves the natural flow. Only the LAST colon in the joined text is
|
|
32
|
+
* touched; mid-sentence colons ("Here's the plan: step 1, step 2")
|
|
33
|
+
* stay intact. Also guards against `::` sequences (code / emoticons) by
|
|
34
|
+
* requiring the preceding char to not also be `:`.
|
|
26
35
|
*/
|
|
27
36
|
function extractAssistantText(event) {
|
|
28
37
|
const blocks = event?.message?.content;
|
|
@@ -34,7 +43,7 @@ function extractAssistantText(event) {
|
|
|
34
43
|
parts.push(b.text);
|
|
35
44
|
}
|
|
36
45
|
}
|
|
37
|
-
return parts.join('\n\n').trim();
|
|
46
|
+
return parts.join('\n\n').trim().replace(/([^:]):\s*$/, '$1…');
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
class ProcessManager {
|
|
@@ -272,9 +281,17 @@ class ProcessManager {
|
|
|
272
281
|
entry.pending = { resolve, reject };
|
|
273
282
|
entry.streamText = '';
|
|
274
283
|
|
|
284
|
+
// Timer handles kept in closure vars (not entry.pending), because
|
|
285
|
+
// the result-event handler in rl.on('line') sets entry.pending = null
|
|
286
|
+
// BEFORE calling the wrapped resolve. Reading from entry.pending
|
|
287
|
+
// after null-out gave undefined → clearTimeout was never called →
|
|
288
|
+
// the default 30-min maxTurnMs timer stayed armed and held Node's
|
|
289
|
+
// event loop open, hanging the test runner on CI.
|
|
290
|
+
let idleTimer = null;
|
|
291
|
+
let maxTimer = null;
|
|
275
292
|
const clearTimers = () => {
|
|
276
|
-
if (
|
|
277
|
-
if (
|
|
293
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
294
|
+
if (maxTimer) { clearTimeout(maxTimer); maxTimer = null; }
|
|
278
295
|
};
|
|
279
296
|
|
|
280
297
|
// Timer fire path. New in 0.3.9: after rejecting, SIGTERM the
|
|
@@ -299,19 +316,25 @@ class ProcessManager {
|
|
|
299
316
|
// Idle timeout: counts N seconds of SILENCE from Claude. Reset on
|
|
300
317
|
// every assistant event so long productive turns (multi-tool
|
|
301
318
|
// reasoning) don't falsely trip.
|
|
302
|
-
// .unref()
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
319
|
+
// Note on .unref(): an earlier revision called unref() on both
|
|
320
|
+
// timers to avoid holding the node event loop open in tests. That
|
|
321
|
+
// broke Node's test runner on CI ("Promise resolution is still
|
|
322
|
+
// pending but the event loop has already resolved") — the runner
|
|
323
|
+
// detects unref'd timers as a drained loop and cancels awaiters
|
|
324
|
+
// before the timer can fire. Production polygram stays alive via
|
|
325
|
+
// grammy's poll loop + child process pipes; we don't need unref.
|
|
306
326
|
const armIdle = () => setTimeout(
|
|
307
327
|
() => fireTimeout(`Timeout: ${timeoutMs / 1000}s idle with no Claude activity`),
|
|
308
328
|
timeoutMs,
|
|
309
|
-
)
|
|
310
|
-
|
|
329
|
+
);
|
|
330
|
+
idleTimer = armIdle();
|
|
331
|
+
entry.pending.idleTimer = idleTimer;
|
|
311
332
|
entry.pending.resetIdleTimer = () => {
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
333
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
334
|
+
if (entry.pending) {
|
|
335
|
+
idleTimer = armIdle();
|
|
336
|
+
entry.pending.idleTimer = idleTimer;
|
|
337
|
+
}
|
|
315
338
|
};
|
|
316
339
|
|
|
317
340
|
// Wall-clock ceiling: fires at maxTurnMs regardless of activity.
|
|
@@ -319,13 +342,14 @@ class ProcessManager {
|
|
|
319
342
|
// idle timer alive) but never produce a result. OpenClaw's only
|
|
320
343
|
// timer was wall-clock; polygram's 0.3.5 change replaced it with
|
|
321
344
|
// idle-reset, creating a gap this restores as a last-resort.
|
|
322
|
-
|
|
345
|
+
maxTimer = setTimeout(
|
|
323
346
|
() => fireTimeout(`Turn exceeded ${maxTurnMs / 1000}s wall-clock ceiling`),
|
|
324
347
|
maxTurnMs,
|
|
325
|
-
)
|
|
348
|
+
);
|
|
349
|
+
entry.pending.maxTimer = maxTimer;
|
|
326
350
|
|
|
327
351
|
// Legacy alias: some callers / tests refer to entry.pending.timer.
|
|
328
|
-
entry.pending.timer =
|
|
352
|
+
entry.pending.timer = idleTimer;
|
|
329
353
|
|
|
330
354
|
const wrappedResolve = entry.pending.resolve;
|
|
331
355
|
const wrappedReject = entry.pending.reject;
|
|
@@ -342,6 +366,7 @@ class ProcessManager {
|
|
|
342
366
|
entry.pending = null;
|
|
343
367
|
entry.inFlight = false;
|
|
344
368
|
reject(err);
|
|
369
|
+
return;
|
|
345
370
|
}
|
|
346
371
|
});
|
|
347
372
|
}
|
package/lib/telegram.js
CHANGED
|
@@ -76,7 +76,18 @@ function nextPendingId() {
|
|
|
76
76
|
return -(v + 1);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
// Methods we don't insert a `messages` row for. Reactions/deletes/markup
|
|
80
|
+
// edits never produced a chat message in the first place. editMessageText
|
|
81
|
+
// DOES modify a message, but creating a new DB row per edit collides with
|
|
82
|
+
// the UNIQUE(chat_id, msg_id) constraint on the 2nd edit — the stream
|
|
83
|
+
// edits one bubble N times in a single turn. The initial sendMessage
|
|
84
|
+
// already persisted the row; edits just update the live bubble.
|
|
85
|
+
const METHODS_WITHOUT_MSG = new Set([
|
|
86
|
+
'setMessageReaction',
|
|
87
|
+
'deleteMessage',
|
|
88
|
+
'editMessageReplyMarkup',
|
|
89
|
+
'editMessageText',
|
|
90
|
+
]);
|
|
80
91
|
|
|
81
92
|
// Derive the row's `text` column. sendSticker has no text/caption, so we
|
|
82
93
|
// synthesize `[sticker:<name>]` (or file_id as fallback) — without this the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc-client.js",
|
|
6
6
|
"bin": {
|
package/polygram.js
CHANGED
|
@@ -35,6 +35,7 @@ const { createStreamer } = require('./lib/stream-reply');
|
|
|
35
35
|
const { isAbortRequest } = require('./lib/abort-detector');
|
|
36
36
|
const { startTyping } = require('./lib/typing-indicator');
|
|
37
37
|
const { createReactionManager, classifyToolName } = require('./lib/status-reactions');
|
|
38
|
+
const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
|
|
38
39
|
const {
|
|
39
40
|
createStore: createApprovalsStore,
|
|
40
41
|
matchesAnyPattern: matchesApprovalPattern,
|
|
@@ -230,6 +231,12 @@ function sanitizeFilename(name) {
|
|
|
230
231
|
}
|
|
231
232
|
|
|
232
233
|
function extractAttachments(msg) {
|
|
234
|
+
// Media-group bundling path: when we synthesised a single message from
|
|
235
|
+
// several siblings sharing a media_group_id, the merged attachment list
|
|
236
|
+
// was pre-computed in `_mergedAttachments`. Return it directly instead
|
|
237
|
+
// of running the per-field extraction against the primary message.
|
|
238
|
+
if (Array.isArray(msg._mergedAttachments)) return msg._mergedAttachments;
|
|
239
|
+
|
|
233
240
|
const items = [];
|
|
234
241
|
if (msg.document) {
|
|
235
242
|
const d = msg.document;
|
|
@@ -548,6 +555,18 @@ async function enqueue(sessionKey, chatId, msg, bot) {
|
|
|
548
555
|
if (!processing[sessionKey]) processQueue(sessionKey);
|
|
549
556
|
}
|
|
550
557
|
|
|
558
|
+
// Sessions the operator just /stop'd (or natural-language "стоп"). Entries
|
|
559
|
+
// suppress the generic "Sorry, I couldn't process" reply below — the abort
|
|
560
|
+
// handler already sent its own "Остановлено." ack, and the subsequent
|
|
561
|
+
// handleMessage rejection from the killed subprocess would otherwise
|
|
562
|
+
// spam a second contradictory message. Cleared on first use; long-lived
|
|
563
|
+
// only if the abort kills something that never finishes rejecting.
|
|
564
|
+
const abortedSessions = new Set();
|
|
565
|
+
|
|
566
|
+
function markSessionAborted(sessionKey) {
|
|
567
|
+
abortedSessions.add(sessionKey);
|
|
568
|
+
}
|
|
569
|
+
|
|
551
570
|
async function processQueue(sessionKey) {
|
|
552
571
|
processing[sessionKey] = true;
|
|
553
572
|
while (queues[sessionKey]?.length > 0) {
|
|
@@ -555,6 +574,8 @@ async function processQueue(sessionKey) {
|
|
|
555
574
|
try {
|
|
556
575
|
await handleMessage(sessionKey, chatId, msg, bot);
|
|
557
576
|
} catch (err) {
|
|
577
|
+
const wasAborted = abortedSessions.has(sessionKey);
|
|
578
|
+
if (wasAborted) abortedSessions.delete(sessionKey);
|
|
558
579
|
// Raw err.message can carry host paths, DB columns, internal state.
|
|
559
580
|
// Surface a generic message to the user; log the detail to events
|
|
560
581
|
// so operators can still debug.
|
|
@@ -564,15 +585,18 @@ async function processQueue(sessionKey) {
|
|
|
564
585
|
msg_id: msg?.message_id,
|
|
565
586
|
error: err.message?.slice(0, 500),
|
|
566
587
|
stack: err.stack?.split('\n').slice(0, 5).join('\n'),
|
|
588
|
+
aborted: wasAborted || undefined,
|
|
567
589
|
}), 'log handler-error');
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
590
|
+
if (!wasAborted) {
|
|
591
|
+
try {
|
|
592
|
+
await tg(bot, 'sendMessage', {
|
|
593
|
+
chat_id: chatId,
|
|
594
|
+
text: `Sorry, I couldn't process that message. The operator has been notified.`,
|
|
595
|
+
reply_parameters: { message_id: msg.message_id },
|
|
596
|
+
}, { source: 'error-reply', botName: BOT_NAME });
|
|
597
|
+
} catch (replyErr) {
|
|
598
|
+
console.error(`[${sessionKey}] failed to send error reply: ${replyErr.message}`);
|
|
599
|
+
}
|
|
576
600
|
}
|
|
577
601
|
}
|
|
578
602
|
}
|
|
@@ -1143,11 +1167,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1143
1167
|
}, outMetaBase),
|
|
1144
1168
|
edit: async (messageId, text) => {
|
|
1145
1169
|
try {
|
|
1146
|
-
|
|
1170
|
+
// Route edits through tg() so applyFormatting runs (MarkdownV2
|
|
1171
|
+
// + escape). Going direct to bot.api.editMessageText would
|
|
1172
|
+
// skip formatting and leave every edit rendering literal
|
|
1173
|
+
// **bold** / `code` in the bubble — which was the visible bug
|
|
1174
|
+
// in 0.4.2 where the initial send was formatted and every
|
|
1175
|
+
// subsequent edit overwrote it with plain text.
|
|
1176
|
+
return await tg(bot, 'editMessageText', {
|
|
1177
|
+
chat_id: chatId,
|
|
1178
|
+
message_id: messageId,
|
|
1179
|
+
text,
|
|
1180
|
+
}, { source: 'bot-reply-stream-edit', botName: BOT_NAME });
|
|
1147
1181
|
} catch (err) {
|
|
1148
|
-
// Stream-edit failures would otherwise be invisible — edits
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1182
|
+
// Stream-edit failures would otherwise be invisible — edits
|
|
1183
|
+
// don't insert a messages row by default (tg() does, but we
|
|
1184
|
+
// want the failure path specifically surfaced). Log to events.
|
|
1151
1185
|
dbWrite(() => db.logEvent('telegram-edit-failed', {
|
|
1152
1186
|
chat_id: chatId, msg_id: messageId,
|
|
1153
1187
|
api_error: err.message?.slice(0, 200),
|
|
@@ -1400,42 +1434,14 @@ function createBot(token) {
|
|
|
1400
1434
|
return newChat;
|
|
1401
1435
|
}
|
|
1402
1436
|
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
reason: 'missing chat.id / message_id',
|
|
1409
|
-
}), 'log malformed-update');
|
|
1410
|
-
return;
|
|
1411
|
-
}
|
|
1412
|
-
const chatId = ctx.chat.id.toString();
|
|
1413
|
-
let chatConfig = config.chats[chatId];
|
|
1414
|
-
|
|
1415
|
-
// Auto-onboarding: /pair <CODE> from an unconfigured private chat.
|
|
1416
|
-
// Without this, the !chatConfig drop below would silently eat pair
|
|
1417
|
-
// claims from DMs the operator hasn't pre-listed — defeating the
|
|
1418
|
-
// whole point of pair codes (which exist to grant access without
|
|
1419
|
-
// pre-configuration). Group chats are not auto-onboarded: they must
|
|
1420
|
-
// still be added to config.json by the operator, because adding a
|
|
1421
|
-
// group can affect multiple users.
|
|
1422
|
-
if (!chatConfig && ctx.chat.type === 'private') {
|
|
1423
|
-
const probe = (ctx.message.text || '').trim();
|
|
1424
|
-
const pairMatch = /^\/pair(?:@\S+)?\s+(\S+)\s*$/.exec(probe);
|
|
1425
|
-
if (pairMatch) {
|
|
1426
|
-
chatConfig = await onboardPairedChat(ctx, pairMatch[1]);
|
|
1427
|
-
if (!chatConfig) return;
|
|
1428
|
-
recordInbound(ctx.message);
|
|
1429
|
-
return;
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1437
|
+
// Shared post-validation dispatch. Called directly for single messages
|
|
1438
|
+
// and for the synthesised "primary" of a media-group bundle.
|
|
1439
|
+
const dispatchRegularMessage = async (msg) => {
|
|
1440
|
+
const chatId = msg.chat.id.toString();
|
|
1441
|
+
const chatConfig = config.chats[chatId];
|
|
1432
1442
|
if (!chatConfig) return;
|
|
1433
1443
|
|
|
1434
|
-
|
|
1435
|
-
// lookups and the transcript skill.
|
|
1436
|
-
recordInbound(ctx.message);
|
|
1437
|
-
|
|
1438
|
-
const rawText = ctx.message.text || '';
|
|
1444
|
+
const rawText = msg.text || '';
|
|
1439
1445
|
const cleanText = mentionRe ? rawText.replace(mentionRe, '').trim() : rawText.trim();
|
|
1440
1446
|
|
|
1441
1447
|
// Abort: skip the queue entirely. Matches bilingual natural-language
|
|
@@ -1445,23 +1451,43 @@ function createBot(token) {
|
|
|
1445
1451
|
// the user sees the bot heard them — silent abort is worse than
|
|
1446
1452
|
// acknowledged abort.
|
|
1447
1453
|
if (isAbortRequest(cleanText)) {
|
|
1448
|
-
const threadId =
|
|
1454
|
+
const threadId = msg.message_thread_id?.toString();
|
|
1449
1455
|
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
1450
1456
|
const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
|
|
1451
1457
|
const dropped = drainQueuesForChat(chatId);
|
|
1458
|
+
// Mark BEFORE killing: the 'close' event fires almost immediately
|
|
1459
|
+
// after SIGTERM, and processQueue's catch needs to see the flag to
|
|
1460
|
+
// skip the generic error-reply. If we marked after, there'd be a
|
|
1461
|
+
// race where the error-reply slips through.
|
|
1462
|
+
if (hadActive) markSessionAborted(sessionKey);
|
|
1452
1463
|
await pm.killChat(chatId).catch(() => {});
|
|
1453
1464
|
dbWrite(() => db.logEvent('abort-requested', {
|
|
1454
|
-
chat_id: chatId, user_id:
|
|
1465
|
+
chat_id: chatId, user_id: msg.from?.id || null,
|
|
1455
1466
|
had_active: hadActive, queued_dropped: dropped,
|
|
1456
1467
|
trigger: cleanText.slice(0, 40),
|
|
1457
1468
|
}), 'log abort-requested');
|
|
1469
|
+
// Reply in the same language the user aborted in. Cyrillic-detection
|
|
1470
|
+
// is crude but reliable for ru/en (the only two cue sets we ship).
|
|
1471
|
+
const lang = /[а-яё]/i.test(cleanText) ? 'ru' : 'en';
|
|
1472
|
+
const strs = {
|
|
1473
|
+
en: {
|
|
1474
|
+
stopped: 'Stopped.',
|
|
1475
|
+
withDropped: (n) => `Stopped. Cleared ${n} queued message${n === 1 ? '' : 's'}.`,
|
|
1476
|
+
nothing: 'Nothing to stop.',
|
|
1477
|
+
},
|
|
1478
|
+
ru: {
|
|
1479
|
+
stopped: 'Остановлено.',
|
|
1480
|
+
withDropped: (n) => `Остановлено. Очередь очищена (${n}).`,
|
|
1481
|
+
nothing: 'Нечего останавливать.',
|
|
1482
|
+
},
|
|
1483
|
+
}[lang];
|
|
1458
1484
|
const reply = hadActive || dropped
|
|
1459
|
-
? (dropped ?
|
|
1460
|
-
:
|
|
1485
|
+
? (dropped ? strs.withDropped(dropped) : strs.stopped)
|
|
1486
|
+
: strs.nothing;
|
|
1461
1487
|
try {
|
|
1462
1488
|
await tg(bot, 'sendMessage', {
|
|
1463
1489
|
chat_id: chatId, text: reply,
|
|
1464
|
-
reply_parameters: { message_id:
|
|
1490
|
+
reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
|
|
1465
1491
|
...(threadId && { message_thread_id: threadId }),
|
|
1466
1492
|
}, { source: 'abort-ack', botName: BOT_NAME });
|
|
1467
1493
|
} catch {}
|
|
@@ -1472,23 +1498,90 @@ function createBot(token) {
|
|
|
1472
1498
|
const isAdminCmd = botAllowsCommands && ADMIN_CMD_RE.test(cleanText);
|
|
1473
1499
|
const isPairClaim = PAIR_CLAIM_RE.test(cleanText);
|
|
1474
1500
|
if (isAdminCmd || isPairClaim) {
|
|
1475
|
-
|
|
1476
|
-
const threadId =
|
|
1501
|
+
msg.text = cleanText;
|
|
1502
|
+
const threadId = msg.message_thread_id?.toString();
|
|
1477
1503
|
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
1478
|
-
await handleMessage(sessionKey, chatId,
|
|
1504
|
+
await handleMessage(sessionKey, chatId, msg, bot);
|
|
1479
1505
|
return;
|
|
1480
1506
|
}
|
|
1481
1507
|
|
|
1482
|
-
if (!shouldHandle(
|
|
1508
|
+
if (!shouldHandle(msg, chatConfig, botUsername)) return;
|
|
1483
1509
|
|
|
1484
1510
|
if (botUsername) {
|
|
1485
|
-
|
|
1511
|
+
msg.text = cleanText;
|
|
1486
1512
|
}
|
|
1487
1513
|
|
|
1488
|
-
const threadId =
|
|
1514
|
+
const threadId = msg.message_thread_id?.toString();
|
|
1489
1515
|
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
1516
|
+
await enqueue(sessionKey, chatId, msg, bot);
|
|
1517
|
+
};
|
|
1518
|
+
|
|
1519
|
+
// Media-group buffer: coalesce multi-photo uploads (Telegram delivers
|
|
1520
|
+
// each attachment as a separate Message sharing a `media_group_id`) into
|
|
1521
|
+
// a single synthetic turn with all attachments merged. Timer resets on
|
|
1522
|
+
// every new sibling, so as long as messages arrive faster than the
|
|
1523
|
+
// DEFAULT_FLUSH_MS window apart they stay in the same bundle.
|
|
1524
|
+
const mediaBuffer = createMediaGroupBuffer({
|
|
1525
|
+
onFlush: (messages) => {
|
|
1526
|
+
if (!messages || messages.length === 0) return;
|
|
1527
|
+
// Primary = the (usually first) message with text/caption; that's
|
|
1528
|
+
// where the user's actual prompt lives. Fall back to index 0 for
|
|
1529
|
+
// all-media-no-text groups.
|
|
1530
|
+
const primary = messages.find((m) => m.text || m.caption) || messages[0];
|
|
1531
|
+
const merged = messages.flatMap((m) => extractAttachments(m));
|
|
1532
|
+
const synthetic = { ...primary, _mergedAttachments: merged };
|
|
1533
|
+
// Carry the primary's text verbatim (dispatchRegularMessage re-cleans
|
|
1534
|
+
// the mention). Caption → text so downstream sees it uniformly.
|
|
1535
|
+
if (!synthetic.text && synthetic.caption) synthetic.text = synthetic.caption;
|
|
1536
|
+
dispatchRegularMessage(synthetic).catch((err) =>
|
|
1537
|
+
console.error(`[${BOT_NAME}] media-group dispatch error: ${err.message}`));
|
|
1538
|
+
},
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
bot.on('message', async (ctx) => {
|
|
1542
|
+
if (!isWellFormedMessage(ctx.message)) {
|
|
1543
|
+
dbWrite(() => db.logEvent('malformed-update', {
|
|
1544
|
+
bot: BOT_NAME,
|
|
1545
|
+
update_id: ctx.update?.update_id,
|
|
1546
|
+
reason: 'missing chat.id / message_id',
|
|
1547
|
+
}), 'log malformed-update');
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
const chatId = ctx.chat.id.toString();
|
|
1551
|
+
let chatConfig = config.chats[chatId];
|
|
1552
|
+
|
|
1553
|
+
// Auto-onboarding: /pair <CODE> from an unconfigured private chat.
|
|
1554
|
+
// Without this, the !chatConfig drop below would silently eat pair
|
|
1555
|
+
// claims from DMs the operator hasn't pre-listed — defeating the
|
|
1556
|
+
// whole point of pair codes (which exist to grant access without
|
|
1557
|
+
// pre-configuration). Group chats are not auto-onboarded: they must
|
|
1558
|
+
// still be added to config.json by the operator, because adding a
|
|
1559
|
+
// group can affect multiple users.
|
|
1560
|
+
if (!chatConfig && ctx.chat.type === 'private') {
|
|
1561
|
+
const probe = (ctx.message.text || '').trim();
|
|
1562
|
+
const pairMatch = /^\/pair(?:@\S+)?\s+(\S+)\s*$/.exec(probe);
|
|
1563
|
+
if (pairMatch) {
|
|
1564
|
+
chatConfig = await onboardPairedChat(ctx, pairMatch[1]);
|
|
1565
|
+
if (!chatConfig) return;
|
|
1566
|
+
recordInbound(ctx.message);
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
if (!chatConfig) return;
|
|
1571
|
+
|
|
1572
|
+
// Record every inbound msg, even unaddressed ones — needed for reply-to
|
|
1573
|
+
// lookups and the transcript skill.
|
|
1574
|
+
recordInbound(ctx.message);
|
|
1575
|
+
|
|
1576
|
+
// Multi-photo / album upload: Telegram delivers siblings as separate
|
|
1577
|
+
// Messages sharing a media_group_id. Stash each and let the buffer
|
|
1578
|
+
// dispatch them together 500ms after the last sibling arrives.
|
|
1579
|
+
if (ctx.message.media_group_id) {
|
|
1580
|
+
mediaBuffer.add(`${chatId}:${ctx.message.media_group_id}`, ctx.message);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1490
1583
|
|
|
1491
|
-
await
|
|
1584
|
+
await dispatchRegularMessage(ctx.message);
|
|
1492
1585
|
});
|
|
1493
1586
|
|
|
1494
1587
|
bot.on('callback_query:data', async (ctx) => {
|