polygram 0.6.4 → 0.6.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.
package/lib/db.js CHANGED
@@ -337,10 +337,23 @@ function wrap(db) {
337
337
  // Dedupe check: did we already send an outbound reply to this inbound?
338
338
  // Prevents double-processing if a redelivered/replayed message has
339
339
  // already been answered.
340
+ //
341
+ // We also count rows in the special 'failed crashed-mid-send' state
342
+ // as "probably sent" for dedupe. Those rows were created when polygram
343
+ // crashed AFTER inserting the pending row but before marking it sent
344
+ // — the API call may or may not have actually reached Telegram. The
345
+ // boot-time markStalePending sweep flips them to 'failed' with the
346
+ // 'crashed-mid-send' sentinel error. Treating them as un-replied
347
+ // (status='sent' only) caused boot replay to re-dispatch and Telegram
348
+ // delivered the SAME answer twice. Treating them as replied risks the
349
+ // opposite (the user never got a reply because the API truly failed
350
+ // before reaching Telegram), but a missed reply is recoverable —
351
+ // the user resends — while a duplicate reply is not.
340
352
  hasOutboundReplyTo({ chat_id, msg_id }) {
341
353
  const row = db.prepare(`
342
354
  SELECT 1 FROM messages
343
- WHERE chat_id = ? AND direction = 'out' AND reply_to_id = ? AND status = 'sent'
355
+ WHERE chat_id = ? AND direction = 'out' AND reply_to_id = ?
356
+ AND (status = 'sent' OR (status = 'failed' AND error = 'crashed-mid-send'))
344
357
  LIMIT 1
345
358
  `).get(chat_id, msg_id);
346
359
  return !!row;
package/lib/prompt.js CHANGED
@@ -32,17 +32,6 @@ function truncateReplyText(s, max = REPLY_TO_MAX_CHARS) {
32
32
  return `${s.slice(0, head)}…${s.slice(-tail)}`;
33
33
  }
34
34
 
35
- /**
36
- * Attachment summary for reply-to (never embed full content).
37
- */
38
- function summarizeReplyAttachments(attachmentsJson) {
39
- if (!attachmentsJson) return '';
40
- let items;
41
- try { items = JSON.parse(attachmentsJson); } catch { return ''; }
42
- if (!Array.isArray(items) || !items.length) return '';
43
- return items.map((a) => `[${a.kind}: ${a.name}]`).join(' ');
44
- }
45
-
46
35
  /**
47
36
  * Build a reply-to block. Callers pass either:
48
37
  * - { telegram: msg.reply_to_message } (canonical Telegram payload), or
@@ -71,15 +60,22 @@ ${xmlEscape(body)}
71
60
  }
72
61
 
73
62
  if (dbRow) {
63
+ // Attachment summary for the reply-to block used to read
64
+ // dbRow.attachments_json, but that column was dropped in migration
65
+ // 008. Per-attachment rows live in the `attachments` table now;
66
+ // building a summary here would need a separate join. For reply-to
67
+ // context Claude already sees the canonical Telegram payload via
68
+ // the `telegram` branch above (the DB-row path is only the fallback
69
+ // for resurrected/replayed messages where the live payload is
70
+ // unavailable). Skipping the summary here is acceptable — text
71
+ // alone is enough context for "this is what they replied to".
74
72
  const ts = dbRow.ts ? new Date(dbRow.ts).toISOString() : '';
75
73
  const text = truncateReplyText(dbRow.text || '');
76
- const attachSummary = summarizeReplyAttachments(dbRow.attachments_json);
77
- const body = [text, attachSummary].filter(Boolean).join('\n');
78
74
  const editedAttr = dbRow.edited_ts
79
75
  ? ` edited_ts="${new Date(dbRow.edited_ts).toISOString()}"`
80
76
  : '';
81
77
  return `<reply_to msg_id="${dbRow.msg_id}" user="${xmlEscape(dbRow.user || 'Unknown')}" ts="${ts}"${editedAttr} source="bridge-db">
82
- ${xmlEscape(body)}
78
+ ${xmlEscape(text)}
83
79
  </reply_to>`;
84
80
  }
85
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.6.4",
3
+ "version": "0.6.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
@@ -487,7 +487,15 @@ async function downloadAttachments(bot, token, chatId, msg, rows) {
487
487
  // <attachment-failed reason="..." /> so claude tells the user
488
488
  // "I couldn't see your <kind>" instead of pretending it received
489
489
  // text only.
490
- const reason = (err.message || 'unknown').slice(0, 200);
490
+ //
491
+ // Token redaction: the fetch URL embeds bot${TOKEN} (Telegram CDN
492
+ // requirement) and some undici/network error variants stringify
493
+ // the request including the URL into err.message. Persisting that
494
+ // raw to attachments.download_error or stderr would leak the bot
495
+ // token to anyone with DB or log access. Strip any `bot<token>`
496
+ // pattern from the reason before storing/logging.
497
+ const raw = (err.message || 'unknown').slice(0, 200);
498
+ const reason = raw.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot<redacted>');
491
499
  console.error(`[attach] download failed for ${att.name}: ${reason}`);
492
500
  results.push({ ...att, path: null, error: reason });
493
501
  dbWrite(() => db.markAttachmentFailed(att.id, reason),
@@ -756,12 +764,6 @@ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
756
764
  });
757
765
  }
758
766
 
759
- // drainQueuesForChat is retained as a no-op for backwards compat with
760
- // call sites in /model, /effort, chat-migration, and abort handlers.
761
- // Returns 0 always; a drain isn't meaningful in the concurrent model —
762
- // callers that want to abort should rely on pm.killChat.
763
- const drainQueuesForChat = (_chatId) => 0;
764
-
765
767
  // Per-session lock ordering stdin writes. Module is I/O-pure.
766
768
  const stdinLock = createAsyncLock();
767
769
 
@@ -1161,17 +1163,17 @@ async function handleConfigCallback(ctx) {
1161
1163
  user: cmdUser, user_id: cmdUserId, source: 'inline-button',
1162
1164
  }), `log ${setting} change`);
1163
1165
 
1164
- // Graceful respawn across all sessionKeys for this chat (matches the
1165
- // text-command flow in handleMessage).
1166
+ // Graceful respawn of the topic's session that the card is in. With
1167
+ // isolateTopics=false sessionKey is the chat (one shared session). With
1168
+ // isolateTopics=true sessionKey carries the topic, so other topics'
1169
+ // in-flight turns are not disturbed and the card update + button toast
1170
+ // only affect the user's own context. Mirrors the text-command flow in
1171
+ // handleMessage's requestRespawnForSession.
1172
+ const callbackThreadId = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
1173
+ const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
1166
1174
  const reason = setting === 'model' ? 'model-change' : 'effort-change';
1167
- const prefix = chatId;
1168
- let anyActive = false;
1169
- for (const key of pm.keys()) {
1170
- if (key === prefix || key.startsWith(prefix + ':')) {
1171
- const res = pm.requestRespawn(key, reason);
1172
- if (!res.killed) anyActive = true;
1173
- }
1174
- }
1175
+ const respawn = pm.requestRespawn(callbackSessionKey, reason);
1176
+ const anyActive = !respawn.killed;
1175
1177
 
1176
1178
  // Re-render the card with updated ✓ + the same help text shown initially.
1177
1179
  // Detect original card type (model-only / effort-only / both) by counting
@@ -1307,23 +1309,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1307
1309
  await sendReply(info, { params: { reply_markup } });
1308
1310
  return;
1309
1311
  }
1310
- // Helper: request respawn across ALL sessionKeys owned by this chat (one
1311
- // per topic if isolateTopics=true, otherwise just the single chat-level
1312
- // key). Graceful: in-flight turns drain on old settings, new turns use
1313
- // the new settings. Returns total pending turns across all keys so the
1314
- // reply can tell the user.
1315
- const requestRespawnForChat = (reason) => {
1316
- const prefix = String(chatId);
1317
- let totalQueued = 0;
1318
- let anyActive = false;
1319
- for (const key of pm.keys()) {
1320
- if (key === prefix || key.startsWith(prefix + ':')) {
1321
- const res = pm.requestRespawn(key, reason);
1322
- totalQueued += res.queued;
1323
- if (!res.killed) anyActive = true;
1324
- }
1325
- }
1326
- return { queued: totalQueued, anyActive };
1312
+ // Graceful respawn of the user's CURRENT session only. With
1313
+ // isolateTopics=false the sessionKey is just the chat (one shared
1314
+ // session for the whole chat every topic respawns implicitly).
1315
+ // With isolateTopics=true each topic is a separate session, and a
1316
+ // /model in topic A should NOT disturb topic B's in-flight turn or
1317
+ // post a phantom "✓ Using sonnet now" in a topic that didn't ask.
1318
+ // Pre-0.6.5 this iterated pm.keys() by chat prefix and incorrectly
1319
+ // fanned out across all topics under isolateTopics=true.
1320
+ const requestRespawnForSession = (reason) => {
1321
+ const res = pm.requestRespawn(sessionKey, reason);
1322
+ return { queued: res.queued, anyActive: !res.killed };
1327
1323
  };
1328
1324
 
1329
1325
  if (botAllowsCommands && text.startsWith('/model ')) {
@@ -1337,7 +1333,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1337
1333
  old_value: oldModel, new_value: newModel,
1338
1334
  user: cmdUser, user_id: cmdUserId, source: 'command',
1339
1335
  }), 'log model change');
1340
- const { anyActive } = requestRespawnForChat('model-change');
1336
+ const { anyActive } = requestRespawnForSession('model-change');
1341
1337
  const ver = MODEL_VERSIONS[newModel] || newModel;
1342
1338
  const suffix = anyActive ? ` — I'll switch when I finish` : '';
1343
1339
  await sendReply(`Model → ${newModel} (${ver})${suffix}`);
@@ -1357,7 +1353,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1357
1353
  old_value: oldEffort, new_value: newEffort,
1358
1354
  user: cmdUser, user_id: cmdUserId, source: 'command',
1359
1355
  }), 'log effort change');
1360
- const { anyActive } = requestRespawnForChat('effort-change');
1356
+ const { anyActive } = requestRespawnForSession('effort-change');
1361
1357
  const suffix = anyActive ? ` — I'll switch when I finish` : '';
1362
1358
  await sendReply(`Effort → ${newEffort}${suffix}`);
1363
1359
  } else {
@@ -1507,7 +1503,19 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1507
1503
  chat_id: chatId, text: `Attachment(s) skipped: ${summary.slice(0, 300)}`,
1508
1504
  ...replyOpts(threadId),
1509
1505
  }, { source: 'attachment-skipped', botName: BOT_NAME });
1510
- } catch {}
1506
+ } catch (err) {
1507
+ // Surface the failure: claude is about to reply as if the photo
1508
+ // was processed (because filterAttachments dropped it before
1509
+ // download), and the user would otherwise have no signal that
1510
+ // their attachment was rejected. They'd assume claude saw it
1511
+ // and is just answering oddly.
1512
+ console.error(`[${label}] failed to notify user of skipped attachments: ${err.message}`);
1513
+ dbWrite(() => db.logEvent('attachment-skip-notice-failed', {
1514
+ chat_id: chatId, msg_id: msg.message_id,
1515
+ error: err.message?.slice(0, 200),
1516
+ rejected_count: rejected.length,
1517
+ }), 'log attachment-skip-notice-failed');
1518
+ }
1511
1519
  }
1512
1520
 
1513
1521
  await transcribeVoiceAttachments(downloaded, {
@@ -1870,16 +1878,24 @@ function createBot(token) {
1870
1878
  const threadId = msg.message_thread_id?.toString();
1871
1879
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1872
1880
  const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
1873
- const dropped = drainQueuesForChat(chatId);
1874
1881
  // Mark BEFORE killing: the 'close' event fires almost immediately
1875
1882
  // after SIGTERM, and processQueue's catch needs to see the flag to
1876
1883
  // skip the generic error-reply. If we marked after, there'd be a
1877
1884
  // race where the error-reply slips through.
1878
1885
  if (hadActive) markSessionAborted(sessionKey);
1879
- await pm.killChat(chatId).catch(() => {});
1886
+ // Kill ONLY the user's own session, not every topic in the chat.
1887
+ // Pre-0.6.5 this was pm.killChat(chatId) which fanned out across
1888
+ // all topics under isolateTopics=true: the user typed "stop" in
1889
+ // topic A and the bot tore down topic B's in-flight turn, surfacing
1890
+ // a 💥 reply to topic B's user (whose key was never marked aborted,
1891
+ // so the abort grace window didn't apply). With isolateTopics=false
1892
+ // the sessionKey is the chat itself, so killing one session is the
1893
+ // same as killing the chat — behavior unchanged for the common case.
1894
+ await pm.kill(sessionKey).catch((err) =>
1895
+ console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
1880
1896
  dbWrite(() => db.logEvent('abort-requested', {
1881
1897
  chat_id: chatId, user_id: msg.from?.id || null,
1882
- had_active: hadActive, queued_dropped: dropped,
1898
+ had_active: hadActive,
1883
1899
  trigger: cleanText.slice(0, 40),
1884
1900
  }), 'log abort-requested');
1885
1901
  // Reply in the same language the user aborted in. Cyrillic-detection
@@ -2088,8 +2104,10 @@ function createBot(token) {
2088
2104
  config.chats[newChatId] = { ...config.chats[oldChatId] };
2089
2105
  delete config.chats[oldChatId];
2090
2106
  saveConfig();
2091
- const droppedMigrate = drainQueuesForChat(oldChatId);
2092
- if (droppedMigrate) dbWrite(() => db.logEvent('queue-drained', { chat_id: oldChatId, reason: 'chat-migrated', dropped: droppedMigrate }), 'log queue-drained');
2107
+ // Chat migration is the one legit chat-wide kill: every session
2108
+ // (every topic) under the old chat_id is stale and must restart
2109
+ // under the new chat_id. Other respawn/abort paths target a
2110
+ // single sessionKey, but here ALL sessions are invalid.
2093
2111
  await pm.killChat(oldChatId);
2094
2112
  }
2095
2113
  });