polygram 0.6.8 → 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 +102 -73
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.6.8",
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
@@ -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;
@@ -374,17 +383,17 @@ async function transcribeVoiceAttachments(downloaded, { chatId, msgId, label, bo
374
383
  const r = await transcribeVoice(a.path, opts);
375
384
  a.transcription = r;
376
385
  console.log(`[${label}] transcribed ${a.kind} (${r.duration_sec?.toFixed?.(1) || '?'}s, ${r.text.length} chars)`);
377
- dbWrite(() => db.logEvent('voice-transcribed', {
386
+ logEvent('voice-transcribed', {
378
387
  chat_id: chatId, msg_id: msgId,
379
388
  provider: r.provider, language: r.language,
380
389
  duration_sec: r.duration_sec, chars: r.text.length,
381
390
  cost_usd: r.cost_usd,
382
- }), 'log voice-transcribed');
391
+ });
383
392
  } catch (err) {
384
393
  console.error(`[${label}] transcribe failed for ${a.name}: ${err.message}`);
385
- dbWrite(() => db.logEvent('voice-transcribe-failed', {
394
+ logEvent('voice-transcribe-failed', {
386
395
  chat_id: chatId, msg_id: msgId, name: a.name, error: err.message,
387
- }), 'log voice-transcribe-failed');
396
+ });
388
397
  }
389
398
  }));
390
399
 
