polygram 0.6.12 → 0.6.14

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.5.3",
4
+ "version": "0.6.14",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
package/README.md CHANGED
@@ -110,7 +110,7 @@ Practical differences that matter for migration:
110
110
 
111
111
  ## Install
112
112
 
113
- Requires Node 20+.
113
+ Requires Node 22+ (24 recommended; native test coverage is stable in 22).
114
114
 
115
115
  ```bash
116
116
  # Global binary:
@@ -256,11 +256,20 @@ Per-bot flags:
256
256
 
257
257
  - `allowConfigCommands: true` — enables `/model`, `/effort`, `/pair-code`,
258
258
  `/pairings`, `/unpair`.
259
- - `streamReplies: true` — live-edit the Telegram message as Claude works.
259
+ - `streamMinChars` (default 30) debounce before the first stream edit.
260
+ - `streamThrottleMs` (default 1000, min 250) — stream edit cadence.
260
261
  - `voice: { enabled, provider: "openai"|"local", ... }` — Whisper
261
262
  transcription settings.
262
263
  - `approvals: { adminChatId, timeoutMs, gatedTools, ... }` — which tool
263
264
  calls require an inline-keyboard approval and where to post the card.
265
+ - `attachmentConcurrency` (default 6) — parallel Telegram file
266
+ downloads per turn. Cap is conservative against Telegram's
267
+ ~30 req/s/bot rate limit.
268
+ - `queueWarnThreshold` (default 20) — fires a `queue-depth-warning`
269
+ event when in-flight handlers for a session exceed this.
270
+ - `replayWindowMs` (default 180000 = 3 min) — boot replay only
271
+ resurrects interrupted turns younger than this. Longer outages drop
272
+ the queue rather than re-dispatching ancient work.
264
273
 
265
274
  See `config.example.json` for the full schema.
266
275
 
@@ -351,7 +360,8 @@ foreign-chat clicks are rejected. Default-deny on IPC error.
351
360
  ## Development
352
361
 
353
362
  ```bash
354
- npm test # 470 tests, 110 suites, node:test, no external services
363
+ npm test # 494 tests, 114 suites, node:test, no external services
364
+ npm run coverage # native test coverage (Node 22+, no devDeps)
355
365
  npm start -- --bot my-bot
356
366
  npm run split-db -- --config config.json --dry-run
357
367
  npm run ipc-smoke -- my-bot
