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.
@@ -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.1",
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 };
@@ -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 (entry.pending?.idleTimer) clearTimeout(entry.pending.idleTimer);
277
- if (entry.pending?.maxTimer) clearTimeout(entry.pending.maxTimer);
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() so these timers don't hold the node event loop open in
303
- // tests or when the parent process wants to exit. Real-world polygram
304
- // stays alive via grammy's poll loop + stdin/stdout pipes; the timers
305
- // don't need to keep it alive on their own.
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
- ).unref();
310
- entry.pending.idleTimer = armIdle();
329
+ );
330
+ idleTimer = armIdle();
331
+ entry.pending.idleTimer = idleTimer;
311
332
  entry.pending.resetIdleTimer = () => {
312
- if (!entry.pending) return;
313
- clearTimeout(entry.pending.idleTimer);
314
- entry.pending.idleTimer = armIdle();
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
- entry.pending.maxTimer = setTimeout(
345
+ maxTimer = setTimeout(
323
346
  () => fireTimeout(`Turn exceeded ${maxTurnMs / 1000}s wall-clock ceiling`),
324
347
  maxTurnMs,
325
- ).unref();
348
+ );
349
+ entry.pending.maxTimer = maxTimer;
326
350
 
327
351
  // Legacy alias: some callers / tests refer to entry.pending.timer.
328
- entry.pending.timer = entry.pending.idleTimer;
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
- const METHODS_WITHOUT_MSG = new Set(['setMessageReaction', 'deleteMessage', 'editMessageReplyMarkup']);
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.1",
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
- try {
569
- await tg(bot, 'sendMessage', {
570
- chat_id: chatId,
571
- text: `Sorry, I couldn't process that message. The operator has been notified.`,
572
- reply_parameters: { message_id: msg.message_id },
573
- }, { source: 'error-reply', botName: BOT_NAME });
574
- } catch (replyErr) {
575
- console.error(`[${sessionKey}] failed to send error reply: ${replyErr.message}`);
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
- return await bot.api.editMessageText(chatId, messageId, text);
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 bypass
1149
- // tg() so there's no messages row reflecting the attempt. Log to
1150
- // events so stuck streams leave a forensic trail.
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
- bot.on('message', async (ctx) => {
1404
- if (!isWellFormedMessage(ctx.message)) {
1405
- dbWrite(() => db.logEvent('malformed-update', {
1406
- bot: BOT_NAME,
1407
- update_id: ctx.update?.update_id,
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
- // Record every inbound msg, even unaddressed ones — needed for reply-to
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 = ctx.message.message_thread_id?.toString();
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: ctx.message.from?.id || null,
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 ? `Остановлено. Очередь очищена (${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: ctx.message.message_id, allow_sending_without_reply: true },
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
- ctx.message.text = cleanText;
1476
- const threadId = ctx.message.message_thread_id?.toString();
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, ctx.message, bot);
1504
+ await handleMessage(sessionKey, chatId, msg, bot);
1479
1505
  return;
1480
1506
  }
1481
1507
 
1482
- if (!shouldHandle(ctx.message, chatConfig, botUsername)) return;
1508
+ if (!shouldHandle(msg, chatConfig, botUsername)) return;
1483
1509
 
1484
1510
  if (botUsername) {
1485
- ctx.message.text = cleanText;
1511
+ msg.text = cleanText;
1486
1512
  }
1487
1513
 
1488
- const threadId = ctx.message.message_thread_id?.toString();
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 enqueue(sessionKey, chatId, ctx.message, bot);
1584
+ await dispatchRegularMessage(ctx.message);
1492
1585
  });
1493
1586
 
1494
1587
  bot.on('callback_query:data', async (ctx) => {