polygram 0.6.5 → 0.6.7

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/README.md CHANGED
@@ -49,8 +49,18 @@ ergonomics while running on top of `claude` CLI.
49
49
  - **Voice transcription.** OpenAI Whisper API or local `whisper.cpp`,
50
50
  selectable per bot. Transcriptions land in `messages.text` so FTS
51
51
  finds them.
52
+ - **Per-attachment table** (`attachments`, since 0.6.0) with download
53
+ lifecycle (`pending` → `downloaded` | `failed`), per-attachment
54
+ transcription, and `chat_id`/`kind`/`status` indexes for ops queries.
55
+ Replaces the older `attachments_json` blob — query "all PDFs Maria
56
+ sent last week" without scanning every message. Failed downloads
57
+ surface to Claude as `<attachment-failed reason="..." />` so the
58
+ user gets a real explanation, not silence.
52
59
  - **Content-addressed attachment storage** via Telegram's `file_unique_id`.
53
- Same photo forwarded twice = one file on disk.
60
+ Same photo forwarded twice = one file on disk. Multi-photo albums
61
+ (Telegram delivers each photo as a separate message sharing
62
+ `media_group_id`) coalesce into one logical turn so Claude sees the
63
+ whole album, not just the first photo.
54
64
  - **Prompt-injection hardening.** User text wrapped in `<untrusted-input>`
55
65
  with xml-escape; attributes use `&quot;`. A partner typing
56
66
  `</channel><system>...` sees it as literal text in the prompt.
@@ -59,6 +69,22 @@ ergonomics while running on top of `claude` CLI.
59
69
  - **Step-level streaming replies** (optional per bot). Telegram message
60
70
  edits on each assistant step as Claude works through tool calls and
61
71
  reasoning.
72
+ - **Crash-resilient handler lifecycle.** Inbound rows track a
73
+ `handler_status` (received → dispatched → replied | failed |
74
+ replay-pending). On graceful shutdown, in-flight turns are marked
75
+ for replay; on next boot the daemon re-dispatches anything within a
76
+ 3-minute window, deduped against already-sent outbound replies.
77
+ One-shot guard prevents replay loops.
78
+ - **Contextual error replies.** Idle timeouts, wall-clock ceilings, and
79
+ process crashes each get a distinct user-facing message with a
80
+ recovery hint, not a generic "something went wrong." Restarts and
81
+ user-issued aborts don't fire the apology at all.
82
+ - **Abort detection in natural language** (`stop`, `cancel`, `wait`,
83
+ `стоп`, `отмена`, `хватит`, ...) plus the slash forms (`/stop`,
84
+ `/abort`, `/cancel`). First-sentence match catches "Stop. I'll ask
85
+ in another session." too. Scoped to the user's own session, so an
86
+ abort in one topic never disturbs sibling topics under
87
+ `isolateTopics`.
62
88
 
63
89
  ## Relation to existing projects
64
90
 
@@ -133,7 +159,7 @@ Output:
133
159
 
134
160
  ```
135
161
  ✅ config — bot found, 4 chat(s), admin=68861949
136
- ✅ db — schema v5
162
+ ✅ db — schema v8
137
163
  ✅ ipc — socket responsive, bot=my-bot
138
164
  ✅ telegram — @my_bot (My Bot)
139
165
  ✅ recent-errors — no failure events in last 24h
@@ -325,7 +351,7 @@ foreign-chat clicks are rejected. Default-deny on IPC error.
325
351
  ## Development
326
352
 
327
353
  ```bash
328
- npm test # 336 tests, 72 suites, node:test, no external services
354
+ npm test # 470 tests, 110 suites, node:test, no external services
329
355
  npm start -- --bot my-bot
330
356
  npm run split-db -- --config config.json --dry-run
331
357
  npm run ipc-smoke -- my-bot