@@ -415,7 +424,13 @@ async function transcribeVoiceAttachments(downloaded, { chatId, msgId, label, bo
415
424
  // downloads are capped to a small pool. Telegram's per-bot rate limit is
416
425
  // ~30 req/s, so 6 concurrent fetches is comfortably under and keeps the
417
426
  // happy path responsive without burning sockets on a 100-file edge case.
418
- 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
+ }
419
434
 
420
435
  // Per-attachment download. Pure function over (att, deps) → result. Pulled
421
436
  // out of the loop so downloadAttachments can run several in parallel.
@@ -519,7 +534,7 @@ async function downloadAttachments(bot, token, chatId, msg, rows) {
519
534
  const results = new Array(rows.length);
520
535
  let cursor = 0;
521
536
  const workers = Array.from(
522
- { length: Math.min(ATTACHMENT_DOWNLOAD_CONCURRENCY, rows.length) },
537
+ { length: Math.min(attachmentConcurrency(), rows.length) },
523
538
  async () => {
524
539
  while (true) {
525
540
  const idx = cursor++;
@@ -679,7 +694,14 @@ async function sendToProcess(sessionKey, prompt, context = {}) {
679
694
  // - emit a `queue-depth-warning` event if the count ever exceeds a
680
695
  // threshold (abnormal inbound rate, slow pre-work, stuck bot)
681
696
  // - (future) drain on shutdown if we want clean exit
682
- 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
+ }
683
705
  const inFlightHandlers = new Map(); // sessionKey → count
684
706
 
685
707
  // Set true by the SIGTERM/SIGINT handler. Module-scoped so the
@@ -744,11 +766,12 @@ function isSessionRecentlyAborted(sessionKey) {
744
766
  function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
745
767
  const count = (inFlightHandlers.get(sessionKey) || 0) + 1;
746
768
  inFlightHandlers.set(sessionKey, count);
747
- if (count === CONCURRENT_WARN_THRESHOLD) {
748
- dbWrite(() => db.logEvent('queue-depth-warning', {
769
+ const warnAt = queueWarnThreshold();
770
+ if (count === warnAt) {
771
+ logEvent('queue-depth-warning', {
749
772
  chat_id: chatId, session_key: sessionKey,
750
- in_flight: count, threshold: CONCURRENT_WARN_THRESHOLD,
751
- }), 'log queue-depth-warning');
773
+ in_flight: count, threshold: warnAt,
774
+ });
752
775
  }
753
776
  handleMessage(sessionKey, chatId, msg, bot).catch((err) => {
754
777
  const wasAborted = isSessionRecentlyAborted(sessionKey);
@@ -761,14 +784,14 @@ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
761
784
  chat_id: chatId, msg_id: msg.message_id,
762
785
  status: wasAborted ? 'aborted' : 'failed',
763
786
  }), 'set handler_status=failed/aborted');
764
- dbWrite(() => db.logEvent('handler-error', {
787
+ logEvent('handler-error', {
765
788
  chat_id: chatId, session_key: sessionKey,
766
789
  msg_id: msg?.message_id,
767
790
  error: err.message?.slice(0, 500),
768
791
  stack: err.stack?.split('\n').slice(0, 5).join('\n'),
769
792
  aborted: wasAborted || undefined,
770
793
  replay: isReplay || undefined,
771
- }), 'log handler-error');
794
+ });
772
795
  // Suppress the user-facing error reply when:
773
796
  // - boot replay (user typed this minutes ago and moved on)
774
797
  // - polygram is shutting down (the failure is "Process killed" /
@@ -1081,11 +1104,11 @@ async function handleApprovalCallback(ctx) {
1081
1104
  return;
1082
1105
  }
1083
1106
  if (!approvalTokensEqual(row.callback_token, token)) {
1084
- dbWrite(() => db.logEvent('approval-token-mismatch', {
1107
+ logEvent('approval-token-mismatch', {
1085
1108
  id, from_user: ctx.from?.id,
1086
1109
  // Don't log the sent_token — attackers guessing it don't need to know
1087
1110
  // which prefix they got close on.
1088
- }), 'log approval-token-mismatch');
1111
+ });
1089
1112
  await ctx.answerCallbackQuery({ text: 'Bad token.', show_alert: true }).catch(() => {});
1090
1113
  return;
1091
1114
  }
@@ -1099,9 +1122,9 @@ async function handleApprovalCallback(ctx) {
1099
1122
  const apprCfg = config.bot?.approvals;
1100
1123
  const expectedChat = String(apprCfg?.adminChatId || '');
1101
1124
  if (String(ctx.chat?.id) !== expectedChat) {
1102
- dbWrite(() => db.logEvent('approval-foreign-chat', {
1125
+ logEvent('approval-foreign-chat', {
1103
1126
  id, from_chat: ctx.chat?.id, expected: expectedChat,
1104
- }), 'log approval-foreign-chat');
1127
+ });
1105
1128
  await ctx.answerCallbackQuery({ text: 'Not authorised here.', show_alert: true }).catch(() => {});
1106
1129
  return;
1107
1130
  }
@@ -1125,9 +1148,9 @@ async function handleApprovalCallback(ctx) {
1125
1148
  }).catch(() => {});
1126
1149
  return;
1127
1150
  }
1128
- dbWrite(() => db.logEvent('approval-resolved', {
1151
+ logEvent('approval-resolved', {
1129
1152
  id, status, by: userId, user, bot: BOT_NAME,
1130
- }), 'log approval-resolved');
1153
+ });
1131
1154
 
1132
1155
  // Edit the card to show the decision.
1133
1156
  try {
@@ -1188,7 +1211,7 @@ async function handleConfigCallback(ctx) {
1188
1211
  chat_id: chatId, thread_id: null, field: setting,
1189
1212
  old_value: oldValue, new_value: value,
1190
1213
  user: cmdUser, user_id: cmdUserId, source: 'inline-button',
1191
- }), `log ${setting} change`);
1214
+ });
1192
1215
 
1193
1216
  // Graceful respawn of the topic's session that the card is in. With
1194
1217
  // isolateTopics=false sessionKey is the chat (one shared session). With
@@ -1243,16 +1266,16 @@ function startApprovalSweeper(intervalMs = 30_000) {
1243
1266
  // Silent failure here is invisible death — pending approvals time out
1244
1267
  // with no operator signal. Log loudly.
1245
1268
  console.error(`[approvals] sweeper DB error: ${err.message}`);
1246
- dbWrite(() => db.logEvent('approval-sweep-failed', {
1269
+ logEvent('approval-sweep-failed', {
1247
1270
  error: err.message?.slice(0, 300),
1248
- }), 'log approval-sweep-failed');
1271
+ });
1249
1272
  return;
1250
1273
  }
1251
1274
  for (const row of rows) {
1252
1275
  approvals.resolve({ id: row.id, status: 'timeout' });
1253
- dbWrite(() => db.logEvent('approval-timeout', {
1276
+ logEvent('approval-timeout', {
1254
1277
  id: row.id, bot: BOT_NAME, tool: row.tool_name,
1255
- }), 'log approval-timeout');
1278
+ });
1256
1279
  resolveApprovalWaiter(row.id, 'timeout', 'swept');
1257
1280
  // Best-effort: edit the card to show the timeout. Routed through
1258
1281
  // tg() so the edit gets the same plain-text formatting policy as
@@ -1364,7 +1387,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1364
1387
  chat_id: chatId, thread_id: threadIdStr, field: 'model',
1365
1388
  old_value: oldModel, new_value: newModel,
1366
1389
  user: cmdUser, user_id: cmdUserId, source: 'command',
1367
- }), 'log model change');
1390
+ });
1368
1391
  const { anyActive } = requestRespawnForSession('model-change');
