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.
- package/package.json +1 -1
- package/polygram.js +135 -87
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.6.
|
|
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
|
|
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 (
|
|
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).
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
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
|
-
|
|
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
|
-
})
|
|
391
|
+
});
|
|
379
392
|
} catch (err) {
|
|
380
393
|
console.error(`[${label}] transcribe failed for ${a.name}: ${err.message}`);
|
|
381
|
-
|
|
394
|
+
logEvent('voice-transcribe-failed', {
|
|
382
395
|
chat_id: chatId, msg_id: msgId, name: a.name, error: err.message,
|
|
383
|
-
})
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
744
|
-
|
|
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:
|
|
747
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
1125
|
+
logEvent('approval-foreign-chat', {
|
|
1099
1126
|
id, from_chat: ctx.chat?.id, expected: expectedChat,
|
|
1100
|
-
})
|
|
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
|
-
|
|
1151
|
+
logEvent('approval-resolved', {
|
|
1125
1152
|
id, status, by: userId, user, bot: BOT_NAME,
|
|
1126
|
-
})
|
|
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
|
-
})
|
|
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
|
-
|
|
1269
|
+
logEvent('approval-sweep-failed', {
|
|
1243
1270
|
error: err.message?.slice(0, 300),
|
|
1244
|
-
})
|
|
1271
|
+
});
|
|
1245
1272
|
return;
|
|
1246
1273
|
}
|
|
1247
1274
|
for (const row of rows) {
|
|
1248
1275
|
approvals.resolve({ id: row.id, status: 'timeout' });
|
|
1249
|
-
|
|
1276
|
+
logEvent('approval-timeout', {
|
|
1250
1277
|
id: row.id, bot: BOT_NAME, tool: row.tool_name,
|
|
1251
|
-
})
|
|
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
|
|
1256
|
-
row.approver_chat_id,
|
|
1257
|
-
row.approver_msg_id,
|
|
1258
|
-
approvalCardText(approvals.getById(row.id), { resolvedBy: '⏰ Timed out' }),
|
|
1259
|
-
|
|
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
|
-
})
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
1475
|
+
logEvent('pair-revoked', {
|
|
1444
1476
|
bot: BOT_NAME, user_id: targetId, by: cmdUserId, count: n,
|
|
1445
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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) =>
|
|
1584
|
+
onEvent: (e) => logEvent(e.kind, {
|
|
1553
1585
|
bot: BOT_NAME, chat_id: e.chat_id, ...(e.detail || {}),
|
|
1554
|
-
}),
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
1891
|
+
logEvent('auto-onboard-failed', {
|
|
1850
1892
|
bot: BOT_NAME, chat_id: chatId, user_id: userId,
|
|
1851
1893
|
reason: 'no-cwd',
|
|
1852
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
2152
|
+
});
|
|
2111
2153
|
return;
|
|
2112
2154
|
}
|
|
2113
2155
|
const oldChatId = rawOld.toString();
|
|
2114
2156
|
const newChatId = rawNew.toString();
|
|
2115
2157
|
if (oldChatId === newChatId) {
|
|
2116
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
2468
|
+
logEvent('shutdown-drain', {
|
|
2427
2469
|
bot: BOT_NAME,
|
|
2428
2470
|
in_flight: 0,
|
|
2429
2471
|
elapsed_ms: drainElapsed,
|
|
2430
|
-
})
|
|
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
|
|
2476
|
-
//
|
|
2477
|
-
//
|
|
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
|
|
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
|
-
|
|
2585
|
+
logEvent('replay-on-boot', {
|
|
2538
2586
|
bot: BOT_NAME, replayed, skipped, total: candidates.length,
|
|
2539
|
-
})
|
|
2587
|
+
});
|
|
2540
2588
|
}
|
|
2541
2589
|
}
|
|
2542
2590
|
} catch (err) {
|