polygram 0.4.2 → 0.4.6

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.4.2",
4
+ "version": "0.4.6",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -281,9 +281,17 @@ class ProcessManager {
281
281
  entry.pending = { resolve, reject };
282
282
  entry.streamText = '';
283
283
 
284
+ // Timer handles kept in closure vars (not entry.pending), because
285
+ // the result-event handler in rl.on('line') sets entry.pending = null
286
+ // BEFORE calling the wrapped resolve. Reading from entry.pending
287
+ // after null-out gave undefined → clearTimeout was never called →
288
+ // the default 30-min maxTurnMs timer stayed armed and held Node's
289
+ // event loop open, hanging the test runner on CI.
290
+ let idleTimer = null;
291
+ let maxTimer = null;
284
292
  const clearTimers = () => {
285
- if (entry.pending?.idleTimer) clearTimeout(entry.pending.idleTimer);
286
- if (entry.pending?.maxTimer) clearTimeout(entry.pending.maxTimer);
293
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
294
+ if (maxTimer) { clearTimeout(maxTimer); maxTimer = null; }
287
295
  };
288
296
 
289
297
  // Timer fire path. New in 0.3.9: after rejecting, SIGTERM the
@@ -308,19 +316,25 @@ class ProcessManager {
308
316
  // Idle timeout: counts N seconds of SILENCE from Claude. Reset on
309
317
  // every assistant event so long productive turns (multi-tool
310
318
  // reasoning) don't falsely trip.
311
- // .unref() so these timers don't hold the node event loop open in
312
- // tests or when the parent process wants to exit. Real-world polygram
313
- // stays alive via grammy's poll loop + stdin/stdout pipes; the timers
314
- // don't need to keep it alive on their own.
319
+ // Note on .unref(): an earlier revision called unref() on both
320
+ // timers to avoid holding the node event loop open in tests. That
321
+ // broke Node's test runner on CI ("Promise resolution is still
322
+ // pending but the event loop has already resolved") — the runner
323
+ // detects unref'd timers as a drained loop and cancels awaiters
324
+ // before the timer can fire. Production polygram stays alive via
325
+ // grammy's poll loop + child process pipes; we don't need unref.
315
326
  const armIdle = () => setTimeout(
316
327
  () => fireTimeout(`Timeout: ${timeoutMs / 1000}s idle with no Claude activity`),
317
328
  timeoutMs,
318
- ).unref();
319
- entry.pending.idleTimer = armIdle();
329
+ );
330
+ idleTimer = armIdle();
331
+ entry.pending.idleTimer = idleTimer;
320
332
  entry.pending.resetIdleTimer = () => {
321
- if (!entry.pending) return;
322
- clearTimeout(entry.pending.idleTimer);
323
- entry.pending.idleTimer = armIdle();
333
+ if (idleTimer) clearTimeout(idleTimer);
334
+ if (entry.pending) {
335
+ idleTimer = armIdle();
336
+ entry.pending.idleTimer = idleTimer;
337
+ }
324
338
  };
325
339
 
326
340
  // Wall-clock ceiling: fires at maxTurnMs regardless of activity.
@@ -328,13 +342,14 @@ class ProcessManager {
328
342
  // idle timer alive) but never produce a result. OpenClaw's only
329
343
  // timer was wall-clock; polygram's 0.3.5 change replaced it with
330
344
  // idle-reset, creating a gap this restores as a last-resort.
331
- entry.pending.maxTimer = setTimeout(
345
+ maxTimer = setTimeout(
332
346
  () => fireTimeout(`Turn exceeded ${maxTurnMs / 1000}s wall-clock ceiling`),
333
347
  maxTurnMs,
334
- ).unref();
348
+ );
349
+ entry.pending.maxTimer = maxTimer;
335
350
 
336
351
  // Legacy alias: some callers / tests refer to entry.pending.timer.
337
- entry.pending.timer = entry.pending.idleTimer;
352
+ entry.pending.timer = idleTimer;
338
353
 
339
354
  const wrappedResolve = entry.pending.resolve;
340
355
  const wrappedReject = entry.pending.reject;
@@ -351,6 +366,7 @@ class ProcessManager {
351
366
  entry.pending = null;
352
367
  entry.inFlight = false;
353
368
  reject(err);
369
+ return;
354
370
  }
355
371
  });