1369
1392
  const ver = MODEL_VERSIONS[newModel] || newModel;
1370
1393
  const suffix = anyActive ? ` — I'll switch when I finish` : '';
@@ -1384,7 +1407,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1384
1407
  chat_id: chatId, thread_id: threadIdStr, field: 'effort',
1385
1408
  old_value: oldEffort, new_value: newEffort,
1386
1409
  user: cmdUser, user_id: cmdUserId, source: 'command',
1387
- }), 'log effort change');
1410
+ });
1388
1411
  const { anyActive } = requestRespawnForSession('effort-change');
1389
1412
  const suffix = anyActive ? ` — I'll switch when I finish` : '';
1390
1413
  await sendReply(`Effort → ${newEffort}${suffix}`);
@@ -1414,10 +1437,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1414
1437
  ttlMs: args.ttl ? parsePairingTtl(args.ttl) : undefined,
1415
1438
  note: args.note || null,
1416
1439
  });
1417
- dbWrite(() => db.logEvent('pair-code-issued', {
1440
+ logEvent('pair-code-issued', {
1418
1441
  bot: BOT_NAME, by: issuerId, scope: out.scope,
1419
1442
  chat_id: out.chat_id, note: out.note,
1420
- }), 'log pair-code-issued');
1443
+ });
1421
1444
  const ttlLabel = args.ttl || '10m';
1422
1445
  const chatLabel = out.chat_id ? `chat ${out.chat_id}` : 'any chat';
1423
1446
  await sendReply(
@@ -1449,9 +1472,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1449
1472
  await sendReply('Usage: /unpair <user_id>'); return;
1450
1473
  }
1451
1474
  const n = pairings.revokeByUser({ bot_name: BOT_NAME, user_id: targetId });
1452
- dbWrite(() => db.logEvent('pair-revoked', {
1475
+ logEvent('pair-revoked', {
1453
1476
  bot: BOT_NAME, user_id: targetId, by: cmdUserId, count: n,
1454
- }), 'log pair-revoked');
1477
+ });
1455
1478
  await sendReply(n ? `Revoked ${n} pairing(s) for user ${targetId}.` : `No active pairings for user ${targetId}.`);
1456
1479
  return;
1457
1480
  }
@@ -1463,10 +1486,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1463
1486
  code, claimer_user_id: cmdUserId,
1464
1487
  chat_id: chatId, bot_name: BOT_NAME,
1465
1488
  });
1466
- dbWrite(() => db.logEvent('pair-claim-attempt', {
1489
+ logEvent('pair-claim-attempt', {
1467
1490
  bot: BOT_NAME, user_id: cmdUserId, chat_id: chatId,
1468
1491
  ok: res.ok, reason: res.reason,
1469
- }), 'log pair-claim-attempt');
1492
+ });
1470
1493
  if (res.ok) {
1471
1494
  const chatLabel = res.chat_id ? `chat ${res.chat_id}` : `every chat ${BOT_NAME} is in`;
1472
1495
  await sendReply(`Paired. You can use me in ${chatLabel}.${res.note ? `\n(${res.note})` : ''}`);
@@ -1492,7 +1515,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1492
1515
  const { accepted, rejected } = filterAttachments(rawAtts);
1493
1516
  for (const { att, reason } of rejected) {
1494
1517
  console.log(`[${label}] attachment skipped: ${att.name} (${reason})`);
1495
- 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 });
1496
1519
  }
1497
1520
  const token = config.bot?.token || '';
1498
1521
 
@@ -1542,11 +1565,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1542
1565
  // their attachment was rejected. They'd assume claude saw it
1543
1566
  // and is just answering oddly.
1544
1567
  console.error(`[${label}] failed to notify user of skipped attachments: ${err.message}`);
1545
- dbWrite(() => db.logEvent('attachment-skip-notice-failed', {
1568
+ logEvent('attachment-skip-notice-failed', {
1546
1569
  chat_id: chatId, msg_id: msg.message_id,
1547
1570
  error: err.message?.slice(0, 200),
1548
1571
  rejected_count: rejected.length,
1549
- }), 'log attachment-skip-notice-failed');
1572
+ });
1550
1573
  }
1551
1574
  }
1552
1575
 