@@ -383,11 +393,10 @@ tests/*.test.js node:test
383
393
  - Claude Code only. No abstraction over other AIs.
384
394
  - macOS LaunchAgent plists included; Linux systemd units are not (easy
385
395
  to adapt).
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.
396
+ - On FileVault-on macOS, the daemon's LaunchAgents fire via the daemon
397
+ user's own GUI login — there's no auto-start without the keychain
398
+ being unlocked, so a one-time Fast User Switch into the daemon's
399
+ user after each reboot is the supported pattern.
391
400
 
392
401
  ## Roadmap
393
402
 
@@ -11,6 +11,10 @@
11
11
  "_comment_stream": "Streaming is always on. `streamMinChars` sets the initial-send debounce (default 30) — short responses below that stay idle until the final result. `streamThrottleMs` sets the edit cadence (default 1000ms, min 250).",
12
12
  "streamMinChars": 30,
13
13
  "streamThrottleMs": 1000,
14
+ "_comment_ops_tunings": "Optional per-bot operational knobs. `attachmentConcurrency` caps parallel Telegram file downloads per turn (default 6, well under Telegram's 30 req/s/bot limit). `queueWarnThreshold` fires a `queue-depth-warning` event when in-flight handlers exceed it (default 20). `replayWindowMs` bounds boot replay so an outage longer than this won't resurrect ancient interrupted turns (default 180000 = 3 min).",
15
+ "attachmentConcurrency": 6,
16
+ "queueWarnThreshold": 20,
17
+ "replayWindowMs": 180000,
14
18
  "_comment_pairedChatDefaults": "When a user sends /pair <CODE> from a private chat that isn't in config.chats yet, polygram auto-creates a chat entry using these defaults (merged over top-level `defaults`). Leave out `cwd` at your peril — without it, auto-onboarded DMs have no working directory and pairing will fail.",
15
19
  "pairedChatDefaults": {
16
20
  "agent": "admin",
@@ -35,19 +35,23 @@ function filterAttachments(attachments, opts = {}) {
35
35
  rejected.push({ att: a, reason: `mime not allowed (${mime || 'unknown'})` });
36
36
  continue;
37
37
  }
38
- const size = a.size || 0;
39
- // Telegram sometimes reports file_size=0 or omits it. Those bypass the
40
- // cap here but the download step MUST re-check Content-Length and actual
41
- // bytes see downloadAttachments in polygram.js.
42
- if (size > maxFileBytes) {
43
- rejected.push({ att: a, reason: `exceeds per-file cap (${maxFileBytes} bytes, got ${size})` });
38
+ const reported = a.size || 0;
39
+ // Telegram sometimes reports file_size=0 or omits it. Pre-0.6.14
40
+ // those bypassed the cumulative cap entirely (totalBytes + 0 always
41
+ // maxTotalBytes), so 5 size-0 attachments could blow through the
42
+ // 20 MB total cap. Treat unknown sizes as worst-case (= per-file
43
+ // cap) for budgeting; the per-file cap is still enforced live by
44
+ // the streaming download in polygram.js.
45
+ const sizeForBudget = reported > 0 ? reported : maxFileBytes;
46
+ if (reported > maxFileBytes) {
47
+ rejected.push({ att: a, reason: `exceeds per-file cap (${maxFileBytes} bytes, got ${reported})` });
44
48
  continue;
45
49
  }
46
- if (totalBytes + size > maxTotalBytes) {
50
+ if (totalBytes + sizeForBudget > maxTotalBytes) {
47
51
  rejected.push({ att: a, reason: `exceeds total size cap (${maxTotalBytes} bytes)` });
48
52
  continue;
49
53
  }
50
- totalBytes += size;
54
+ totalBytes += sizeForBudget;
51
55
  accepted.push(a);
52
56
  }
53
57
  return { accepted, rejected, totalBytes };
package/lib/db.js CHANGED
@@ -338,22 +338,29 @@ function wrap(db) {
338
338
  // Prevents double-processing if a redelivered/replayed message has
339
339
  // already been answered.
340
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.
341
+ // Three states count as "probably sent":
342
+ // - 'sent': the happy path.
343
+ // - 'failed' with error='crashed-mid-send': polygram crashed
344
+ // after inserting the pending row but before markOutboundSent.
345
+ // The boot-time markStalePending sweep flipped them to this.
346
+ // - 'pending' (0.6.14): markStalePending only flips rows older
347
+ // than 60s, so a fast restart (boot replay fires in &lt;60s) leaves
348
+ // fresh pending rows in 'pending' state. Without counting them
349
+ // here, the inbound looks unanswered and gets re-dispatched
350
+ // Telegram already delivered the original reply duplicate.
351
+ //
352
+ // Treating ambiguous states as "replied" costs us occasional missed
353
+ // replies (recoverable: user resends) to prevent duplicates
354
+ // (irrecoverable: user has to mentally dedupe two answers).
352
355
  hasOutboundReplyTo({ chat_id, msg_id }) {
353
356
  const row = db.prepare(`
354
357
  SELECT 1 FROM messages
355
358
  WHERE chat_id = ? AND direction = 'out' AND reply_to_id = ?
356
- AND (status = 'sent' OR (status = 'failed' AND error = 'crashed-mid-send'))
359
+ AND (
360
+ status = 'sent'
361
+ OR status = 'pending'
362
+ OR (status = 'failed' AND error = 'crashed-mid-send')
363
+ )
357
364
  LIMIT 1
358
365
  `).get(chat_id, msg_id);
359
366
  return !!row;
@@ -21,19 +21,35 @@
21
21
  */
22
22
 
23
23
  const DEFAULT_FLUSH_MS = 500;