356
372
  }
package/lib/telegram.js CHANGED
@@ -76,7 +76,18 @@ function nextPendingId() {
76
76
  return -(v + 1);
77
77
  }
78
78
 
79
- const METHODS_WITHOUT_MSG = new Set(['setMessageReaction', 'deleteMessage', 'editMessageReplyMarkup']);
79
+ // Methods we don't insert a `messages` row for. Reactions/deletes/markup
80
+ // edits never produced a chat message in the first place. editMessageText
81
+ // DOES modify a message, but creating a new DB row per edit collides with
82
+ // the UNIQUE(chat_id, msg_id) constraint on the 2nd edit — the stream
83
+ // edits one bubble N times in a single turn. The initial sendMessage
84
+ // already persisted the row; edits just update the live bubble.
85
+ const METHODS_WITHOUT_MSG = new Set([
86
+ 'setMessageReaction',
87
+ 'deleteMessage',
88
+ 'editMessageReplyMarkup',
89
+ 'editMessageText',
90
+ ]);
80
91
 
81
92
  // Derive the row's `text` column. sendSticker has no text/caption, so we
82
93
  // synthesize `[sticker:<name>]` (or file_id as fallback) — without this the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.4.2",
3
+ "version": "0.4.6",
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
@@ -555,6 +555,18 @@ async function enqueue(sessionKey, chatId, msg, bot) {
555
555
  if (!processing[sessionKey]) processQueue(sessionKey);
556
556
  }
557
557
 
558
+ // Sessions the operator just /stop'd (or natural-language "стоп"). Entries
559
+ // suppress the generic "Sorry, I couldn't process" reply below — the abort
560
+ // handler already sent its own "Остановлено." ack, and the subsequent
561
+ // handleMessage rejection from the killed subprocess would otherwise
562
+ // spam a second contradictory message. Cleared on first use; long-lived
563
+ // only if the abort kills something that never finishes rejecting.
564
+ const abortedSessions = new Set();
565
+
566
+ function markSessionAborted(sessionKey) {
567
+ abortedSessions.add(sessionKey);
568
+ }
569
+
558
570
  async function processQueue(sessionKey) {
559
571
  processing[sessionKey] = true;
560
572
  while (queues[sessionKey]?.length > 0) {
@@ -562,6 +574,8 @@ async function processQueue(sessionKey) {
562
574
  try {
563
575
  await handleMessage(sessionKey, chatId, msg, bot);
564
576
  } catch (err) {
577
+ const wasAborted = abortedSessions.has(sessionKey);
578
+ if (wasAborted) abortedSessions.delete(sessionKey);
565
579
  // Raw err.message can carry host paths, DB columns, internal state.
566
580
  // Surface a generic message to the user; log the detail to events
567
581
  // so operators can still debug.
@@ -571,15 +585,18 @@ async function processQueue(sessionKey) {
571
585
  msg_id: msg?.message_id,
572
586
  error: err.message?.slice(0, 500),
573
587
  stack: err.stack?.split('\n').slice(0, 5).join('\n'),
588
+ aborted: wasAborted || undefined,
574
589
  }), 'log handler-error');
575
- try {
576
- await tg(bot, 'sendMessage', {
577
- chat_id: chatId,
578
- text: `Sorry, I couldn't process that message. The operator has been notified.`,
579
- reply_parameters: { message_id: msg.message_id },
580
- }, { source: 'error-reply', botName: BOT_NAME });
581
- } catch (replyErr) {
582
- console.error(`[${sessionKey}] failed to send error reply: ${replyErr.message}`);
590
+ if (!wasAborted) {
591
+ try {
592
+ await tg(bot, 'sendMessage', {
593
+ chat_id: chatId,
594
+ text: `Sorry, I couldn't process that message. The operator has been notified.`,
595
+ reply_parameters: { message_id: msg.message_id },
596
+ }, { source: 'error-reply', botName: BOT_NAME });
597
+ } catch (replyErr) {
598
+ console.error(`[${sessionKey}] failed to send error reply: ${replyErr.message}`);
599
+ }
583
600
  }
584
601
  }
585
602
  }
@@ -1150,11 +1167,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1150
1167
  }, outMetaBase),
