polygram 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -19
- package/config.example.json +10 -2
- package/lib/abort-detector.js +63 -0
- package/lib/db.js +15 -0
- package/lib/net-errors.js +94 -0
- package/lib/process-manager.js +77 -23
- package/lib/status-reactions.js +168 -0
- package/lib/stream-reply.js +5 -1
- package/lib/telegram-format.js +36 -0
- package/lib/telegram.js +98 -7
- package/lib/typing-indicator.js +143 -0
- package/migrations/005-polling-state.sql +14 -0
- package/package.json +5 -4
- package/polygram.js +251 -49
- package/scripts/doctor.js +324 -0
- package/scripts/smoke.js +0 -122
package/polygram.js
CHANGED
|
@@ -32,6 +32,9 @@ const { parseBotArg, parseDbArg, filterConfigToBot } = require('./lib/config-sco
|
|
|
32
32
|
const { createStore: createPairingsStore, parseTtl: parsePairingTtl } = require('./lib/pairings');
|
|
33
33
|
const { transcribe: transcribeVoice, isVoiceAttachment } = require('./lib/voice');
|
|
34
34
|
const { createStreamer } = require('./lib/stream-reply');
|
|
35
|
+
const { isAbortRequest } = require('./lib/abort-detector');
|
|
36
|
+
const { startTyping } = require('./lib/typing-indicator');
|
|
37
|
+
const { createReactionManager, classifyToolName } = require('./lib/status-reactions');
|
|
35
38
|
const {
|
|
36
39
|
createStore: createApprovalsStore,
|
|
37
40
|
matchesAnyPattern: matchesApprovalPattern,
|
|
@@ -79,6 +82,7 @@ let ipcCloser = null;
|
|
|
79
82
|
let BOT_NAME = null; // string, frozen after boot
|
|
80
83
|
let bot = null; // grammy Bot for BOT_NAME
|
|
81
84
|
let streamers = new Map(); // sessionKey -> active Streamer (while turn is in flight)
|
|
85
|
+
let reactors = new Map(); // sessionKey -> active ReactionManager (while turn is in flight)
|
|
82
86
|
|
|
83
87
|
// Allowlist of env var names passed through to spawned Claude processes.
|
|
84
88
|
// Anything not listed here is dropped to prevent leaked secrets/ssh agents
|
|
@@ -515,7 +519,12 @@ async function sendToProcess(sessionKey, prompt) {
|
|
|
515
519
|
const chatId = getChatIdFromKey(sessionKey);
|
|
516
520
|
const chatConfig = config.chats[chatId];
|
|
517
521
|
const timeoutMs = (chatConfig.timeout || config.defaults.timeout) * 1000;
|
|
518
|
-
|
|
522
|
+
// Wall-clock ceiling (seconds). Overridable per-chat via chatConfig.maxTurn
|
|
523
|
+
// or globally via config.defaults.maxTurn. 30 min default is generous for
|
|
524
|
+
// long audits; stuck API calls rarely run that long without firing the
|
|
525
|
+
// idle timer first. Unit: seconds → milliseconds.
|
|
526
|
+
const maxTurnMs = (chatConfig.maxTurn || config.defaults?.maxTurn || 1800) * 1000;
|
|
527
|
+
return pm.send(sessionKey, prompt, { timeoutMs, maxTurnMs });
|
|
519
528
|
}
|
|
520
529
|
|
|
521
530
|
// ─── Message queue (per-chat) ───────────────────────────────────────
|
|
@@ -572,15 +581,10 @@ async function processQueue(sessionKey) {
|
|
|
572
581
|
|
|
573
582
|
const drainQueuesForChat = (chatId) => drainQueuesForChatImpl(queues, chatId);
|
|
574
583
|
|
|
575
|
-
//
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const send = () => bot.api.sendChatAction(chatId, 'typing', opts).catch(() => {});
|
|
580
|
-
send();
|
|
581
|
-
const interval = setInterval(send, 4000);
|
|
582
|
-
return () => clearInterval(interval);
|
|
583
|
-
}
|
|
584
|
+
// Typing indicator is imported from lib/typing-indicator — it adds a
|
|
585
|
+
// per-chat circuit breaker with exponential backoff so a chat that
|
|
586
|
+
// permanently 401s (bot blocked, chat deleted) doesn't have us
|
|
587
|
+
// hammering sendChatAction every 4s for the full turn duration.
|
|
584
588
|
|
|
585
589
|
// ─── Response parsing (stickers, reactions) ─────────────────────────
|
|
586
590
|
|
|
@@ -736,7 +740,7 @@ async function handleApprovalRequest(req) {
|
|
|
736
740
|
chat_id: apprCfg.adminChatId,
|
|
737
741
|
text: approvalCardText(row),
|
|
738
742
|
reply_markup: buildApprovalKeyboard(row.id, row.callback_token),
|
|
739
|
-
}, { source: 'approval-request', botName: BOT_NAME });
|
|
743
|
+
}, { source: 'approval-request', botName: BOT_NAME, plainText: true });
|
|
740
744
|
if (sent?.message_id) {
|
|
741
745
|
approvals.setApproverMsgId(row.id, sent.message_id);
|
|
742
746
|
}
|
|
@@ -1106,46 +1110,77 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1106
1110
|
});
|
|
1107
1111
|
|
|
1108
1112
|
const prompt = formatPrompt(msg, sessionCtx, downloaded);
|
|
1109
|
-
const stopTyping = startTyping(
|
|
1113
|
+
const stopTyping = startTyping({
|
|
1114
|
+
bot, chatId, threadId,
|
|
1115
|
+
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
1116
|
+
onEvent: (e) => dbWrite(() => db.logEvent(e.kind, {
|
|
1117
|
+
bot: BOT_NAME, chat_id: e.chat_id, ...(e.detail || {}),
|
|
1118
|
+
}), `log ${e.kind}`),
|
|
1119
|
+
});
|
|
1110
1120
|
|
|
1111
1121
|
const botCfg = config.bot || {};
|
|
1112
|
-
const streamEnabled = botCfg.streamReplies === true;
|
|
1113
1122
|
const outMetaBase = {
|
|
1114
|
-
source:
|
|
1123
|
+
source: 'bot-reply-stream',
|
|
1115
1124
|
botName: BOT_NAME,
|
|
1116
1125
|
model: chatConfig.model,
|
|
1117
1126
|
effort: chatConfig.effort,
|
|
1118
1127
|
};
|
|
1119
1128
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1129
|
+
// Streaming is unconditional as of 0.4.0 — matches OpenClaw's model and
|
|
1130
|
+
// eliminates the "stuck at 15min typing" complaint from the non-streaming
|
|
1131
|
+
// code path. For short responses the streamer stays idle and we fall
|
|
1132
|
+
// through to the normal send path via finalize() returning streamed=false.
|
|
1133
|
+
const streamer = createStreamer({
|
|
1134
|
+
send: async (text) => tg(bot, 'sendMessage', {
|
|
1135
|
+
chat_id: chatId, text,
|
|
1136
|
+
// allow_sending_without_reply: long-running turns give the user
|
|
1137
|
+
// plenty of time to delete their original message. Without this
|
|
1138
|
+
// flag, Telegram rejects the reply with MESSAGE_NOT_FOUND and the
|
|
1139
|
+
// whole streamed answer is lost. With it, the reply simply lands
|
|
1140
|
+
// as a standalone message.
|
|
1141
|
+
reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
|
|
1142
|
+
...(threadId && { message_thread_id: threadId }),
|
|
1143
|
+
}, outMetaBase),
|
|
1144
|
+
edit: async (messageId, text) => {
|
|
1145
|
+
try {
|
|
1146
|
+
return await bot.api.editMessageText(chatId, messageId, text);
|
|
1147
|
+
} 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.
|
|
1151
|
+
dbWrite(() => db.logEvent('telegram-edit-failed', {
|
|
1152
|
+
chat_id: chatId, msg_id: messageId,
|
|
1153
|
+
api_error: err.message?.slice(0, 200),
|
|
1154
|
+
bot: BOT_NAME,
|
|
1155
|
+
}), 'log telegram-edit-failed');
|
|
1156
|
+
throw err;
|
|
1157
|
+
}
|
|
1158
|
+
},
|
|
1159
|
+
minChars: botCfg.streamMinChars,
|
|
1160
|
+
throttleMs: botCfg.streamThrottleMs,
|
|
1161
|
+
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
1162
|
+
});
|
|
1163
|
+
streamers.set(sessionKey, streamer);
|
|
1164
|
+
|
|
1165
|
+
// Status reactions on the user's message: 👀 queued → 🤔 thinking →
|
|
1166
|
+
// 👨💻 coding / ⚡ web / 🔥 tool → 👍 done / 🤯 error. Silent (no
|
|
1167
|
+
// notifications), updates in place, one emoji per message. Uses
|
|
1168
|
+
// setMessageReaction which skips the DB row (the tg() wrapper
|
|
1169
|
+
// short-circuits that method), so no transcript spam.
|
|
1170
|
+
const reactor = createReactionManager({
|
|
1171
|
+
apply: async (emoji) => {
|
|
1172
|
+
const params = {
|
|
1173
|
+
chat_id: chatId,
|
|
1174
|
+
message_id: msg.message_id,
|
|
1175
|
+
reaction: emoji ? [{ type: 'emoji', emoji }] : [],
|
|
1176
|
+
};
|
|
1177
|
+
await tg(bot, 'setMessageReaction', params,
|
|
1178
|
+
{ source: 'status-reaction', botName: BOT_NAME });
|
|
1179
|
+
},
|
|
1180
|
+
logError: (m) => console.error(`[${label}] ${m}`),
|
|
1181
|
+
});
|
|
1182
|
+
reactors.set(sessionKey, reactor);
|
|
1183
|
+
reactor.setState('THINKING');
|
|
1149
1184
|
|
|
1150
1185
|
try {
|
|
1151
1186
|
const result = await sendToProcess(sessionKey, prompt);
|
|
@@ -1155,7 +1190,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1155
1190
|
|
|
1156
1191
|
if (result.error) {
|
|
1157
1192
|
console.error(`[${label}] Error (${elapsed}s):`, result.error);
|
|
1193
|
+
reactor.setState('ERROR');
|
|
1158
1194
|
if (!result.text) return;
|
|
1195
|
+
} else {
|
|
1196
|
+
reactor.setState('DONE');
|
|
1159
1197
|
}
|
|
1160
1198
|
|
|
1161
1199
|
if (!result.text || result.text === 'NO_REPLY') return;
|
|
@@ -1165,7 +1203,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1165
1203
|
|
|
1166
1204
|
// Streamed text path: finalise the live-edit and, if the full response
|
|
1167
1205
|
// overflows Telegram's 4096 cap, send remainder as follow-up chunks.
|
|
1168
|
-
if (
|
|
1206
|
+
if (parsed.text) {
|
|
1169
1207
|
const fin = await streamer.finalize(parsed.text);
|
|
1170
1208
|
if (fin.streamed) {
|
|
1171
1209
|
if (parsed.text.length > TG_MAX_LEN) {
|
|
@@ -1221,14 +1259,24 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1221
1259
|
|
|
1222
1260
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1223
1261
|
} catch (err) {
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1262
|
+
// Generic suffix — err.message can leak internal paths/state.
|
|
1263
|
+
await streamer.finalize('', { errorSuffix: 'stream interrupted' }).catch(() => {});
|
|
1264
|
+
// Signal the failure to the user's message reaction. Timeout gets its
|
|
1265
|
+
// own face; anything else is generic error.
|
|
1266
|
+
if (/wall-clock ceiling|idle with no Claude activity/i.test(err?.message || '')) {
|
|
1267
|
+
reactor.setState('TIMEOUT');
|
|
1268
|
+
} else {
|
|
1269
|
+
reactor.setState('ERROR');
|
|
1227
1270
|
}
|
|
1228
1271
|
throw err;
|
|
1229
1272
|
} finally {
|
|
1230
1273
|
stopTyping();
|
|
1231
|
-
|
|
1274
|
+
streamers.delete(sessionKey);
|
|
1275
|
+
// Give the reactor a beat to flush the terminal state (DONE/ERROR/TIMEOUT
|
|
1276
|
+
// bypass throttle so this is instant in practice; the stop() below
|
|
1277
|
+
// guards against any late transition leaking after the turn ends).
|
|
1278
|
+
reactor.stop();
|
|
1279
|
+
reactors.delete(sessionKey);
|
|
1232
1280
|
}
|
|
1233
1281
|
}
|
|
1234
1282
|
|
|
@@ -1271,6 +1319,87 @@ function createBot(token) {
|
|
|
1271
1319
|
// not another bot's problem.
|
|
1272
1320
|
const knownChat = (chatId) => !!config.chats[chatId];
|
|
1273
1321
|
|
|
1322
|
+
// Claim a pair code from an unconfigured private chat and persist a new
|
|
1323
|
+
// chat entry so subsequent messages go through the normal flow. Replies
|
|
1324
|
+
// to the user on both success and failure. Returns the new chatConfig on
|
|
1325
|
+
// success, null on any failure.
|
|
1326
|
+
//
|
|
1327
|
+
// The new chat inherits cwd/agent from bot-level pairedChatDefaults if
|
|
1328
|
+
// present, otherwise from the first existing chat the bot owns — on the
|
|
1329
|
+
// reasonable assumption that paired DMs should behave like other DMs for
|
|
1330
|
+
// this bot. Operator can override by setting config.bots.<bot>.pairedChatDefaults.
|
|
1331
|
+
async function onboardPairedChat(ctx, code) {
|
|
1332
|
+
const chatId = ctx.chat.id.toString();
|
|
1333
|
+
const userId = ctx.message.from?.id;
|
|
1334
|
+
const send = (text) => bot.api.sendMessage(chatId, text).catch(() => {});
|
|
1335
|
+
|
|
1336
|
+
if (!userId) {
|
|
1337
|
+
await send('No user id on request.');
|
|
1338
|
+
return null;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
const res = pairings.claimCode({
|
|
1342
|
+
code, claimer_user_id: userId,
|
|
1343
|
+
chat_id: chatId, bot_name: BOT_NAME,
|
|
1344
|
+
});
|
|
1345
|
+
dbWrite(() => db.logEvent('pair-claim-attempt', {
|
|
1346
|
+
bot: BOT_NAME, user_id: userId, chat_id: chatId,
|
|
1347
|
+
ok: res.ok, reason: res.reason, via: 'auto-onboard',
|
|
1348
|
+
}), 'log pair-claim-attempt');
|
|
1349
|
+
|
|
1350
|
+
if (!res.ok) {
|
|
1351
|
+
const reply = res.reason === 'rate-limited'
|
|
1352
|
+
? 'Too many attempts. Try again later.'
|
|
1353
|
+
: 'Invalid or expired code.';
|
|
1354
|
+
await send(reply);
|
|
1355
|
+
return null;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const paired = config.bot?.pairedChatDefaults || {};
|
|
1359
|
+
const globals = config.defaults || {};
|
|
1360
|
+
const firstChat = Object.values(config.chats)[0] || {};
|
|
1361
|
+
const chatName = paired.name
|
|
1362
|
+
|| (ctx.chat.username && `@${ctx.chat.username}`)
|
|
1363
|
+
|| ctx.chat.first_name
|
|
1364
|
+
|| `User ${userId}`;
|
|
1365
|
+
|
|
1366
|
+
const cwd = paired.cwd || firstChat.cwd;
|
|
1367
|
+
if (!cwd) {
|
|
1368
|
+
dbWrite(() => db.logEvent('auto-onboard-failed', {
|
|
1369
|
+
bot: BOT_NAME, chat_id: chatId, user_id: userId,
|
|
1370
|
+
reason: 'no-cwd',
|
|
1371
|
+
}), 'log auto-onboard-failed');
|
|
1372
|
+
await send('Paired, but no working directory is configured. Ask the operator to set pairedChatDefaults.cwd.');
|
|
1373
|
+
return null;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const newChat = {
|
|
1377
|
+
name: chatName,
|
|
1378
|
+
bot: BOT_NAME,
|
|
1379
|
+
agent: paired.agent || firstChat.agent,
|
|
1380
|
+
model: paired.model || globals.model || 'sonnet',
|
|
1381
|
+
effort: paired.effort || globals.effort || 'medium',
|
|
1382
|
+
cwd,
|
|
1383
|
+
timeout: paired.timeout || globals.timeout || 600,
|
|
1384
|
+
};
|
|
1385
|
+
if (paired.requireMention != null) newChat.requireMention = paired.requireMention;
|
|
1386
|
+
|
|
1387
|
+
config.chats[chatId] = newChat;
|
|
1388
|
+
try { saveConfig(); }
|
|
1389
|
+
catch (err) {
|
|
1390
|
+
console.error(`[${BOT_NAME}] saveConfig on auto-onboard failed: ${err.message}`);
|
|
1391
|
+
}
|
|
1392
|
+
dbWrite(() => db.logEvent('chat-auto-created', {
|
|
1393
|
+
bot: BOT_NAME, chat_id: chatId, user_id: userId,
|
|
1394
|
+
source: 'pair-claim', model: newChat.model, effort: newChat.effort,
|
|
1395
|
+
}), 'log chat-auto-created');
|
|
1396
|
+
|
|
1397
|
+
const chatLabel = res.chat_id ? `chat ${res.chat_id}` : `every chat ${BOT_NAME} is in`;
|
|
1398
|
+
const suffix = res.note ? `\n(${res.note})` : '';
|
|
1399
|
+
await send(`Paired. You can use me in ${chatLabel}.${suffix}`);
|
|
1400
|
+
return newChat;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1274
1403
|
bot.on('message', async (ctx) => {
|
|
1275
1404
|
if (!isWellFormedMessage(ctx.message)) {
|
|
1276
1405
|
dbWrite(() => db.logEvent('malformed-update', {
|
|
@@ -1281,7 +1410,25 @@ function createBot(token) {
|
|
|
1281
1410
|
return;
|
|
1282
1411
|
}
|
|
1283
1412
|
const chatId = ctx.chat.id.toString();
|
|
1284
|
-
|
|
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
|
+
}
|
|
1285
1432
|
if (!chatConfig) return;
|
|
1286
1433
|
|
|
1287
1434
|
// Record every inbound msg, even unaddressed ones — needed for reply-to
|
|
@@ -1291,6 +1438,36 @@ function createBot(token) {
|
|
|
1291
1438
|
const rawText = ctx.message.text || '';
|
|
1292
1439
|
const cleanText = mentionRe ? rawText.replace(mentionRe, '').trim() : rawText.trim();
|
|
1293
1440
|
|
|
1441
|
+
// Abort: skip the queue entirely. Matches bilingual natural-language
|
|
1442
|
+
// cues ("stop" / "стоп" / "cancel" / "отмена" / …) and explicit
|
|
1443
|
+
// slash commands (/stop, /abort, /cancel). Kills the active Claude
|
|
1444
|
+
// subprocess and drains queued messages for this chat. Replies so
|
|
1445
|
+
// the user sees the bot heard them — silent abort is worse than
|
|
1446
|
+
// acknowledged abort.
|
|
1447
|
+
if (isAbortRequest(cleanText)) {
|
|
1448
|
+
const threadId = ctx.message.message_thread_id?.toString();
|
|
1449
|
+
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
1450
|
+
const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
|
|
1451
|
+
const dropped = drainQueuesForChat(chatId);
|
|
1452
|
+
await pm.killChat(chatId).catch(() => {});
|
|
1453
|
+
dbWrite(() => db.logEvent('abort-requested', {
|
|
1454
|
+
chat_id: chatId, user_id: ctx.message.from?.id || null,
|
|
1455
|
+
had_active: hadActive, queued_dropped: dropped,
|
|
1456
|
+
trigger: cleanText.slice(0, 40),
|
|
1457
|
+
}), 'log abort-requested');
|
|
1458
|
+
const reply = hadActive || dropped
|
|
1459
|
+
? (dropped ? `Остановлено. Очередь очищена (${dropped}).` : 'Остановлено.')
|
|
1460
|
+
: 'Нечего останавливать.';
|
|
1461
|
+
try {
|
|
1462
|
+
await tg(bot, 'sendMessage', {
|
|
1463
|
+
chat_id: chatId, text: reply,
|
|
1464
|
+
reply_parameters: { message_id: ctx.message.message_id, allow_sending_without_reply: true },
|
|
1465
|
+
...(threadId && { message_thread_id: threadId }),
|
|
1466
|
+
}, { source: 'abort-ack', botName: BOT_NAME });
|
|
1467
|
+
} catch {}
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1294
1471
|
const botAllowsCommands = !!config.bot?.allowConfigCommands;
|
|
1295
1472
|
const isAdminCmd = botAllowsCommands && ADMIN_CMD_RE.test(cleanText);
|
|
1296
1473
|
const isPairClaim = PAIR_CLAIM_RE.test(cleanText);
|
|
@@ -1409,7 +1586,21 @@ async function pollBot(bot) {
|
|
|
1409
1586
|
|
|
1410
1587
|
await bot.api.deleteWebhook();
|
|
1411
1588
|
|
|
1589
|
+
// Restore polling offset from DB so a restart doesn't re-process the
|
|
1590
|
+
// backlog Telegram has accumulated while we were down. Grammy's in-memory
|
|
1591
|
+
// offset resets to 0 each boot, which makes getUpdates return every
|
|
1592
|
+
// un-confirmed update since the last ack — for an overnight outage that
|
|
1593
|
+
// can mean replaying dozens of stale messages.
|
|
1412
1594
|
let offset = 0;
|
|
1595
|
+
try {
|
|
1596
|
+
const saved = db?.getPollingOffset?.(BOT_NAME);
|
|
1597
|
+
if (saved && saved > 0) {
|
|
1598
|
+
offset = saved + 1;
|
|
1599
|
+
console.log(`[${BOT_NAME}] resuming polling from update_id ${saved}`);
|
|
1600
|
+
}
|
|
1601
|
+
} catch (err) {
|
|
1602
|
+
console.error(`[${BOT_NAME}] getPollingOffset failed: ${err.message}`);
|
|
1603
|
+
}
|
|
1413
1604
|
let running = true;
|
|
1414
1605
|
bot._lastPollTs = Date.now();
|
|
1415
1606
|
|
|
@@ -1446,6 +1637,13 @@ async function pollBot(bot) {
|
|
|
1446
1637
|
console.error(`[${BOT_NAME}] Handler error:`, err.message);
|
|
1447
1638
|
}
|
|
1448
1639
|
}
|
|
1640
|
+
// Persist offset after batch dispatch so a crash mid-batch only risks
|
|
1641
|
+
// re-processing the unacked updates. We write only on non-empty batches
|
|
1642
|
+
// to avoid churning the row on every 25s idle poll.
|
|
1643
|
+
if (updates.length > 0) {
|
|
1644
|
+
dbWrite(() => db.savePollingOffset(BOT_NAME, updates[updates.length - 1].update_id),
|
|
1645
|
+
'save polling offset');
|
|
1646
|
+
}
|
|
1449
1647
|
// No sleep on the success path: long-poll already blocks up to 25s
|
|
1450
1648
|
// when idle. Sleeping here would add latency with no gain.
|
|
1451
1649
|
} catch (err) {
|
|
@@ -1567,6 +1765,10 @@ async function main() {
|
|
|
1567
1765
|
const s = streamers.get(sessionKey);
|
|
1568
1766
|
if (s) s.onChunk(partial).catch(() => {});
|
|
1569
1767
|
},
|
|
1768
|
+
onToolUse: (sessionKey, toolName) => {
|
|
1769
|
+
const r = reactors.get(sessionKey);
|
|
1770
|
+
if (r) r.setState(classifyToolName(toolName));
|
|
1771
|
+
},
|
|
1570
1772
|
});
|
|
1571
1773
|
|
|
1572
1774
|
console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
|