24
+ // 0.6.14 caps. The buffer is a single in-memory Map shared across every
25
+ // chat the bot serves; without bounds a hostile sender (or buggy client)
26
+ // could keep entries retained indefinitely by drip-feeding siblings under
27
+ // the flushMs window, OR balloon a single entry's messages array to the
28
+ // point of OOM. Reasonable defaults for typical Telegram album behavior:
29
+ // max 10 messages per group (Telegram's own album limit), max 64 entries
30
+ // in flight (one per active chat is plenty), and a hard 5s wall-clock
31
+ // retention regardless of arrivals.
32
+ const DEFAULT_MAX_MESSAGES_PER_GROUP = 10;
33
+ const DEFAULT_MAX_ENTRIES = 64;
34
+ const DEFAULT_MAX_AGE_MS = 5_000;
24
35
 
25
36
  function createMediaGroupBuffer({
26
37
  flushMs = DEFAULT_FLUSH_MS,
38
+ maxMessagesPerGroup = DEFAULT_MAX_MESSAGES_PER_GROUP,
39
+ maxEntries = DEFAULT_MAX_ENTRIES,
40
+ maxAgeMs = DEFAULT_MAX_AGE_MS,
27
41
  onFlush,
28
42
  timerFn = setTimeout,
29
43
  clearTimerFn = clearTimeout,
44
+ nowFn = Date.now,
30
45
  } = {}) {
31
46
  if (typeof onFlush !== 'function') throw new Error('onFlush required');
32
- const entries = new Map(); // key → { messages, timer }
47
+ const entries = new Map(); // key → { messages, timer, firstAddedTs }
33
48
 
34
49
  const flushKey = (key) => {
35
50
  const entry = entries.get(key);
36
51
  if (!entry) return;
52
+ if (entry.timer) clearTimerFn(entry.timer);
37
53
  entries.delete(key);
38
54
  // Defensive: onFlush errors must not break future group buffering.
39
55
  try { onFlush(entry.messages, key); }
@@ -43,10 +59,32 @@ function createMediaGroupBuffer({
43
59
  const add = (key, msg) => {
44
60
  let entry = entries.get(key);
45
61
  if (!entry) {
46
- entry = { messages: [], timer: null };
62
+ // Cap total entries: if we're at the limit, force-flush the oldest
63
+ // first. Avoids unbounded memory if a hostile sender spams keys.
64
+ if (entries.size >= maxEntries) {
65
+ const oldestKey = entries.keys().next().value;
66
+ if (oldestKey !== undefined) flushKey(oldestKey);
67
+ }
68
+ entry = { messages: [], timer: null, firstAddedTs: nowFn() };
47
69
  entries.set(key, entry);
48
70
  }
49
71
  entry.messages.push(msg);
72
+
73
+ // Per-group size cap: flush immediately when we hit the limit.
74
+ if (entry.messages.length >= maxMessagesPerGroup) {
75
+ flushKey(key);
76
+ return;
77
+ }
78
+
79
+ // Per-group wall-clock cap: don't let drip-feeding indefinitely
80
+ // postpone the flush via the resetting timer. If the group has been
81
+ // open longer than maxAgeMs, flush now even though new siblings keep
82
+ // arriving.
83
+ if (nowFn() - entry.firstAddedTs >= maxAgeMs) {
84
+ flushKey(key);
85
+ return;
86
+ }
87
+
50
88
  if (entry.timer) clearTimerFn(entry.timer);
51
89
  const t = timerFn(() => flushKey(key), flushMs);
52
90
  // Don't keep the node event loop alive waiting for a buffered group
package/lib/net-errors.js CHANGED
@@ -83,6 +83,32 @@ function isTransientNetworkError(err) {
83
83
  return false;
84
84
  }
85
85
 
86
+ /**
87
+ * Strip Telegram bot tokens from a message string before logging or
88
+ * persisting. The fetch-CDN URL embeds `bot${TOKEN}` literally, but
89
+ * various error stringifiers / proxy layers may leak the same token in
90
+ * other shapes — URL-encoded in query strings, percent-encoded
91
+ * (`bot1234%3AAAH…`), or as a bare `Authorization: Bearer …` header.
92
+ *
93
+ * Telegram tokens have the canonical shape `\d{8,10}:[A-Za-z0-9_-]{35}`
94
+ * — match on that shape directly so the leading `bot` literal isn't
95
+ * load-bearing. Three patterns:
96
+ * 1. `bot1234567:AAH…` (canonical, used by the file CDN URL)
97
+ * 2. `bot1234567%3AAAH…` (URL-encoded `:`)
98
+ * 3. bare token shape anywhere in the string
99
+ *
100
+ * Pattern 3 is intentionally broad — false positives (some random
101
+ * `1234567:abcdef…35chars` that isn't a token) are vanishingly rare.
102
+ */
103
+ function redactBotToken(s) {
104
+ if (!s) return s;
105
+ return String(s)
106
+ .replace(/bot\d+(?::|%3A)[A-Za-z0-9_%-]+/gi, 'bot<redacted>')
107
+ .replace(/\b\d{8,10}:[A-Za-z0-9_-]{35}\b/g, '<redacted-token>')
108
+ .replace(/(Authorization:\s*Bearer\s+)\S+/gi, '$1<redacted>')
109
+ .replace(/(bot_token=)[^&\s]+/gi, '$1<redacted>');
110
+ }
111
+
86
112
  module.exports = {
87
113
  PRE_CONNECT_ERROR_CODES,
88
114
  RECOVERABLE_ERROR_CODES,
@@ -91,4 +117,5 @@ module.exports = {
91
117
  isTransientNetworkError,
92
118
  extractCode,
93
119
  extractName,
120
+ redactBotToken,
94
121
  };
package/lib/telegram.js CHANGED
@@ -21,7 +21,7 @@
21
21
 
22
22
  const crypto = require('crypto');
23
23
  const { toTelegramMarkdown } = require('./telegram-format');
24
- const { isSafeToRetry } = require('./net-errors');
24
+ const { isSafeToRetry, redactBotToken } = require('./net-errors');
25
25
 
26
26
  // Topic deletion race: a user can delete a forum topic while a turn is in
27
27
  // flight, turning a valid `message_thread_id` into a 404. Telegram's error
@@ -175,18 +175,23 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
175
175
  catch {}
176
176
  } catch (err2) {
177
177
  if (rowId != null && db) {
178
- try { db.markOutboundFailed(rowId, err2.message); }
178
+ // 0.6.14: redact bot tokens before persisting err.message
179
+ // some undici/network error shapes embed the request URL
180
+ // (which carries `bot${TOKEN}`) into err.message.
181
+ const safe2 = redactBotToken(err2.message);
182
+ try { db.markOutboundFailed(rowId, safe2); }
179
183
  catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
180
- try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: err2.message }); }
184
+ try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: safe2 }); }
181
185
  catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
182
186
  }
