polygram 0.6.7 → 0.6.9

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 (2) hide show
  1. package/package.json +1 -1
  2. package/polygram.js +135 -87
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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
@@ -11,7 +11,7 @@
11
11
  * → sends to persistent claude process via stdin (stream-json)
12
12
  * → reads response from stdout (stream-json)
13
13
  * → sends reply to Telegram
14
- * → writes every in/out message to bridge.db (Phase 1: parallel write)
14
+ * → writes every in/out message to per-bot SQLite (source of truth)
15
15
  *
16
16
  * Chat commands: /model <model>, /effort <level>, /config
17
17
  */
@@ -191,7 +191,7 @@ async function readSessionContext(sessionKey, cwd) {
191
191
  } catch { return ''; }
192
192
  }
193
193
 
194
- // ─── DB writes (Phase 1 — best-effort, never throws) ────────────────
194
+ // ─── DB writes (best-effort wrapper, never throws) ──────────────────
195
195
 
196
196
  function dbWrite(fn, context) {
197
197
  if (!db) return;
@@ -200,6 +200,15 @@ function dbWrite(fn, context) {
200
200
  }
201
201
  }
202
202
 
203
+ // Convenience for the most common dbWrite pattern: log an event.
204
+ // Pre-0.6.9 every call site was logEvent(KIND, {...}),
205
+ // `log ${KIND}`) — three repeated lines for one logical operation.
206
+ // This collapses them to logEvent(KIND, {...}). Same best-effort
207
+ // semantics; never throws.
208
+ function logEvent(kind, detail) {
209
+ logEvent(kind, detail), `log ${kind}`);
210
+ }
211
+
203
212
  function recordInbound(msg) {
204
213
  const chatId = msg.chat.id.toString();
205
214
  const threadId = msg.message_thread_id?.toString() || null;
@@ -238,11 +247,15 @@ function recordInbound(msg) {
238
247
  const messageId = db.getInboundMessageId({ chat_id: chatId, msg_id: msg.message_id });
239
248
  if (!messageId) return;
240
249
  // Edit-safe insert: Telegram edited_message events re-fire
241
- // recordInbound with the same (chat_id, msg_id). Telegram doesn't
242
- // permit replacing media in an edit (only text/caption), so if rows
243
- // already exist for this message_id they're correct as-is
244
- // re-inserting would (a) duplicate them, (b) reset download_status
245
- // back to 'pending' and lose the local_path we already fetched.
250
+ // recordInbound with the same (chat_id, msg_id). polygram doesn't
251
+ // currently handle media-edit cases (Bot API does support
252
+ // editMessageMedia, but we don't process it specially the typical
253
+ // edit is text/caption). If rows already exist for this message_id
254
+ // they're correct as-is re-inserting would (a) duplicate them,
255
+ // (b) reset download_status back to 'pending' and lose the
256
+ // local_path we already fetched. If we add media-edit support
257
+ // later, this guard needs to compare file_unique_id and replace
258
+ // selectively rather than skipping wholesale.
246
259
  if (db.getAttachmentsByMessage(messageId).length > 0) return;
247
260
  for (const att of attachments) {
248
261
  db.insertAttachment({
@@ -370,17 +383,17 @@ async function transcribeVoiceAttachments(downloaded, { chatId, msgId, label, bo
370
383
  const r = await transcribeVoice(a.path, opts);
371
384
  a.transcription = r;
372
385
  console.log(`[${label}] transcribed ${a.kind} (${r.duration_sec?.toFixed?.(1) || '?'}s, ${r.text.length} chars)`);
373
- dbWrite(() => db.logEvent('voice-transcribed', {
386
+ logEvent('voice-transcribed', {
374
387
  chat_id: chatId, msg_id: msgId,
375
388
  provider: r.provider, language: r.language,
376
389
  duration_sec: r.duration_sec, chars: r.text.length,
377
390
  cost_usd: r.cost_usd,
378
- }), 'log voice-transcribed');
391
+ });
379
392
  } catch (err) {
380
393
  console.error(`[${label}] transcribe failed for ${a.name}: ${err.message}`);
381
- dbWrite(() => db.logEvent('voice-transcribe-failed', {
394
+ logEvent('voice-transcribe-failed', {
382
395
  chat_id: chatId, msg_id: msgId, name: a.name, error: err.message,
383
- }), 'log voice-transcribe-failed');
396
+ });
384
397
  }
385
398
  }));
386
399
 
@@ -411,7 +424,13 @@ async function transcribeVoiceAttachments(downloaded, { chatId, msgId, label, bo
411
424
  // downloads are capped to a small pool. Telegram's per-bot rate limit is
412
425
  // ~30 req/s, so 6 concurrent fetches is comfortably under and keeps the
413
426
  // happy path responsive without burning sockets on a 100-file edge case.
414
- const ATTACHMENT_DOWNLOAD_CONCURRENCY = 6;
427
+ // Override via `config.bot.attachmentConcurrency` (per-bot) for ops-time
428
+ // rate-limit tuning.
429
+ const ATTACHMENT_DOWNLOAD_CONCURRENCY_DEFAULT = 6;
430
+ function attachmentConcurrency() {
431
+ const v = Number(config.bot?.attachmentConcurrency);
432
+ return (Number.isInteger(v) && v > 0) ? v : ATTACHMENT_DOWNLOAD_CONCURRENCY_DEFAULT;
433
+ }
415
434
 
416
435
  // Per-attachment download. Pure function over (att, deps) → result. Pulled
417
436
  // out of the loop so downloadAttachments can run several in parallel.
@@ -515,7 +534,7 @@ async function downloadAttachments(bot, token, chatId, msg, rows) {
515
534
  const results = new Array(rows.length);
516
535
  let cursor = 0;
517
536
  const workers = Array.from(
518
- { length: Math.min(ATTACHMENT_DOWNLOAD_CONCURRENCY, rows.length) },
537
+ { length: Math.min(attachmentConcurrency(), rows.length) },
519
538
  async () => {
520
539
  while (true) {
521
540
  const idx = cursor++;
@@ -675,7 +694,14 @@ async function sendToProcess(sessionKey, prompt, context = {}) {
675
694
  // - emit a `queue-depth-warning` event if the count ever exceeds a
676
695
  // threshold (abnormal inbound rate, slow pre-work, stuck bot)
677
696
  // - (future) drain on shutdown if we want clean exit
678
- const CONCURRENT_WARN_THRESHOLD = 20;
697
+ // Threshold for the queue-depth-warning event (operator-tuned signal
698
+ // for "this session is getting hammered"). Override via
699
+ // `config.bot.queueWarnThreshold`. Below the threshold no event fires.
700
+ const CONCURRENT_WARN_THRESHOLD_DEFAULT = 20;
701
+ function queueWarnThreshold() {
702
+ const v = Number(config.bot?.queueWarnThreshold);
703
+ return (Number.isInteger(v) && v > 0) ? v : CONCURRENT_WARN_THRESHOLD_DEFAULT;
704
+ }
679
705
  const inFlightHandlers = new Map(); // sessionKey → count
680
706
 
681
707
  // Set true by the SIGTERM/SIGINT handler. Module-scoped so the
@@ -740,11 +766,12 @@ function isSessionRecentlyAborted(sessionKey) {
740
766
  function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
741
767
  const count = (inFlightHandlers.get(sessionKey) || 0) + 1;
742
768
  inFlightHandlers.set(sessionKey, count);
743
- if (count === CONCURRENT_WARN_THRESHOLD) {
744
- dbWrite(() => db.logEvent('queue-depth-warning', {
769
+ const warnAt = queueWarnThreshold();
770
+ if (count === warnAt) {
771
+ logEvent('queue-depth-warning', {
745
772
  chat_id: chatId, session_key: sessionKey,
746
- in_flight: count, threshold: CONCURRENT_WARN_THRESHOLD,
747
- }), 'log queue-depth-warning');
773
+ in_flight: count, threshold: warnAt,
774
+ });
748
775
  }
749
776
  handleMessage(sessionKey, chatId, msg, bot).catch((err) => {
750
777
  const wasAborted = isSessionRecentlyAborted(sessionKey);
@@ -757,14 +784,14 @@ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
757
784
  chat_id: chatId, msg_id: msg.message_id,
758
785
  status: wasAborted ? 'aborted' : 'failed',
759
786
  }), 'set handler_status=failed/aborted');
760
- dbWrite(() => db.logEvent('handler-error', {
787
+ logEvent('handler-error', {
761
788
  chat_id: chatId, session_key: sessionKey,
762
789
  msg_id: msg?.message_id,
763
790
  error: err.message?.slice(0, 500),
764
791
  stack: err.stack?.split('\n').slice(0, 5).join('\n'),
765
792
  aborted: wasAborted || undefined,
766
793
  replay: isReplay || undefined,
767
- }), 'log handler-error');
794
+ });
768
795
  // Suppress the user-facing error reply when:
769
796
  // - boot replay (user typed this minutes ago and moved on)
770
797
  // - polygram is shutting down (the failure is "Process killed" /
@@ -1077,11 +1104,11 @@ async function handleApprovalCallback(ctx) {
1077
1104
  return;
1078
1105
  }
1079
1106
  if (!approvalTokensEqual(row.callback_token, token)) {
1080
- dbWrite(() => db.logEvent('approval-token-mismatch', {
1107
+ logEvent('approval-token-mismatch', {
1081
1108
  id, from_user: ctx.from?.id,
1082
1109
  // Don't log the sent_token — attackers guessing it don't need to know
1083
1110
  // which prefix they got close on.
1084
- }), 'log approval-token-mismatch');
1111
+ });
1085
1112
  await ctx.answerCallbackQuery({ text: 'Bad token.', show_alert: true }).catch(() => {});
1086
1113
  return;
1087
1114
  }
@@ -1095,9 +1122,9 @@ async function handleApprovalCallback(ctx) {
1095
1122
  const apprCfg = config.bot?.approvals;
1096
1123
  const expectedChat = String(apprCfg?.adminChatId || '');
1097
1124
  if (String(ctx.chat?.id) !== expectedChat) {
1098
- dbWrite(() => db.logEvent('approval-foreign-chat', {
1125
+ logEvent('approval-foreign-chat', {
1099
1126
  id, from_chat: ctx.chat?.id, expected: expectedChat,
1100
- }), 'log approval-foreign-chat');
1127
+ });
1101
1128
  await ctx.answerCallbackQuery({ text: 'Not authorised here.', show_alert: true }).catch(() => {});
1102
1129
  return;
1103
1130
  }
@@ -1121,9 +1148,9 @@ async function handleApprovalCallback(ctx) {
1121
1148
  }).catch(() => {});
1122
1149
  return;
1123
1150
  }
1124
- dbWrite(() => db.logEvent('approval-resolved', {
1151
+ logEvent('approval-resolved', {
1125
1152
  id, status, by: userId, user, bot: BOT_NAME,
1126
- }), 'log approval-resolved');
1153
+ });
1127
1154
 
1128
1155
  // Edit the card to show the decision.
1129
1156
  try {
@@ -1184,7 +1211,7 @@ async function handleConfigCallback(ctx) {
1184
1211
  chat_id: chatId, thread_id: null, field: setting,
1185
1212
  old_value: oldValue, new_value: value,
1186
1213
  user: cmdUser, user_id: cmdUserId, source: 'inline-button',
1187
- }), `log ${setting} change`);
1214
+ });
1188
1215
 
1189
1216
  // Graceful respawn of the topic's session that the card is in. With
1190
1217
  // isolateTopics=false sessionKey is the chat (one shared session). With
@@ -1239,24 +1266,29 @@ function startApprovalSweeper(intervalMs = 30_000) {
1239
1266
  // Silent failure here is invisible death — pending approvals time out
1240
1267
  // with no operator signal. Log loudly.
1241
1268
  console.error(`[approvals] sweeper DB error: ${err.message}`);
1242
- dbWrite(() => db.logEvent('approval-sweep-failed', {
1269
+ logEvent('approval-sweep-failed', {
1243
1270
  error: err.message?.slice(0, 300),
1244
- }), 'log approval-sweep-failed');
1271
+ });
1245
1272
  return;
1246
1273
  }
1247
1274
  for (const row of rows) {
1248
1275
  approvals.resolve({ id: row.id, status: 'timeout' });
1249
- dbWrite(() => db.logEvent('approval-timeout', {
1276
+ logEvent('approval-timeout', {
1250
1277
  id: row.id, bot: BOT_NAME, tool: row.tool_name,
1251
- }), 'log approval-timeout');
1278
+ });
1252
1279
  resolveApprovalWaiter(row.id, 'timeout', 'swept');
1253
- // Best-effort: edit the card to show the timeout.
1280
+ // Best-effort: edit the card to show the timeout. Routed through
1281
+ // tg() so the edit gets the same plain-text formatting policy as
1282
+ // the original card post (no parse_mode injection from tool input)
1283
+ // AND lands in the transcript like every other outbound. Pre-0.6.8
1284
+ // this called bot.api.editMessageText directly and bypassed both.
1254
1285
  if (bot && row.approver_msg_id) {
1255
- bot.api.editMessageText(
1256
- row.approver_chat_id,
1257
- row.approver_msg_id,
1258
- approvalCardText(approvals.getById(row.id), { resolvedBy: '⏰ Timed out' }),
1259
- ).catch(() => {});
1286
+ tg(bot, 'editMessageText', {
1287
+ chat_id: row.approver_chat_id,
1288
+ message_id: row.approver_msg_id,
1289
+ text: approvalCardText(approvals.getById(row.id), { resolvedBy: '⏰ Timed out' }),
1290
+ }, { source: 'approval-card-timeout', botName: BOT_NAME, plainText: true })
1291
+ .catch((err) => console.error(`[${BOT_NAME}] approval-card-timeout edit: ${err.message}`));
1260
1292
  }
1261
1293
  }
1262
1294
  }, intervalMs);
@@ -1355,7 +1387,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1355
1387
  chat_id: chatId, thread_id: threadIdStr, field: 'model',
1356
1388
  old_value: oldModel, new_value: newModel,
1357
1389
  user: cmdUser, user_id: cmdUserId, source: 'command',
1358
- }), 'log model change');
1390
+ });
1359
1391
  const { anyActive } = requestRespawnForSession('model-change');