@@ -357,7 +383,11 @@ tests/*.test.js node:test
357
383
  - Claude Code only. No abstraction over other AIs.
358
384
  - macOS LaunchAgent plists included; Linux systemd units are not (easy
359
385
  to adapt).
360
- - No marketplace plugin wrapper yet. See roadmap.
386
+ - On FileVault-on macOS, the daemon's LaunchAgents fire via shumabit's
387
+ own GUI login — there's no auto-start without the keychain being
388
+ unlocked, so a one-time Fast User Switch into the daemon's user
389
+ after each reboot is the supported pattern. See
390
+ `skills/infrastructure/SKILL.md` in the source repo for details.
361
391
 
362
392
  ## Roadmap
363
393
 
@@ -365,8 +395,8 @@ tests/*.test.js node:test
365
395
  unknown chats.
366
396
  - Approvals phase 2: deny-with-reason, per-user quotas.
367
397
  - Voice phase 2: `/replay-voice` to re-transcribe with a language hint.
368
- - `/replay-pending` admin command for crashed-mid-send rows.
369
- - Marketplace plugin wrapper with slash commands for admin.
398
+ - Per-attachment ops queries wired into `/polygram:*` slash commands
399
+ (search by chat/kind/time, list failed downloads).
370
400
 
371
401
  ## Licence
372
402
 
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.7",
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
@@ -406,94 +406,125 @@ async function transcribeVoiceAttachments(downloaded, { chatId, msgId, label, bo
406
406
  }), 'persist voice transcription');
407
407
  }
408
408
 
409
+ // Bounded concurrency for parallel fetches. A 10-photo album used to be
410
+ // 10× per-photo latency (each `await fetch` was serial); now in-flight
411
+ // downloads are capped to a small pool. Telegram's per-bot rate limit is
412
+ // ~30 req/s, so 6 concurrent fetches is comfortably under and keeps the
413
+ // happy path responsive without burning sockets on a 100-file edge case.
414
+ const ATTACHMENT_DOWNLOAD_CONCURRENCY = 6;
415
+
416
+ // Per-attachment download. Pure function over (att, deps) → result. Pulled
417
+ // out of the loop so downloadAttachments can run several in parallel.
418
+ async function downloadOneAttachment(bot, token, chatId, msg, chatDir, att) {
419
+ // Reuse path: row already says downloaded AND the file is on disk.
420
+ if (att.download_status === 'downloaded' && att.local_path) {
421
+ try {
422
+ if (fs.statSync(att.local_path).size > 0) {
423
+ return { ...att, path: att.local_path, size: att.size_bytes || 0, error: null };
424
+ }
425
+ } catch { /* fall through to refetch */ }
426
+ }
427
+ try {
428
+ const fileInfo = await bot.api.getFile(att.file_id);
429
+ if (!fileInfo?.file_path) throw new Error('no file_path from getFile');
430
+ const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
431
+ const res = await fetch(url);
432
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
433
+ // Defense in depth: re-check size at download time. Telegram can
434
+ // omit file_size from the Message, or its value may not match what
435
+ // the CDN actually serves. Trust Content-Length and fall back to
436
+ // buffering with a ceiling.
437
+ const cl = parseInt(res.headers.get('content-length') || '0', 10);
438
+ if (cl > MAX_FILE_BYTES) {
439
+ throw new Error(`content-length ${cl} exceeds per-file cap ${MAX_FILE_BYTES}`);
440
+ }
441
+ const buf = Buffer.from(await res.arrayBuffer());
442
+ if (buf.length > MAX_FILE_BYTES) {
443
+ throw new Error(`body ${buf.length} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
444
+ }
445
+ const safeName = sanitizeFilename(att.name);
446
+ // Embed file_unique_id so two attachments with the same msg_id+name
447
+ // (album, resend) can't silently overwrite each other. Telegram
448
+ // guarantees file_unique_id is stable and globally unique per file.
449
+ const uniq = att.file_unique_id ? `-${att.file_unique_id}` : '';
450
+ const localName = `${msg.message_id}${uniq}-${safeName}`;
451
+ const localPath = path.join(chatDir, localName);
452
+ // Atomic write: create a temp with the unique PID+timestamp suffix,
453
+ // fill it, then rename to the canonical name. A crash mid-write leaves
454
+ // a `.tmp.*` file (swept later) rather than a truncated canonical file
455
+ // that the EEXIST dedup branch would happily serve on next request.
456
+ if (fs.existsSync(localPath)) {
457
+ console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
458
+ } else {
459
+ const tmpPath = `${localPath}.tmp.${process.pid}.${Date.now()}`;
460
+ try {
461
+ fs.writeFileSync(tmpPath, buf, { flag: 'wx' });
462
+ fs.renameSync(tmpPath, localPath);
463
+ } catch (e) {
464
+ // Clean up stray tmp on any failure; if the rename fell through
465
+ // because another process beat us, EEXIST on the target is fine.
466
+ try { fs.unlinkSync(tmpPath); } catch {}
467
+ if (e.code !== 'EEXIST') throw e;
468
+ console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
469
+ }
470
+ }
471
+ console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (${buf.length} bytes) → ${localPath}`);
472
+ dbWrite(() => db.markAttachmentDownloaded(att.id, {
473
+ local_path: localPath, size_bytes: att.size_bytes || buf.length,
474
+ }), `markAttachmentDownloaded ${att.id}`);
475
+ return { ...att, path: localPath, size: att.size_bytes || buf.length, error: null };
476
+ } catch (err) {
477
+ // Don't drop the attachment silently — push it through with the
478
+ // failure noted. buildAttachmentTags renders this as
479
+ // <attachment-failed reason="..." /> so claude tells the user
480
+ // "I couldn't see your <kind>" instead of pretending it received
481
+ // text only.
482
+ //
483
+ // Token redaction: the fetch URL embeds bot${TOKEN} (Telegram CDN
484
+ // requirement) and some undici/network error variants stringify
485
+ // the request including the URL into err.message. Persisting that
486
+ // raw to attachments.download_error or stderr would leak the bot
487
+ // token to anyone with DB or log access. Strip any `bot<token>`
488
+ // pattern from the reason before storing/logging.
489
+ const raw = (err.message || 'unknown').slice(0, 200);
490
+ const reason = raw.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot<redacted>');
491
+ console.error(`[attach] download failed for ${att.name}: ${reason}`);
492
+ dbWrite(() => db.markAttachmentFailed(att.id, reason),
493
+ `markAttachmentFailed ${att.id}`);
494
+ return { ...att, path: null, error: reason };
495
+ }
496
+ }
497
+
409
498
  // 0.6.0: takes attachment ROW objects from the DB (not raw extracted