1151
1168
  edit: async (messageId, text) => {
1152
1169
  try {
1153
- return await bot.api.editMessageText(chatId, messageId, text);
1170
+ // Route edits through tg() so applyFormatting runs (MarkdownV2
1171
+ // + escape). Going direct to bot.api.editMessageText would
1172
+ // skip formatting and leave every edit rendering literal
1173
+ // **bold** / `code` in the bubble — which was the visible bug
1174
+ // in 0.4.2 where the initial send was formatted and every
1175
+ // subsequent edit overwrote it with plain text.
1176
+ return await tg(bot, 'editMessageText', {
1177
+ chat_id: chatId,
1178
+ message_id: messageId,
1179
+ text,
1180
+ }, { source: 'bot-reply-stream-edit', botName: BOT_NAME });
1154
1181
  } catch (err) {
1155
- // Stream-edit failures would otherwise be invisible — edits bypass
1156
- // tg() so there's no messages row reflecting the attempt. Log to
1157
- // events so stuck streams leave a forensic trail.
1182
+ // Stream-edit failures would otherwise be invisible — edits
1183
+ // don't insert a messages row by default (tg() does, but we
1184
+ // want the failure path specifically surfaced). Log to events.
1158
1185
  dbWrite(() => db.logEvent('telegram-edit-failed', {
1159
1186
  chat_id: chatId, msg_id: messageId,
1160
1187
  api_error: err.message?.slice(0, 200),
@@ -1428,15 +1455,35 @@ function createBot(token) {
1428
1455
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1429
1456
  const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
1430
1457
  const dropped = drainQueuesForChat(chatId);
1458
+ // Mark BEFORE killing: the 'close' event fires almost immediately
1459
+ // after SIGTERM, and processQueue's catch needs to see the flag to
1460
+ // skip the generic error-reply. If we marked after, there'd be a
1461
+ // race where the error-reply slips through.
1462
+ if (hadActive) markSessionAborted(sessionKey);
1431
1463
  await pm.killChat(chatId).catch(() => {});
1432
1464
  dbWrite(() => db.logEvent('abort-requested', {
1433
1465
  chat_id: chatId, user_id: msg.from?.id || null,
1434
1466
  had_active: hadActive, queued_dropped: dropped,
1435
1467
  trigger: cleanText.slice(0, 40),
1436
1468
  }), 'log abort-requested');
1469
+ // Reply in the same language the user aborted in. Cyrillic-detection
1470
+ // is crude but reliable for ru/en (the only two cue sets we ship).
1471
+ const lang = /[а-яё]/i.test(cleanText) ? 'ru' : 'en';
1472
+ const strs = {
1473
+ en: {
1474
+ stopped: 'Stopped.',
1475
+ withDropped: (n) => `Stopped. Cleared ${n} queued message${n === 1 ? '' : 's'}.`,
1476
+ nothing: 'Nothing to stop.',
1477
+ },
1478
+ ru: {
1479
+ stopped: 'Остановлено.',
1480
+ withDropped: (n) => `Остановлено. Очередь очищена (${n}).`,
1481
+ nothing: 'Нечего останавливать.',
1482
+ },
1483
+ }[lang];
1437
1484
  const reply = hadActive || dropped
1438
- ? (dropped ? `Остановлено. Очередь очищена (${dropped}).` : 'Остановлено.')
1439
- : 'Нечего останавливать.';
1485
+ ? (dropped ? strs.withDropped(dropped) : strs.stopped)
1486
+ : strs.nothing;
1440
1487
  try {
1441
1488
  await tg(bot, 'sendMessage', {
1442
1489
  chat_id: chatId, text: reply,