polygram 0.6.6 → 0.6.8

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.
Files changed (3) hide show
  1. package/README.md +36 -6
  2. package/package.json +1 -1
  3. package/polygram.js +141 -99
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
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
@@ -11,7 +11,7 @@
11
11
  * → sends to persistent claude process via stdin (stream-json)
12
12
  * → reads response from stdout (stream-json)
13
13
  * → sends reply to Telegram
14
- * → writes every in/out message to bridge.db (Phase 1: parallel write)
14
+ * → writes every in/out message to per-bot SQLite (source of truth)
15
15
  *
16
16
  * Chat commands: /model <model>, /effort <level>, /config
17
17
  */
@@ -191,7 +191,7 @@ async function readSessionContext(sessionKey, cwd) {
191
191
  } catch { return ''; }
192
192
  }
193
193
 
194
- // ─── DB writes (Phase 1 — best-effort, never throws) ────────────────
194
+ // ─── DB writes (best-effort wrapper, never throws) ──────────────────
195
195
 
196
196
  function dbWrite(fn, context) {
197
197
  if (!db) return;
@@ -238,11 +238,15 @@ function recordInbound(msg) {
238
238
  const messageId = db.getInboundMessageId({ chat_id: chatId, msg_id: msg.message_id });
239
239
  if (!messageId) return;
240
240
  // Edit-safe insert: Telegram edited_message events re-fire
241
- // recordInbound with the same (chat_id, msg_id). Telegram doesn't
242
- // permit replacing media in an edit (only text/caption), so if rows
243
- // already exist for this message_id they're correct as-is
244
- // re-inserting would (a) duplicate them, (b) reset download_status
245
- // back to 'pending' and lose the local_path we already fetched.
241
+ // recordInbound with the same (chat_id, msg_id). polygram doesn't
242
+ // currently handle media-edit cases (Bot API does support
243
+ // editMessageMedia, but we don't process it specially the typical
244
+ // edit is text/caption). If rows already exist for this message_id
245
+ // they're correct as-is re-inserting would (a) duplicate them,
246
+ // (b) reset download_status back to 'pending' and lose the
247
+ // local_path we already fetched. If we add media-edit support
248
+ // later, this guard needs to compare file_unique_id and replace
249
+ // selectively rather than skipping wholesale.
246
250
  if (db.getAttachmentsByMessage(messageId).length > 0) return;
247
251
  for (const att of attachments) {
248
252
  db.insertAttachment({
@@ -406,102 +410,125 @@ async function transcribeVoiceAttachments(downloaded, { chatId, msgId, label, bo
406
410
  }), 'persist voice transcription');
407
411
  }
408
412
 
413
+ // Bounded concurrency for parallel fetches. A 10-photo album used to be
414
+ // 10× per-photo latency (each `await fetch` was serial); now in-flight
415
+ // downloads are capped to a small pool. Telegram's per-bot rate limit is
416
+ // ~30 req/s, so 6 concurrent fetches is comfortably under and keeps the
417
+ // happy path responsive without burning sockets on a 100-file edge case.
418
+ const ATTACHMENT_DOWNLOAD_CONCURRENCY = 6;
419
+
420
+ // Per-attachment download. Pure function over (att, deps) → result. Pulled
421
+ // out of the loop so downloadAttachments can run several in parallel.
422
+ async function downloadOneAttachment(bot, token, chatId, msg, chatDir, att) {
423
+ // Reuse path: row already says downloaded AND the file is on disk.
424
+ if (att.download_status === 'downloaded' && att.local_path) {
425
+ try {
426
+ if (fs.statSync(att.local_path).size > 0) {
427
+ return { ...att, path: att.local_path, size: att.size_bytes || 0, error: null };
428
+ }
429
+ } catch { /* fall through to refetch */ }
430
+ }
431
+ try {
432
+ const fileInfo = await bot.api.getFile(att.file_id);
433
+ if (!fileInfo?.file_path) throw new Error('no file_path from getFile');
434
+ const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
435
+ const res = await fetch(url);
436
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
437
+ // Defense in depth: re-check size at download time. Telegram can
438
+ // omit file_size from the Message, or its value may not match what
439
+ // the CDN actually serves. Trust Content-Length and fall back to
440
+ // buffering with a ceiling.
441
+ const cl = parseInt(res.headers.get('content-length') || '0', 10);
442
+ if (cl > MAX_FILE_BYTES) {
443
+ throw new Error(`content-length ${cl} exceeds per-file cap ${MAX_FILE_BYTES}`);
444
+ }
445
+ const buf = Buffer.from(await res.arrayBuffer());
446
+ if (buf.length > MAX_FILE_BYTES) {
447
+ throw new Error(`body ${buf.length} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
448
+ }
449
+ const safeName = sanitizeFilename(att.name);
450
+ // Embed file_unique_id so two attachments with the same msg_id+name
451
+ // (album, resend) can't silently overwrite each other. Telegram
452
+ // guarantees file_unique_id is stable and globally unique per file.
453
+ const uniq = att.file_unique_id ? `-${att.file_unique_id}` : '';
454
+ const localName = `${msg.message_id}${uniq}-${safeName}`;
455
+ const localPath = path.join(chatDir, localName);
456
+ // Atomic write: create a temp with the unique PID+timestamp suffix,
457
+ // fill it, then rename to the canonical name. A crash mid-write leaves
458
+ // a `.tmp.*` file (swept later) rather than a truncated canonical file
459
+ // that the EEXIST dedup branch would happily serve on next request.
460
+ if (fs.existsSync(localPath)) {
461
+ console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
462
+ } else {
463
+ const tmpPath = `${localPath}.tmp.${process.pid}.${Date.now()}`;
464
+ try {
465
+ fs.writeFileSync(tmpPath, buf, { flag: 'wx' });
466
+ fs.renameSync(tmpPath, localPath);
467
+ } catch (e) {
468
+ // Clean up stray tmp on any failure; if the rename fell through
469
+ // because another process beat us, EEXIST on the target is fine.
470
+ try { fs.unlinkSync(tmpPath); } catch {}
471
+ if (e.code !== 'EEXIST') throw e;
472
+ console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
473
+ }
474
+ }
475
+ console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (${buf.length} bytes) → ${localPath}`);
476
+ dbWrite(() => db.markAttachmentDownloaded(att.id, {
477
+ local_path: localPath, size_bytes: att.size_bytes || buf.length,
478
+ }), `markAttachmentDownloaded ${att.id}`);
479
+ return { ...att, path: localPath, size: att.size_bytes || buf.length, error: null };
480
+ } catch (err) {
481
+ // Don't drop the attachment silently — push it through with the
482
+ // failure noted. buildAttachmentTags renders this as
483
+ // <attachment-failed reason="..." /> so claude tells the user
484
+ // "I couldn't see your <kind>" instead of pretending it received
485
+ // text only.
486
+ //
487
+ // Token redaction: the fetch URL embeds bot${TOKEN} (Telegram CDN
488
+ // requirement) and some undici/network error variants stringify
489
+ // the request including the URL into err.message. Persisting that
490
+ // raw to attachments.download_error or stderr would leak the bot
491
+ // token to anyone with DB or log access. Strip any `bot<token>`
492
+ // pattern from the reason before storing/logging.
493
+ const raw = (err.message || 'unknown').slice(0, 200);
494
+ const reason = raw.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot<redacted>');
495
+ console.error(`[attach] download failed for ${att.name}: ${reason}`);
496
+ dbWrite(() => db.markAttachmentFailed(att.id, reason),
497
+ `markAttachmentFailed ${att.id}`);
498
+ return { ...att, path: null, error: reason };
499
+ }
500
+ }
501
+
409
502
  // 0.6.0: takes attachment ROW objects from the DB (not raw extracted
410
503
  // metadata). Each row has an `id` so we can mark status as we go.
411
504
  // On replay: a row with status='downloaded' and a local_path that's
412
505
  // still on disk is reused without re-fetching. Anything else (failed,
413
506
  // missing file, never downloaded) hits Telegram's CDN.
507
+ //
508
+ // 0.6.7: parallel fetches with bounded concurrency. The inner work is
509
+ // stateless per-attachment (only writes go to DB / disk via paths
510
+ // keyed on file_unique_id, so two parallel downloads can't collide).
511
+ // Order of `results` is preserved by writing into a fixed-size array
512
+ // at the original index — important so the prompt sees attachments in
513
+ // the same order the user sent them in an album.
414
514
  async function downloadAttachments(bot, token, chatId, msg, rows) {
415
515
  if (!rows.length) return [];
416
516
  const chatDir = path.join(INBOX_DIR, String(chatId));
417
517
  fs.mkdirSync(chatDir, { recursive: true });
418
518
 
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
- }
519
+ const results = new Array(rows.length);
520
+ let cursor = 0;
521
+ const workers = Array.from(
522
+ { length: Math.min(ATTACHMENT_DOWNLOAD_CONCURRENCY, rows.length) },
523
+ async () => {
524
+ while (true) {
525
+ const idx = cursor++;
526
+ if (idx >= rows.length) return;
527
+ results[idx] = await downloadOneAttachment(bot, token, chatId, msg, chatDir, rows[idx]);
478
528
  }
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
- //
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>');
499
- console.error(`[attach] download failed for ${att.name}: ${reason}`);
500
- results.push({ ...att, path: null, error: reason });
501
- dbWrite(() => db.markAttachmentFailed(att.id, reason),
502
- `markAttachmentFailed ${att.id}`);
503
- }
504
- }
529
+ },
530
+ );
531
+ await Promise.all(workers);
505
532
  return results;
506
533
  }
507
534
 
@@ -1227,13 +1254,18 @@ function startApprovalSweeper(intervalMs = 30_000) {
1227
1254
  id: row.id, bot: BOT_NAME, tool: row.tool_name,
1228
1255
  }), 'log approval-timeout');