1360
1392
  const ver = MODEL_VERSIONS[newModel] || newModel;
1361
1393
  const suffix = anyActive ? ` — I'll switch when I finish` : '';
@@ -1375,7 +1407,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1375
1407
  chat_id: chatId, thread_id: threadIdStr, field: 'effort',
1376
1408
  old_value: oldEffort, new_value: newEffort,
1377
1409
  user: cmdUser, user_id: cmdUserId, source: 'command',
1378
- }), 'log effort change');
1410
+ });
1379
1411
  const { anyActive } = requestRespawnForSession('effort-change');
1380
1412
  const suffix = anyActive ? ` — I'll switch when I finish` : '';
1381
1413
  await sendReply(`Effort → ${newEffort}${suffix}`);
@@ -1405,10 +1437,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1405
1437
  ttlMs: args.ttl ? parsePairingTtl(args.ttl) : undefined,
1406
1438
  note: args.note || null,
1407
1439
  });
1408
- dbWrite(() => db.logEvent('pair-code-issued', {
1440
+ logEvent('pair-code-issued', {
1409
1441
  bot: BOT_NAME, by: issuerId, scope: out.scope,
1410
1442
  chat_id: out.chat_id, note: out.note,
1411
- }), 'log pair-code-issued');
1443
+ });
1412
1444
  const ttlLabel = args.ttl || '10m';