410
499
  // metadata). Each row has an `id` so we can mark status as we go.
411
500
  // On replay: a row with status='downloaded' and a local_path that's
412
501
  // still on disk is reused without re-fetching. Anything else (failed,
413
502
  // missing file, never downloaded) hits Telegram's CDN.
503
+ //
504
+ // 0.6.7: parallel fetches with bounded concurrency. The inner work is
505
+ // stateless per-attachment (only writes go to DB / disk via paths
506
+ // keyed on file_unique_id, so two parallel downloads can't collide).
507
+ // Order of `results` is preserved by writing into a fixed-size array
508
+ // at the original index — important so the prompt sees attachments in
509
+ // the same order the user sent them in an album.
414
510
  async function downloadAttachments(bot, token, chatId, msg, rows) {
415
511
  if (!rows.length) return [];
416
512
  const chatDir = path.join(INBOX_DIR, String(chatId));
417
513
  fs.mkdirSync(chatDir, { recursive: true });
418
514
 
419
- const results = [];
420
- for (const att of rows) {
421
- // Reuse path: row already says downloaded AND the file is on disk.
422
- if (att.download_status === 'downloaded' && att.local_path) {
423
- try {
424
- if (fs.statSync(att.local_path).size > 0) {
425
- results.push({
426
- ...att,
427
- path: att.local_path,
428
- size: att.size_bytes || 0,
429
- error: null,
430
- });
431
- continue;
432
- }
433
- } catch { /* fall through to refetch */ }
434
- }
435
- try {
436
- const fileInfo = await bot.api.getFile(att.file_id);
437
- if (!fileInfo?.file_path) throw new Error('no file_path from getFile');
438
- const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
439
- const res = await fetch(url);
440
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
441
- // Defense in depth: re-check size at download time. Telegram can
442
- // omit file_size from the Message, or its value may not match what
443
- // the CDN actually serves. Trust Content-Length and fall back to
444
- // buffering with a ceiling.
445
- const cl = parseInt(res.headers.get('content-length') || '0', 10);
446
- if (cl > MAX_FILE_BYTES) {
447
- throw new Error(`content-length ${cl} exceeds per-file cap ${MAX_FILE_BYTES}`);
448
- }
449
- const buf = Buffer.from(await res.arrayBuffer());
450
- if (buf.length > MAX_FILE_BYTES) {
451
- throw new Error(`body ${buf.length} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
452
- }
453
- const safeName = sanitizeFilename(att.name);
454
- // Embed file_unique_id so two attachments with the same msg_id+name
455
- // (album, resend) can't silently overwrite each other. Telegram
456
- // guarantees file_unique_id is stable and globally unique per file.
457
- const uniq = att.file_unique_id ? `-${att.file_unique_id}` : '';
458
- const localName = `${msg.message_id}${uniq}-${safeName}`;
459
- const localPath = path.join(chatDir, localName);
460
- // Atomic write: create a temp with the unique PID+timestamp suffix,
461
- // fill it, then rename to the canonical name. A crash mid-write leaves
462
- // a `.tmp.*` file (swept later) rather than a truncated canonical file
463
- // that the EEXIST dedup branch would happily serve on next request.
464
- if (fs.existsSync(localPath)) {
465
- console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
466
- } else {
467
- const tmpPath = `${localPath}.tmp.${process.pid}.${Date.now()}`;
468
- try {
469
- fs.writeFileSync(tmpPath, buf, { flag: 'wx' });
470
- fs.renameSync(tmpPath, localPath);
471
- } catch (e) {
472
- // Clean up stray tmp on any failure; if the rename fell through
473
- // because another process beat us, EEXIST on the target is fine.
474
- try { fs.unlinkSync(tmpPath); } catch {}
475
- if (e.code !== 'EEXIST') throw e;
476
- console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
477
- }
515
+ const results = new Array(rows.length);
516
+ let cursor = 0;
517
+ const workers = Array.from(
518
+ { length: Math.min(ATTACHMENT_DOWNLOAD_CONCURRENCY, rows.length) },
519
+ async () => {
520
+ while (true) {
521
+ const idx = cursor++;
522
+ if (idx >= rows.length) return;
523
+ results[idx] = await downloadOneAttachment(bot, token, chatId, msg, chatDir, rows[idx]);
478
524
  }
479
- results.push({ ...att, path: localPath, size: att.size_bytes || buf.length, error: null });
480
- console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (${buf.length} bytes) → ${localPath}`);
481
- dbWrite(() => db.markAttachmentDownloaded(att.id, {
482
- local_path: localPath, size_bytes: att.size_bytes || buf.length,
483
- }), `markAttachmentDownloaded ${att.id}`);
484
- } catch (err) {
485
- // Don't drop the attachment silently — push it through with the
486
- // failure noted. buildAttachmentTags renders this as
487
- // <attachment-failed reason="..." /> so claude tells the user
488
- // "I couldn't see your <kind>" instead of pretending it received
489
- // text only.
490
- const reason = (err.message || 'unknown').slice(0, 200);
491
- console.error(`[attach] download failed for ${att.name}: ${reason}`);
492
- results.push({ ...att, path: null, error: reason });
493
- dbWrite(() => db.markAttachmentFailed(att.id, reason),
494
- `markAttachmentFailed ${att.id}`);
495
- }
496
- }
525
+ },
526
+ );
527
+ await Promise.all(workers);
497
528
  return results;
498
529
  }
499
530
 
@@ -1495,7 +1526,19 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1495
1526
  chat_id: chatId, text: `Attachment(s) skipped: ${summary.slice(0, 300)}`,
1496
1527
  ...replyOpts(threadId),
1497
1528
  }, { source: 'attachment-skipped', botName: BOT_NAME });
1498
- } catch {}
1529
+ } catch (err) {
1530
+ // Surface the failure: claude is about to reply as if the photo
1531
+ // was processed (because filterAttachments dropped it before
1532
+ // download), and the user would otherwise have no signal that
1533
+ // their attachment was rejected. They'd assume claude saw it
1534
+ // and is just answering oddly.
1535
+ console.error(`[${label}] failed to notify user of skipped attachments: ${err.message}`);
1536
+ dbWrite(() => db.logEvent('attachment-skip-notice-failed', {
1537
+ chat_id: chatId, msg_id: msg.message_id,
1538
+ error: err.message?.slice(0, 200),
1539
+ rejected_count: rejected.length,
1540
+ }), 'log attachment-skip-notice-failed');
1541
+ }
1499
1542
  }
1500
1543
 
1501
1544
  await transcribeVoiceAttachments(downloaded, {