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 +36 -6
- package/lib/db.js +14 -1
- package/lib/prompt.js +10 -14
- package/package.json +1 -1
- package/polygram.js +121 -78
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 `"`. 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
|
|
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 #
|
|
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
|
-
-
|
|
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
|
-
-
|
|
369
|
-
|
|
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 = ?
|
|
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(
|
|
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.
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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, {
|