@@ -1558,9 +1581,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1558
1581
  const stopTyping = startTyping({
1559
1582
  bot, chatId, threadId,
1560
1583
  logger: { error: (m) => console.error(`[${label}] ${m}`) },
1561
- onEvent: (e) => dbWrite(() => db.logEvent(e.kind, {
1584
+ onEvent: (e) => logEvent(e.kind, {
1562
1585
  bot: BOT_NAME, chat_id: e.chat_id, ...(e.detail || {}),
1563
- }), `log ${e.kind}`),
1586
+ }),
1564
1587
  });
1565
1588
 
1566
1589
  const botCfg = config.bot || {};
@@ -1603,11 +1626,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1603
1626
  // Stream-edit failures would otherwise be invisible — edits
1604
1627
  // don't insert a messages row by default (tg() does, but we
1605
1628
  // want the failure path specifically surfaced). Log to events.
1606
- dbWrite(() => db.logEvent('telegram-edit-failed', {
1629
+ logEvent('telegram-edit-failed', {
1607
1630
  chat_id: chatId, msg_id: messageId,
1608
1631
  api_error: err.message?.slice(0, 200),
1609
1632
  bot: BOT_NAME,
1610
- }), 'log telegram-edit-failed');
1633
+ });
1611
1634
  throw err;
1612
1635
  }
1613
1636
  },
@@ -1842,10 +1865,10 @@ function createBot(token) {
1842
1865
  code, claimer_user_id: userId,
1843
1866
  chat_id: chatId, bot_name: BOT_NAME,
1844
1867
  });
1845
- dbWrite(() => db.logEvent('pair-claim-attempt', {
1868
+ logEvent('pair-claim-attempt', {
1846
1869
  bot: BOT_NAME, user_id: userId, chat_id: chatId,
1847
1870
  ok: res.ok, reason: res.reason, via: 'auto-onboard',
1848
- }), 'log pair-claim-attempt');
1871
+ });
1849
1872
 