1229
1256
  resolveApprovalWaiter(row.id, 'timeout', 'swept');
1230
- // Best-effort: edit the card to show the timeout.
1257
+ // Best-effort: edit the card to show the timeout. Routed through
1258
+ // tg() so the edit gets the same plain-text formatting policy as
1259
+ // the original card post (no parse_mode injection from tool input)
1260
+ // AND lands in the transcript like every other outbound. Pre-0.6.8
1261
+ // this called bot.api.editMessageText directly and bypassed both.
1231
1262
  if (bot && row.approver_msg_id) {
1232
- bot.api.editMessageText(
1233
- row.approver_chat_id,
1234
- row.approver_msg_id,
1235
- approvalCardText(approvals.getById(row.id), { resolvedBy: '⏰ Timed out' }),
1236
- ).catch(() => {});
1263
+ tg(bot, 'editMessageText', {
1264
+ chat_id: row.approver_chat_id,
1265
+ message_id: row.approver_msg_id,
1266
+ text: approvalCardText(approvals.getById(row.id), { resolvedBy: '⏰ Timed out' }),
1267
+ }, { source: 'approval-card-timeout', botName: BOT_NAME, plainText: true })
1268
+ .catch((err) => console.error(`[${BOT_NAME}] approval-card-timeout edit: ${err.message}`));
1237
1269
  }