183
187
  throw err2;
184
188
  }
185
189
  } else {
186
190
  if (rowId != null && db) {
187
- try { db.markOutboundFailed(rowId, err.message); }
191
+ const safe = redactBotToken(err.message);
192
+ try { db.markOutboundFailed(rowId, safe); }
188
193
  catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
189
- try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: err.message }); }
194
+ try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: safe }); }
190
195
  catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
191
196
  }
192
197
  throw err;
@@ -22,7 +22,7 @@
22
22
  <key>ProgramArguments</key>
23
23
  <array>
24
24
  <string>/opt/homebrew/bin/node</string>
25
- <string>/Users/YOURNAME/polygram/bridge.js</string>
25
+ <string>/Users/YOURNAME/polygram/polygram.js</string>
26
26
  <string>--bot</string>
27
27
  <string>BOTNAME</string>
28
28
  </array>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
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": {
@@ -26,12 +26,14 @@
26
26
  ],
27
27
  "scripts": {
28
28
  "test": "node --test tests/*.test.js",
29
+ "coverage": "node --test --experimental-test-coverage tests/*.test.js",
30
+ "coverage:lcov": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info tests/*.test.js",
29
31
  "start": "node polygram.js",
30
32
  "split-db": "node scripts/split-db.js",
31
33
  "ipc-smoke": "node scripts/ipc-smoke.js"
32
34
  },
33
35
  "engines": {
34
- "node": ">=20"
36
+ "node": ">=22"
35
37
  },
36
38
  "keywords": [
37
39
  "telegram",
package/polygram.js CHANGED
@@ -34,6 +34,7 @@ const { transcribe: transcribeVoice, isVoiceAttachment } = require('./lib/voice'
34
34
  const { createStreamer } = require('./lib/stream-reply');
35
35
  const { isAbortRequest } = require('./lib/abort-detector');
36
36
  const { startTyping } = require('./lib/typing-indicator');
37
+ const { redactBotToken } = require('./lib/net-errors');
37
38
  const { createReactionManager, classifyToolName } = require('./lib/status-reactions');
38
39
  const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
39
40
  const {
@@ -449,15 +450,46 @@ async function downloadOneAttachment(bot, token, chatId, msg, chatDir, att) {
449
450
  const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
450
451
  const res = await fetch(url);
451
452
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
452
- // Defense in depth: re-check size at download time. Telegram can
453
- // omit file_size from the Message, or its value may not match what
454
- // the CDN actually serves. Trust Content-Length and fall back to
455
- // buffering with a ceiling.
453
+ // Three-layer size enforcement, in order of cheapness:
454
+ // 1. Content-Length header fail-fast before reading any body.
455
+ // 2. Streaming chunk-by-chunk accumulation abort the moment the
456
+ // cumulative byte count crosses the cap. This is the layer that
457
+ // protects against an attacker omitting Content-Length: pre-0.6.14
458
+ // we read the whole `res.arrayBuffer()` into RAM first and only
459
+ // then checked the size. With the per-bot ATTACHMENT_DOWNLOAD_
460
+ // CONCURRENCY default of 6, six unbounded reads in flight could
461
+ // pin arbitrary RSS — real OOM angle for a malicious upload.
462
+ // 3. Final post-buffer check is now redundant but cheap; left as
463
+ // defense-in-depth in case the streaming logic is ever changed.
456
464
  const cl = parseInt(res.headers.get('content-length') || '0', 10);
457
465
  if (cl > MAX_FILE_BYTES) {
458
466
  throw new Error(`content-length ${cl} exceeds per-file cap ${MAX_FILE_BYTES}`);
459
467
  }
460
- const buf = Buffer.from(await res.arrayBuffer());
468
+ let total = 0;
469
+ const chunks = [];
470
+ if (res.body && typeof res.body.getReader === 'function') {
471
+ const reader = res.body.getReader();
472
+ while (true) {
473
+ const { done, value } = await reader.read();
474
+ if (done) break;
475
+ total += value.byteLength;
476
+ if (total > MAX_FILE_BYTES) {
477
+ try { await reader.cancel(); } catch {}
478
+ throw new Error(`stream ${total}+ bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
479
+ }
480
+ chunks.push(value);
481
+ }
482
+ } else {
483
+ // Fallback for runtimes without WHATWG streams (shouldn't fire on
484
+ // Node 22+). Same arrayBuffer path as before, with the post-check.
485
+ const ab = await res.arrayBuffer();
486
+ if (ab.byteLength > MAX_FILE_BYTES) {
487
+ throw new Error(`body ${ab.byteLength} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
488
+ }
489
+ chunks.push(new Uint8Array(ab));
490
+ total = ab.byteLength;
491
+ }
492
+ const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
461
493
  if (buf.length > MAX_FILE_BYTES) {
462
494
  throw new Error(`body ${buf.length} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
463
495
  }
@@ -503,10 +535,11 @@ async function downloadOneAttachment(bot, token, chatId, msg, chatDir, att) {
503
535
  // requirement) and some undici/network error variants stringify
504
536
  // the request including the URL into err.message. Persisting that
505
537
  // raw to attachments.download_error or stderr would leak the bot
506
- // token to anyone with DB or log access. Strip any `bot<token>`
507
- // pattern from the reason before storing/logging.
538
+ // token. 0.6.14: centralized in net-errors.redactBotToken which
539
+ // also handles URL-encoded (%3A) variants and bare token shapes
540
+ // missed by the original regex.
508
541
  const raw = (err.message || 'unknown').slice(0, 200);
509
- const reason = raw.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot<redacted>');
542
+ const reason = redactBotToken(raw);
510
543
  console.error(`[attach] download failed for ${att.name}: ${reason}`);
511
544
  dbWrite(() => db.markAttachmentFailed(att.id, reason),
512
545
  `markAttachmentFailed ${att.id}`);
@@ -779,17 +812,20 @@ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
779
812
  console.error(`[${sessionKey}] Error:`, err.message);
780
813
  // Mark the row terminal so the right thing happens on next boot:
781
814
  // - aborted: user explicitly stopped → 'aborted' (not replayable)
782
- // - shutting down: the error is "Process exited" / "Process killed"
783
- // from the SIGINT/SIGTERM tearing down claude mid-turn. Mark
784
- // 'replay-pending' so the next boot picks it up via
785
- // getReplayCandidates. Pre-0.6.12 we marked 'failed' here, which
786
- // excluded the row from replay a clean restart between user
787
- // send and reply silently dropped the turn forever.
815
+ // - shutting down on a NEW turn: 'replay-pending' so the next
816
+ // boot picks it up via getReplayCandidates. Pre-0.6.12 we marked
817
+ // 'failed' here, which excluded the row from replay a clean
818
+ // restart between user send and reply silently dropped the turn.
819
+ // - shutting down on a REPLAY turn (msg._isReplay=true): keep
820
+ // 'replay-attempted' so the one-shot guard from 0.6.4 still
821
+ // holds. Without this, a replay interrupted by another shutdown
822
+ // would be promoted to 'replay-pending' and the next boot would
823
+ // replay it AGAIN — infinite loop on chained shutdowns.
788
824
  // - everything else: 'failed' (genuine claude crash / timeout etc).
789
825
  const status = wasAborted
790
826
  ? 'aborted'
791
827
  : isShuttingDown
792
- ? 'replay-pending'
828
+ ? (isReplay ? 'replay-attempted' : 'replay-pending')
793
829
  : 'failed';
794
830
  dbWrite(() => db.setInboundHandlerStatus({
795
831
  chat_id: chatId, msg_id: msg.message_id, status,
@@ -1162,16 +1198,21 @@ async function handleApprovalCallback(ctx) {
1162
1198
  id, status, by: userId, user, bot: BOT_NAME,
1163
1199
  });
1164
1200
 
1165
- // Edit the card to show the decision.
1201
+ // Edit the card to show the decision. 0.6.14: routed through tg() so
1202
+ // the edit gets the same write-before-send DB row, plain-text policy
1203
+ // (no parse_mode injection from tool input), and logged failure
1204
+ // surface as every other outbound. Pre-0.6.14 this called
1205
+ // ctx.api.editMessageText directly — same bypass class as the two
1206
+ // already-routed in 0.6.8 (approval-timeout and pair-onboarding).
1166
1207
  try {
1167
1208
  const fresh = approvals.getById(id);
1168
- await ctx.api.editMessageText(
1169
- row.approver_chat_id,
1170
- row.approver_msg_id,
1171
- approvalCardText(fresh, {
1209
+ await tg(bot, 'editMessageText', {
1210
+ chat_id: row.approver_chat_id,
1211
+ message_id: row.approver_msg_id,
1212
+ text: approvalCardText(fresh, {
1172
1213
  resolvedBy: `${status === 'approved' ? '✅ Approved' : '❌ Denied'} by ${user || userId}`,
1173
1214
  }),
1174
- );
1215
+ }, { source: 'approval-card-decision', botName: BOT_NAME, plainText: true });
1175
1216
  } catch (err) {
1176
1217
  console.error(`[${BOT_NAME}] edit approval card failed: ${err.message}`);
1177
1218
  }