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/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
- return pm.send(sessionKey, prompt, { timeoutMs });
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
- // ─── Typing indicator ───────────────────────────────────────────────
576
-
577
- function startTyping(bot, chatId, threadId) {
578
- const opts = threadId ? { message_thread_id: threadId } : {};
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(bot, chatId, threadId);
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: streamEnabled ? 'bot-reply-stream' : 'bot-reply',
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
- let streamer = null;
1121
- if (streamEnabled) {
1122
- streamer = createStreamer({
1123
- send: async (text) => tg(bot, 'sendMessage', {
1124
- chat_id: chatId, text,
1125
- reply_parameters: { message_id: msg.message_id },
1126
- ...(threadId && { message_thread_id: threadId }),
1127
- }, outMetaBase),
1128
- edit: async (messageId, text) => {
1129
- try {
1130
- return await bot.api.editMessageText(chatId, messageId, text);
1131
- } catch (err) {
1132
- // Stream-edit failures would otherwise be invisible — edits bypass
1133
- // tg() so there's no messages row reflecting the attempt. Log to
1134
- // events so stuck streams leave a forensic trail.
1135
- dbWrite(() => db.logEvent('telegram-edit-failed', {
1136
- chat_id: chatId, msg_id: messageId,
1137
- api_error: err.message?.slice(0, 200),
1138
- bot: BOT_NAME,
1139
- }), 'log telegram-edit-failed');
1140
- throw err;
1141
- }
1142
- },
1143
- minChars: botCfg.streamMinChars,
1144
- throttleMs: botCfg.streamThrottleMs,
1145
- logger: { error: (m) => console.error(`[${label}] ${m}`) },
1146
- });
1147
- streamers.set(sessionKey, streamer);
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 (streamer && parsed.text) {
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
- if (streamer) {
1225
- // Generic suffix err.message can leak internal paths/state.
1226
- await streamer.finalize('', { errorSuffix: 'stream interrupted' }).catch(() => {});
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
- if (streamer) streamers.delete(sessionKey);
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
- const chatConfig = config.chats[chatId];
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)`);