1413
1445
  const chatLabel = out.chat_id ? `chat ${out.chat_id}` : 'any chat';
1414
1446
  await sendReply(
@@ -1440,9 +1472,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1440
1472
  await sendReply('Usage: /unpair <user_id>'); return;
1441
1473
  }
1442
1474
  const n = pairings.revokeByUser({ bot_name: BOT_NAME, user_id: targetId });
1443
- dbWrite(() => db.logEvent('pair-revoked', {
1475
+ logEvent('pair-revoked', {
1444
1476
  bot: BOT_NAME, user_id: targetId, by: cmdUserId, count: n,
1445
- }), 'log pair-revoked');
1477
+ });
1446
1478
  await sendReply(n ? `Revoked ${n} pairing(s) for user ${targetId}.` : `No active pairings for user ${targetId}.`);
1447
1479
  return;
1448
1480
  }
@@ -1454,10 +1486,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1454
1486
  code, claimer_user_id: cmdUserId,
1455
1487
  chat_id: chatId, bot_name: BOT_NAME,
1456
1488
  });
1457
- dbWrite(() => db.logEvent('pair-claim-attempt', {
1489
+ logEvent('pair-claim-attempt', {
1458
1490
  bot: BOT_NAME, user_id: cmdUserId, chat_id: chatId,
1459
1491
  ok: res.ok, reason: res.reason,
1460
- }), 'log pair-claim-attempt');
1492
+ });
1461
1493
  if (res.ok) {
1462
1494
  const chatLabel = res.chat_id ? `chat ${res.chat_id}` : `every chat ${BOT_NAME} is in`;
1463
1495
  await sendReply(`Paired. You can use me in ${chatLabel}.${res.note ? `\n(${res.note})` : ''}`);
@@ -1483,7 +1515,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1483
1515
  const { accepted, rejected } = filterAttachments(rawAtts);
1484
1516
  for (const { att, reason } of rejected) {
1485
1517
  console.log(`[${label}] attachment skipped: ${att.name} (${reason})`);
1486
- dbWrite(() => db.logEvent('attachment-skipped', { chat_id: chatId, msg_id: msg.message_id, name: att.name, reason }), 'log attachment-skipped');
1518
+ logEvent('attachment-skipped', { chat_id: chatId, msg_id: msg.message_id, name: att.name, reason });
1487
1519
  }
1488
1520
  const token = config.bot?.token || '';
1489
1521
 
@@ -1533,11 +1565,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1533
1565
  // their attachment was rejected. They'd assume claude saw it
1534
1566
  // and is just answering oddly.
1535
1567
  console.error(`[${label}] failed to notify user of skipped attachments: ${err.message}`);
1536
- dbWrite(() => db.logEvent('attachment-skip-notice-failed', {
1568
+ logEvent('attachment-skip-notice-failed', {
1537
1569
  chat_id: chatId, msg_id: msg.message_id,
1538
1570
  error: err.message?.slice(0, 200),
1539
1571
  rejected_count: rejected.length,
1540
- }), 'log attachment-skip-notice-failed');
1572
+ });
1541
1573
  }
