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.
- package/README.md +36 -6
- package/package.json +1 -1
- 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 `"`. 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.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
|
|
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 (
|
|
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).
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
1233
|
-
row.approver_chat_id,
|
|
1234
|
-
row.approver_msg_id,
|
|
1235
|
-
approvalCardText(approvals.getById(row.id), { resolvedBy: '⏰ Timed out' }),
|
|
1236
|
-
|
|
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
|
-
|
|
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.');
|