1238
1270
  }
1239
1271
  }, intervalMs);
@@ -1789,7 +1821,17 @@ function createBot(token) {
1789
1821
  async function onboardPairedChat(ctx, code) {
1790
1822
  const chatId = ctx.chat.id.toString();
1791
1823
  const userId = ctx.message.from?.id;
1792
- const send = (text) => bot.api.sendMessage(chatId, text).catch(() => {});
1824
+ // Route through tg() so onboarding replies (success notice + error
1825
+ // messages) get the standard write-before-send DB row, log on
1826
+ // failure, and the same formatting policy as every other outbound.
1827
+ // Pre-0.6.8 this was bot.api.sendMessage(...).catch(() => {}) which
1828
+ // silently dropped failures: the user typed /pair, the code was
1829
+ // claimed (DB mutated), but if the "Paired" reply failed to send
1830
+ // they'd assume it didn't work and try the now-invalid code again.
1831
+ const send = (text) => tg(bot, 'sendMessage', {
1832
+ chat_id: chatId, text,
1833
+ }, { source: 'pair-onboarding', botName: BOT_NAME }).catch((err) =>
1834
+ console.error(`[${BOT_NAME}] pair-onboarding reply: ${err.message}`));
1793
1835
 
1794
1836
  if (!userId) {
1795
1837
  await send('No user id on request.');