1542
1574
  }
1543
1575
 
@@ -1549,9 +1581,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1549
1581
  const stopTyping = startTyping({
1550
1582
  bot, chatId, threadId,
1551
1583
  logger: { error: (m) => console.error(`[${label}] ${m}`) },
1552
- onEvent: (e) => dbWrite(() => db.logEvent(e.kind, {
1584
+ onEvent: (e) => logEvent(e.kind, {
1553
1585
  bot: BOT_NAME, chat_id: e.chat_id, ...(e.detail || {}),
1554
- }), `log ${e.kind}`),
1586
+ }),
1555
1587
  });
1556
1588
 
1557
1589
  const botCfg = config.bot || {};
@@ -1594,11 +1626,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1594
1626
  // Stream-edit failures would otherwise be invisible — edits
1595
1627
  // don't insert a messages row by default (tg() does, but we
1596
1628
  // want the failure path specifically surfaced). Log to events.
1597
- dbWrite(() => db.logEvent('telegram-edit-failed', {
1629
+ logEvent('telegram-edit-failed', {
1598
1630
  chat_id: chatId, msg_id: messageId,
1599
1631
  api_error: err.message?.slice(0, 200),
1600
1632
  bot: BOT_NAME,
1601
- }), 'log telegram-edit-failed');
1633
+ });
1602
1634
  throw err;