1850
1873
  if (!res.ok) {
1851
1874
  const reply = res.reason === 'rate-limited'
@@ -1865,10 +1888,10 @@ function createBot(token) {
1865
1888
 
1866
1889
  const cwd = paired.cwd || firstChat.cwd;
1867
1890
  if (!cwd) {
1868
- dbWrite(() => db.logEvent('auto-onboard-failed', {
1891
+ logEvent('auto-onboard-failed', {
1869
1892
  bot: BOT_NAME, chat_id: chatId, user_id: userId,
1870
1893
  reason: 'no-cwd',
1871
- }), 'log auto-onboard-failed');
1894
+ });
1872
1895
  await send('Paired, but no working directory is configured. Ask the operator to set pairedChatDefaults.cwd.');
1873
1896
  return null;
1874
1897
  }
@@ -1889,10 +1912,10 @@ function createBot(token) {
1889
1912
  catch (err) {
1890
1913
  console.error(`[${BOT_NAME}] saveConfig on auto-onboard failed: ${err.message}`);
1891
1914
  }
1892
- dbWrite(() => db.logEvent('chat-auto-created', {
1915
+ logEvent('chat-auto-created', {
1893
1916
  bot: BOT_NAME, chat_id: chatId, user_id: userId,
1894
1917
  source: 'pair-claim', model: newChat.model, effort: newChat.effort,
1895
- }), 'log chat-auto-created');
1918
+ });
1896
1919
 
1897
1920
  const chatLabel = res.chat_id ? `chat ${res.chat_id}` : `every chat ${BOT_NAME} is in`;
1898
1921
  const suffix = res.note ? `\n(${res.note})` : '';
@@ -1935,11 +1958,11 @@ function createBot(token) {
1935
1958
  // same as killing the chat — behavior unchanged for the common case.
1936
1959
  await pm.kill(sessionKey).catch((err) =>
1937
1960
  console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
1938
- dbWrite(() => db.logEvent('abort-requested', {
1961
+ logEvent('abort-requested', {
1939
1962
  chat_id: chatId, user_id: msg.from?.id || null,
1940
1963
  had_active: hadActive,
1941
1964
  trigger: cleanText.slice(0, 40),
1942
- }), 'log abort-requested');
1965
+ });
1943
1966
  // Reply in the same language the user aborted in. Cyrillic-detection
1944
1967
  // is crude but reliable for ru/en (the only two cue sets we ship).
1945
1968
  const lang = /[а-яё]/i.test(cleanText) ? 'ru' : 'en';
@@ -2037,11 +2060,11 @@ function createBot(token) {
2037
2060
 
2038
2061
  bot.on('message', async (ctx) => {
2039
2062
  if (!isWellFormedMessage(ctx.message)) {
2040
- dbWrite(() => db.logEvent('malformed-update', {
2063
+ logEvent('malformed-update', {
2041
2064
  bot: BOT_NAME,
2042
2065
  update_id: ctx.update?.update_id,
2043
2066
  reason: 'missing chat.id / message_id',
2044
- }), 'log malformed-update');
2067
+ });
2045
2068
  return;
2046
2069
  }
2047
2070
  const chatId = ctx.chat.id.toString();
@@ -2096,21 +2119,21 @@ function createBot(token) {
2096
2119
 
2097
2120
  bot.on('edited_message', async (ctx) => {
2098
2121
  if (!isWellFormedMessage(ctx.editedMessage)) {
2099
- dbWrite(() => db.logEvent('malformed-update', {
2122
+ logEvent('malformed-update', {
2100
2123
  bot: BOT_NAME,
2101
2124
  update_id: ctx.update?.update_id,
2102
2125
  reason: 'edited_message missing chat.id / message_id',
2103
- }), 'log malformed-update');
2126
+ });
2104
2127
  return;
2105
2128
  }
2106
2129
  const chatId = ctx.editedMessage.chat.id.toString();
2107
2130
  if (!knownChat(chatId)) return;
2108
2131
  recordInbound(ctx.editedMessage);
2109
- dbWrite(() => db.logEvent('message-edited', {
2132
+ logEvent('message-edited', {
2110
2133
  chat_id: chatId,
2111
2134
  msg_id: ctx.editedMessage.message_id,
2112
2135
  user_id: ctx.editedMessage.from?.id || null,
2113
- }), 'log message-edited');
2136
+ });
2114
2137
  console.log(`[${BOT_NAME}] edited ${chatId}/${ctx.editedMessage.message_id}`);
2115
2138
  });
2116
2139
 
@@ -2122,26 +2145,26 @@ function createBot(token) {
2122
2145
  const rawNew = ctx.message?.migrate_to_chat_id;
2123
2146
  const isValidId = (v) => (typeof v === 'number' && Number.isFinite(v)) || typeof v === 'bigint';
2124
2147
  if (!isValidId(rawOld) || !isValidId(rawNew)) {
2125
- dbWrite(() => db.logEvent('malformed-update', {
2148
+ logEvent('malformed-update', {
2126
2149
  bot: BOT_NAME,
2127
2150
  update_id: ctx.update?.update_id,
2128
2151
  reason: 'migrate_to_chat_id missing / non-numeric',
2129
- }), 'log malformed-update');
2152
+ });
2130
2153
  return;
2131
2154
  }
2132
2155
  const oldChatId = rawOld.toString();
2133
2156
  const newChatId = rawNew.toString();
2134
2157
  if (oldChatId === newChatId) {
2135
- dbWrite(() => db.logEvent('malformed-update', {
2158
+ logEvent('malformed-update', {
2136
2159
  bot: BOT_NAME,
2137
2160
  update_id: ctx.update?.update_id,
2138
2161
  reason: 'migrate_to_chat_id equals current chat_id',
2139
- }), 'log malformed-update');
2162
+ });
2140
2163
  return;
2141
2164
  }
2142
2165
  console.log(`[${BOT_NAME}] chat migrated: ${oldChatId} → ${newChatId}`);
2143
2166
  dbWrite(() => db.logChatMigration(oldChatId, newChatId), 'log chat-migration');
2144
- 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 });
2145
2168
  if (config.chats[oldChatId] && !config.chats[newChatId]) {
2146
2169
  config.chats[newChatId] = { ...config.chats[oldChatId] };
2147
2170
  delete config.chats[oldChatId];
@@ -2158,12 +2181,12 @@ function createBot(token) {
2158
2181
  const updateId = err.ctx?.update?.update_id;
2159
2182
  const msgId = err.ctx?.update?.message?.message_id || err.ctx?.update?.edited_message?.message_id;
2160
2183
  console.error(`[${BOT_NAME}] update ${updateId} msg ${msgId} error: ${err.message}`);
2161
- dbWrite(() => db.logEvent('update-error', {
2184
+ logEvent('update-error', {
2162
2185
  bot: BOT_NAME,
2163
2186
  update_id: updateId,
2164
2187
  msg_id: msgId,
2165
2188
  error: err.message?.slice(0, 300),
2166
- }), 'log update-error');
2189
+ });
2167
2190
  });
2168
2191
 
2169
2192
  bot._setBotUsername = (u) => {
@@ -2269,12 +2292,12 @@ function startPollWatchdog(bot) {
2269
2292
  if (age > POLL_STALL_MS) {
2270
2293
  if (!stalled) {
2271
2294
  console.error(`[${BOT_NAME}] poll-stalled: no tick in ${Math.round(age / 1000)}s`);
2272
- dbWrite(() => db.logEvent('poll-stalled', { bot: BOT_NAME, stall_ms: age }), 'log poll-stalled');
2295
+ logEvent('poll-stalled', { bot: BOT_NAME, stall_ms: age });
2273
2296
  stalled = true;
2274
2297
  }
2275
2298
  } else if (stalled) {
2276
2299
  console.log(`[${BOT_NAME}] poll-recovered after stall`);
2277
- dbWrite(() => db.logEvent('poll-recovered', { bot: BOT_NAME }), 'log poll-recovered');
2300
+ logEvent('poll-recovered', { bot: BOT_NAME });
2278
2301
  stalled = false;
2279
2302
  }
2280
2303
  }, 30_000);
@@ -2356,7 +2379,7 @@ async function main() {
2356
2379
  },
2357
2380
  onClose: (sessionKey, code, entry) => {
2358
2381
  console.log(`[${entry.label}] Process exited (code ${code})`);
2359
- 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 });
2360
2383
  },
2361
2384
  onStreamChunk: (sessionKey, partial, entry) => {
2362
2385
  // Route to the head pending's per-turn streamer. In the 0.4.8
@@ -2431,22 +2454,22 @@ async function main() {
2431
2454
  if (remaining > 0 && db) {
2432
2455
  try {
2433
2456
  const res = db.markReplayPending({ botName: BOT_NAME });
2434
- dbWrite(() => db.logEvent('shutdown-drain', {
2457
+ logEvent('shutdown-drain', {
2435
2458
  bot: BOT_NAME,
2436
2459
  in_flight: remaining,
2437
2460
  replay_marked: res?.changes ?? 0,
2438
2461
  elapsed_ms: drainElapsed,
2439
- }), 'log shutdown-drain');
2462
+ });
2440
2463
  console.log(`[shutdown] drained ${drainElapsed}ms, ${remaining} still in-flight, ${res?.changes ?? 0} rows marked replay-pending`);
2441
2464
  } catch (err) {
2442
2465
  console.error(`[shutdown] markReplayPending failed: ${err.message}`);
2443
2466
  }
2444
2467
  } else if (db) {
2445
- dbWrite(() => db.logEvent('shutdown-drain', {
2468
+ logEvent('shutdown-drain', {
2446
2469
  bot: BOT_NAME,
2447
2470
  in_flight: 0,
2448
2471
  elapsed_ms: drainElapsed,
2449
- }), 'log shutdown-drain');
2472
+ });
2450
2473
  console.log(`[shutdown] clean drain in ${drainElapsed}ms`);
2451
2474
  }
2452
2475
 
@@ -2491,13 +2514,19 @@ async function main() {
2491
2514
  // Boot replay: re-dispatch any inbound turns that were interrupted by
2492
2515
  // the previous polygram's shutdown or crash. These are rows marked
2493
2516
  // 'dispatched', 'processing', or 'replay-pending' (set by the SIGTERM
2494
- // handler) — all within the last 30 min so we don't resurrect ancient
2495
- // work. Dedupe against already-sent outbound replies in case the
2496
- // 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.
2497
2522
  try {
2498
2523
  const chatIds = Object.keys(config.chats);
2499
2524
  if (chatIds.length > 0) {
2500
- 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 }) });
2501
2530
  let replayed = 0;
2502
2531
  let skipped = 0;
2503
2532
  for (const row of candidates) {
@@ -2553,9 +2582,9 @@ async function main() {
2553
2582
  }
2554
2583
  if (candidates.length > 0) {
2555
2584
  console.log(`[replay] ${replayed} turns re-dispatched, ${skipped} skipped (already replied or no chat config)`);
2556
- dbWrite(() => db.logEvent('replay-on-boot', {
2585
+ logEvent('replay-on-boot', {
2557
2586
  bot: BOT_NAME, replayed, skipped, total: candidates.length,
2558
- }), 'log replay-on-boot');
2587
+ });
2559
2588
  }
2560
2589
  }
2561
2590
  } catch (err) {