polygram 0.6.5 → 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.5",
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),
@@ -1495,7 +1503,19 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1495
1503
  chat_id: chatId, text: `Attachment(s) skipped: ${summary.slice(0, 300)}`,
1496
1504
  ...replyOpts(threadId),
1497
1505
  }, { source: 'attachment-skipped', botName: BOT_NAME });
1498
- } 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
+ }
1499
1519
  }
1500
1520
 
1501
1521
  await transcribeVoiceAttachments(downloaded, {