1603
1635
  }
1604
1636
  },
@@ -1812,7 +1844,17 @@ function createBot(token) {
1812
1844
  async function onboardPairedChat(ctx, code) {
1813
1845
  const chatId = ctx.chat.id.toString();
1814
1846
  const userId = ctx.message.from?.id;
1815
- const send = (text) => bot.api.sendMessage(chatId, text).catch(() => {});
1847
+ // Route through tg() so onboarding replies (success notice + error
1848
+ // messages) get the standard write-before-send DB row, log on
1849
+ // failure, and the same formatting policy as every other outbound.
1850
+ // Pre-0.6.8 this was bot.api.sendMessage(...).catch(() => {}) which
1851
+ // silently dropped failures: the user typed /pair, the code was
1852
+ // claimed (DB mutated), but if the "Paired" reply failed to send
1853
+ // they'd assume it didn't work and try the now-invalid code again.
1854
+ const send = (text) => tg(bot, 'sendMessage', {
1855
+ chat_id: chatId, text,
1856
+ }, { source: 'pair-onboarding', botName: BOT_NAME }).catch((err) =>
1857
+ console.error(`[${BOT_NAME}] pair-onboarding reply: ${err.message}`));
1816
1858
 
1817
1859
  if (!userId) {
1818
1860
  await send('No user id on request.');
@@ -1823,10 +1865,10 @@ function createBot(token) {
1823
1865
  code, claimer_user_id: userId,
1824
1866
  chat_id: chatId, bot_name: BOT_NAME,
1825
1867
  });
1826
- dbWrite(() => db.logEvent('pair-claim-attempt', {
1868
+ logEvent('pair-claim-attempt', {
1827
1869
  bot: BOT_NAME, user_id: userId, chat_id: chatId,
1828
1870
  ok: res.ok, reason: res.reason, via: 'auto-onboard',
1829
- }), 'log pair-claim-attempt');
1871
+ });
1830
1872
 
1831
1873
  if (!res.ok) {
1832
1874
  const reply = res.reason === 'rate-limited'
@@ -1846,10 +1888,10 @@ function createBot(token) {
1846
1888
 
1847
1889
  const cwd = paired.cwd || firstChat.cwd;
1848
1890
  if (!cwd) {
1849
- dbWrite(() => db.logEvent('auto-onboard-failed', {
1891
+ logEvent('auto-onboard-failed', {
1850
1892
  bot: BOT_NAME, chat_id: chatId, user_id: userId,
1851
1893
  reason: 'no-cwd',
1852
- }), 'log auto-onboard-failed');
1894
+ });
1853
1895
  await send('Paired, but no working directory is configured. Ask the operator to set pairedChatDefaults.cwd.');
1854
1896
  return null;
1855
1897
  }
@@ -1870,10 +1912,10 @@ function createBot(token) {
1870
1912
  catch (err) {
1871
1913
  console.error(`[${BOT_NAME}] saveConfig on auto-onboard failed: ${err.message}`);
1872
1914
  }
1873
- dbWrite(() => db.logEvent('chat-auto-created', {
1915
+ logEvent('chat-auto-created', {
1874
1916
  bot: BOT_NAME, chat_id: chatId, user_id: userId,
1875
1917
  source: 'pair-claim', model: newChat.model, effort: newChat.effort,
1876
- }), 'log chat-auto-created');
1918
+ });
1877
1919
 
1878
1920
  const chatLabel = res.chat_id ? `chat ${res.chat_id}` : `every chat ${BOT_NAME} is in`;
1879
1921
  const suffix = res.note ? `\n(${res.note})` : '';
@@ -1916,11 +1958,11 @@ function createBot(token) {
1916
1958
  // same as killing the chat — behavior unchanged for the common case.
1917
1959
  await pm.kill(sessionKey).catch((err) =>
1918
1960
  console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
1919
- dbWrite(() => db.logEvent('abort-requested', {
1961
+ logEvent('abort-requested', {
1920
1962
  chat_id: chatId, user_id: msg.from?.id || null,
1921
1963
  had_active: hadActive,
1922
1964
  trigger: cleanText.slice(0, 40),
1923
- }), 'log abort-requested');
1965
+ });
1924
1966
  // Reply in the same language the user aborted in. Cyrillic-detection
1925
1967
  // is crude but reliable for ru/en (the only two cue sets we ship).
1926
1968
  const lang = /[а-яё]/i.test(cleanText) ? 'ru' : 'en';
@@ -2018,11 +2060,11 @@ function createBot(token) {
2018
2060
 
2019
2061
  bot.on('message', async (ctx) => {
2020
2062
  if (!isWellFormedMessage(ctx.message)) {
2021
- dbWrite(() => db.logEvent('malformed-update', {
2063
+ logEvent('malformed-update', {
2022
2064
  bot: BOT_NAME,
2023
2065
  update_id: ctx.update?.update_id,
2024
2066
  reason: 'missing chat.id / message_id',
2025
- }), 'log malformed-update');
2067
+ });
2026
2068
  return;
2027
2069
  }
2028
2070
  const chatId = ctx.chat.id.toString();
@@ -2077,21 +2119,21 @@ function createBot(token) {
2077
2119
 
2078
2120
  bot.on('edited_message', async (ctx) => {
2079
2121
  if (!isWellFormedMessage(ctx.editedMessage)) {
2080
- dbWrite(() => db.logEvent('malformed-update', {
2122
+ logEvent('malformed-update', {
2081
2123
  bot: BOT_NAME,
2082
2124
  update_id: ctx.update?.update_id,
2083
2125
  reason: 'edited_message missing chat.id / message_id',
2084
- }), 'log malformed-update');
2126
+ });
2085
2127
  return;
2086
2128
  }
2087
2129
  const chatId = ctx.editedMessage.chat.id.toString();
2088
2130
  if (!knownChat(chatId)) return;
2089
2131
  recordInbound(ctx.editedMessage);
2090
- dbWrite(() => db.logEvent('message-edited', {
2132
+ logEvent('message-edited', {
2091
2133
  chat_id: chatId,
2092
2134
  msg_id: ctx.editedMessage.message_id,
2093
2135
  user_id: ctx.editedMessage.from?.id || null,
2094
- }), 'log message-edited');
2136
+ });
2095
2137
  console.log(`[${BOT_NAME}] edited ${chatId}/${ctx.editedMessage.message_id}`);
2096
2138
  });
2097
2139
 
@@ -2103,26 +2145,26 @@ function createBot(token) {
2103
2145
  const rawNew = ctx.message?.migrate_to_chat_id;
2104
2146
  const isValidId = (v) => (typeof v === 'number' && Number.isFinite(v)) || typeof v === 'bigint';
2105
2147
  if (!isValidId(rawOld) || !isValidId(rawNew)) {
2106
- dbWrite(() => db.logEvent('malformed-update', {
2148
+ logEvent('malformed-update', {
2107
2149
  bot: BOT_NAME,
2108
2150
  update_id: ctx.update?.update_id,
2109
2151
  reason: 'migrate_to_chat_id missing / non-numeric',
2110
- }), 'log malformed-update');
2152
+ });
2111
2153
  return;
2112
2154
  }
2113
2155
  const oldChatId = rawOld.toString();
2114
2156
  const newChatId = rawNew.toString();
2115
2157
  if (oldChatId === newChatId) {
2116
- dbWrite(() => db.logEvent('malformed-update', {
2158
+ logEvent('malformed-update', {
2117
2159
  bot: BOT_NAME,
2118
2160
  update_id: ctx.update?.update_id,
2119
2161
  reason: 'migrate_to_chat_id equals current chat_id',
2120
- }), 'log malformed-update');
2162
+ });
2121
2163
  return;
2122
2164
  }
2123
2165
  console.log(`[${BOT_NAME}] chat migrated: ${oldChatId} → ${newChatId}`);
2124
2166
  dbWrite(() => db.logChatMigration(oldChatId, newChatId), 'log chat-migration');
2125
- dbWrite(() => db.logEvent('chat-migrated', { old_chat_id: oldChatId, new_chat_id: newChatId }), 'log chat-migrated event');
2167
+ logEvent('chat-migrated', { old_chat_id: oldChatId, new_chat_id: newChatId });
2126
2168
  if (config.chats[oldChatId] && !config.chats[newChatId]) {
2127
2169
  config.chats[newChatId] = { ...config.chats[oldChatId] };
2128
2170
  delete config.chats[oldChatId];
@@ -2139,12 +2181,12 @@ function createBot(token) {
2139
2181
  const updateId = err.ctx?.update?.update_id;
2140
2182
  const msgId = err.ctx?.update?.message?.message_id || err.ctx?.update?.edited_message?.message_id;
2141
2183
  console.error(`[${BOT_NAME}] update ${updateId} msg ${msgId} error: ${err.message}`);
2142
- dbWrite(() => db.logEvent('update-error', {
2184
+ logEvent('update-error', {
2143
2185
  bot: BOT_NAME,
2144
2186
  update_id: updateId,
2145
2187
  msg_id: msgId,
2146
2188
  error: err.message?.slice(0, 300),
2147
- }), 'log update-error');
2189
+ });
2148
2190
  });
2149
2191
 
2150
2192
  bot._setBotUsername = (u) => {
@@ -2250,12 +2292,12 @@ function startPollWatchdog(bot) {
2250
2292
  if (age > POLL_STALL_MS) {
2251
2293
  if (!stalled) {
2252
2294
  console.error(`[${BOT_NAME}] poll-stalled: no tick in ${Math.round(age / 1000)}s`);
2253
- dbWrite(() => db.logEvent('poll-stalled', { bot: BOT_NAME, stall_ms: age }), 'log poll-stalled');
2295
+ logEvent('poll-stalled', { bot: BOT_NAME, stall_ms: age });
2254
2296
  stalled = true;
2255
2297
  }
2256
2298
  } else if (stalled) {
2257
2299
  console.log(`[${BOT_NAME}] poll-recovered after stall`);
2258
- dbWrite(() => db.logEvent('poll-recovered', { bot: BOT_NAME }), 'log poll-recovered');
2300
+ logEvent('poll-recovered', { bot: BOT_NAME });
2259
2301
  stalled = false;
2260
2302
  }
2261
2303
  }, 30_000);
@@ -2337,7 +2379,7 @@ async function main() {
2337
2379
  },
2338
2380
  onClose: (sessionKey, code, entry) => {
2339
2381
  console.log(`[${entry.label}] Process exited (code ${code})`);
2340
- dbWrite(() => db.logEvent('process-close', { chat_id: entry.chatId, session_key: sessionKey, code }), 'log process-close');
2382
+ logEvent('process-close', { chat_id: entry.chatId, session_key: sessionKey, code });
2341
2383
  },
2342
2384
  onStreamChunk: (sessionKey, partial, entry) => {
2343
2385
  // Route to the head pending's per-turn streamer. In the 0.4.8
@@ -2412,22 +2454,22 @@ async function main() {
2412
2454
  if (remaining > 0 && db) {
2413
2455
  try {
2414
2456
  const res = db.markReplayPending({ botName: BOT_NAME });
2415
- dbWrite(() => db.logEvent('shutdown-drain', {
2457
+ logEvent('shutdown-drain', {
2416
2458
  bot: BOT_NAME,
2417
2459
  in_flight: remaining,
2418
2460
  replay_marked: res?.changes ?? 0,
2419
2461
  elapsed_ms: drainElapsed,
2420
- }), 'log shutdown-drain');
2462
+ });
2421
2463
  console.log(`[shutdown] drained ${drainElapsed}ms, ${remaining} still in-flight, ${res?.changes ?? 0} rows marked replay-pending`);
2422
2464
  } catch (err) {
2423
2465
  console.error(`[shutdown] markReplayPending failed: ${err.message}`);
2424
2466
  }
2425
2467
  } else if (db) {
2426
- dbWrite(() => db.logEvent('shutdown-drain', {
2468
+ logEvent('shutdown-drain', {
2427
2469
  bot: BOT_NAME,
2428
2470
  in_flight: 0,
2429
2471
  elapsed_ms: drainElapsed,
2430
- }), 'log shutdown-drain');
2472
+ });
2431
2473
  console.log(`[shutdown] clean drain in ${drainElapsed}ms`);
2432
2474
  }
2433
2475
 
@@ -2472,13 +2514,19 @@ async function main() {
2472
2514
  // Boot replay: re-dispatch any inbound turns that were interrupted by
2473
2515
  // the previous polygram's shutdown or crash. These are rows marked
2474
2516
  // 'dispatched', 'processing', or 'replay-pending' (set by the SIGTERM
2475
- // handler) — all within the last 30 min so we don't resurrect ancient
2476
- // work. Dedupe against already-sent outbound replies in case the
2477
- // previous instance DID answer before dying.
2517
+ // handler) — all within the last `replayWindowMs` (default 3 min) so
2518
+ // we don't resurrect ancient work. Override via
2519
+ // `config.bot.replayWindowMs` for ops tuning. Dedupe against
2520
+ // already-sent outbound replies in case the previous instance DID
2521
+ // answer before dying.
2478
2522
  try {
2479
2523
  const chatIds = Object.keys(config.chats);
2480
2524
  if (chatIds.length > 0) {
2481
- const candidates = db.getReplayCandidates({ chatIds });
2525
+ const replayWindowMs = (() => {
2526
+ const v = Number(config.bot?.replayWindowMs);
2527
+ return (Number.isInteger(v) && v > 0) ? v : undefined; // undefined → use db.js default
2528
+ })();
2529
+ const candidates = db.getReplayCandidates({ chatIds, ...(replayWindowMs && { olderThanMs: replayWindowMs }) });
2482
2530
  let replayed = 0;
2483
2531
  let skipped = 0;
2484
2532
  for (const row of candidates) {
@@ -2534,9 +2582,9 @@ async function main() {
2534
2582
  }
2535
2583
  if (candidates.length > 0) {
2536
2584
  console.log(`[replay] ${replayed} turns re-dispatched, ${skipped} skipped (already replied or no chat config)`);
2537
- dbWrite(() => db.logEvent('replay-on-boot', {
2585
+ logEvent('replay-on-boot', {
2538
2586
  bot: BOT_NAME, replayed, skipped, total: candidates.length,
2539
- }), 'log replay-on-boot');
2587
+ });
2540
2588
  }
2541
2589
  }
2542
2590
  } catch (err) {