polygram 0.6.13 → 0.6.15

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.15",
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
@@ -1,5 +1,9 @@
1
1
  # polygram
2
2
 
3
+ [![CI](https://github.com/shumkov/polygram/actions/workflows/ci.yml/badge.svg)](https://github.com/shumkov/polygram/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/shumkov/polygram/branch/main/graph/badge.svg)](https://codecov.io/gh/shumkov/polygram)
5
+ [![npm](https://img.shields.io/npm/v/polygram.svg)](https://www.npmjs.com/package/polygram)
6
+
3
7
  A Telegram daemon and Claude Code plugin that preserves the per-chat
4
8
  session model from **OpenClaw**. Intended primarily as a **migration
5
9
  path** for users moving their Telegram-based ops from OpenClaw to Claude
@@ -256,11 +260,20 @@ Per-bot flags:
256
260
 
257
261
  - `allowConfigCommands: true` — enables `/model`, `/effort`, `/pair-code`,
258
262
  `/pairings`, `/unpair`.
259
- - `streamReplies: true` — live-edit the Telegram message as Claude works.
263
+ - `streamMinChars` (default 30) debounce before the first stream edit.
264
+ - `streamThrottleMs` (default 1000, min 250) — stream edit cadence.
260
265
  - `voice: { enabled, provider: "openai"|"local", ... }` — Whisper
261
266
  transcription settings.
262
267
  - `approvals: { adminChatId, timeoutMs, gatedTools, ... }` — which tool
263
268
  calls require an inline-keyboard approval and where to post the card.
269
+ - `attachmentConcurrency` (default 6) — parallel Telegram file
270
+ downloads per turn. Cap is conservative against Telegram's
271
+ ~30 req/s/bot rate limit.
272
+ - `queueWarnThreshold` (default 20) — fires a `queue-depth-warning`
273
+ event when in-flight handlers for a session exceed this.
274
+ - `replayWindowMs` (default 180000 = 3 min) — boot replay only
275
+ resurrects interrupted turns younger than this. Longer outages drop
276
+ the queue rather than re-dispatching ancient work.
264
277
 
265
278
  See `config.example.json` for the full schema.
266
279
 
@@ -351,7 +364,7 @@ foreign-chat clicks are rejected. Default-deny on IPC error.
351
364
  ## Development
352
365
 
353
366
  ```bash
354
- npm test # 481 tests, 113 suites, node:test, no external services
367
+ npm test # 500 tests, 115 suites, node:test, no external services
355
368
  npm run coverage # native test coverage (Node 22+, no devDeps)
356
369
  npm start -- --bot my-bot
357
370
  npm run split-db -- --config config.json --dry-run
@@ -384,11 +397,10 @@ tests/*.test.js node:test
384
397
  - Claude Code only. No abstraction over other AIs.
385
398
  - macOS LaunchAgent plists included; Linux systemd units are not (easy
386
399
  to adapt).
387
- - On FileVault-on macOS, the daemon's LaunchAgents fire via shumabit's
388
- own GUI login — there's no auto-start without the keychain being
389
- unlocked, so a one-time Fast User Switch into the daemon's user
390
- after each reboot is the supported pattern. See
391
- `skills/infrastructure/SKILL.md` in the source repo for details.
400
+ - On FileVault-on macOS, the daemon's LaunchAgents fire via the daemon
401
+ user's own GUI login — there's no auto-start without the keychain
402
+ being unlocked, so a one-time Fast User Switch into the daemon's
403
+ user after each reboot is the supported pattern.
392
404
 
393
405
  ## Roadmap
394
406
 
@@ -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
@@ -10,6 +10,14 @@ const Database = require('better-sqlite3');
10
10
 
11
11
  const SCHEMA_VERSION = 8;
12
12
 
13
+ // Sentinel `error` value for outbound rows whose API call may or may not
14
+ // have reached Telegram. markStalePending writes it; hasOutboundReplyTo
15
+ // reads it to dedupe boot replay against possibly-delivered messages.
16
+ // Constant rather than inline literal so a typo can't silently break the
17
+ // invariant ("AND error = 'crashedmidsend'" → no rows match → duplicate
18
+ // reply on boot).
19
+ const CRASHED_MID_SEND = 'crashed-mid-send';
20
+
13
21
  function open(dbPath) {
14
22
  const db = new Database(dbPath);
15
23
  db.pragma('journal_mode = WAL');
@@ -31,10 +39,13 @@ function runMigrations(db, migrationsDir) {
31
39
  const n = parseInt(file.slice(0, 3), 10);
32
40
  if (Number.isNaN(n)) continue;
33
41
  const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
34
- // BEGIN IMMEDIATE acquires the write lock up-front, preventing two
35
- // processes from both reading user_version=N and both attempting to
36
- // apply migration N+1. The second migrator hits SQLITE_BUSY beyond
37
- // busy_timeout and errors cleanly instead of corrupting state.
42
+ // Concurrent-boot safety: BEGIN IMMEDIATE acquires the write lock
43
+ // up-front; the second migrator blocks on busy_timeout (5s) then
44
+ // re-reads user_version inside the txn for check-and-set semantics.
45
+ // The prepared-statement-against-old-schema hazard is mitigated by
46
+ // polygram's per-bot DB layout (one process per DB file, see
47
+ // scripts/split-db.js), so there is no other long-lived reader on
48
+ // the same DB during a migration in normal operation.
38
49
  db.exec('BEGIN IMMEDIATE');
39
50
  try {
40
51
  // Re-read inside the transaction so we skip anything another process
@@ -152,11 +163,11 @@ function wrap(db) {
152
163
  `);
153
164
 
154
165
  const markStalePendingStmt = db.prepare(`
155
- UPDATE messages SET status = 'failed', error = 'crashed-mid-send'
166
+ UPDATE messages SET status = 'failed', error = '${CRASHED_MID_SEND}'
156
167
  WHERE status = 'pending' AND ts < ?
157
168
  `);
158
169
  const markStalePendingForBotStmt = db.prepare(`
159
- UPDATE messages SET status = 'failed', error = 'crashed-mid-send'
170
+ UPDATE messages SET status = 'failed', error = '${CRASHED_MID_SEND}'
160
171
  WHERE status = 'pending' AND ts < ? AND bot_name = ?
161
172
  `);
162
173
 
@@ -338,22 +349,29 @@ function wrap(db) {
338
349
  // Prevents double-processing if a redelivered/replayed message has
339
350
  // already been answered.
340
351
  //
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.
352
+ // Three states count as "probably sent":
353
+ // - 'sent': the happy path.
354
+ // - 'failed' with error='crashed-mid-send': polygram crashed
355
+ // after inserting the pending row but before markOutboundSent.
356
+ // The boot-time markStalePending sweep flipped them to this.
357
+ // - 'pending' (0.6.14): markStalePending only flips rows older
358
+ // than 60s, so a fast restart (boot replay fires in &lt;60s) leaves
359
+ // fresh pending rows in 'pending' state. Without counting them
360
+ // here, the inbound looks unanswered and gets re-dispatched
361
+ // Telegram already delivered the original reply duplicate.
362
+ //
363
+ // Treating ambiguous states as "replied" costs us occasional missed
364
+ // replies (recoverable: user resends) to prevent duplicates
365
+ // (irrecoverable: user has to mentally dedupe two answers).
352
366
  hasOutboundReplyTo({ chat_id, msg_id }) {
353
367
  const row = db.prepare(`
354
368
  SELECT 1 FROM messages
355
369
  WHERE chat_id = ? AND direction = 'out' AND reply_to_id = ?
356
- AND (status = 'sent' OR (status = 'failed' AND error = 'crashed-mid-send'))
370
+ AND (
371
+ status = 'sent'
372
+ OR status = 'pending'
373
+ OR (status = 'failed' AND error = '${CRASHED_MID_SEND}')
374
+ )
357
375
  LIMIT 1
358
376
  `).get(chat_id, msg_id);
359
377
  return !!row;
@@ -519,4 +537,4 @@ function wrap(db) {
519
537
  };
520
538
  }
521
539
 
522
- module.exports = { open };
540
+ module.exports = { open, CRASHED_MID_SEND };
@@ -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/pairings.js CHANGED
@@ -49,6 +49,38 @@ function parseTtl(input) {
49
49
  return ms;
50
50
  }
51
51
 
52
+ // Per-user attempt tracker (in-memory). Counts EVERY claim call, not just
53
+ // successful ones — pre-0.6.15 the rate-limit query only counted rows where
54
+ // used_ts was set, so an attacker could probe wrong codes indefinitely. A
55
+ // brute-force at 30 req/s/bot (Telegram's per-bot limit) against 30^8 codes
56
+ // takes 685 years even with no rate limit, so this is hardening against
57
+ // targeted guessing of a known-issued code rather than closing an active
58
+ // breach. In-memory state survives the typical polygram lifetime (days
59
+ // between restarts) and rebuilds in the worst case after a restart — the
60
+ // successful-claim DB check stays as belt-and-suspenders for the post-claim
61
+ // path.
62
+ function createAttemptTracker(now) {
63
+ const attemptsByUser = new Map(); // user_id → [ts, ts, ...]
64
+ return {
65
+ countRecent(userId, windowMs) {
66
+ const arr = attemptsByUser.get(userId);
67
+ if (!arr) return 0;
68
+ const cutoff = now() - windowMs;
69
+ // Garbage-collect the user's own bucket on every check; keeps memory
70
+ // bounded without a separate sweep timer.
71
+ const live = arr.filter((t) => t > cutoff);
72
+ if (live.length === 0) attemptsByUser.delete(userId);
73
+ else if (live.length !== arr.length) attemptsByUser.set(userId, live);
74
+ return live.length;
75
+ },
76
+ record(userId) {
77
+ const arr = attemptsByUser.get(userId) || [];
78
+ arr.push(now());
79
+ attemptsByUser.set(userId, arr);
80
+ },
81
+ };
82
+ }
83
+
52
84
  function createStore(rawDb, now = () => Date.now()) {
53
85
  const issueStmt = rawDb.prepare(`
54
86
  INSERT INTO pair_codes
@@ -100,10 +132,7 @@ function createStore(rawDb, now = () => Date.now()) {
100
132
  SELECT COUNT(*) AS n FROM pair_codes
101
133
  WHERE bot_name = ? AND issued_by_user_id = ? AND issued_ts > ?
102
134
  `);
103
- const recentClaimsByUserStmt = rawDb.prepare(`
104
- SELECT COUNT(*) AS n FROM pair_codes
105
- WHERE used_by_user_id = ? AND used_ts > ?
106
- `);
135
+ const claimAttempts = createAttemptTracker(now);
107
136
 
108
137
  return {
109
138
  issueCode({
@@ -144,10 +173,13 @@ function createStore(rawDb, now = () => Date.now()) {
144
173
  claimCode({ code, claimer_user_id, chat_id, bot_name }) {
145
174
  const norm = normalizeCode(code);
146
175
 
147
- const recent = recentClaimsByUserStmt.get(claimer_user_id, now() - 3_600_000).n;
176
+ // Rate-limit BEFORE the DB lookup so probing wrong codes also
177
+ // burns quota. Counts every attempt (success or failure).
178
+ const recent = claimAttempts.countRecent(claimer_user_id, 3_600_000);
148
179
  if (recent >= CLAIM_RATE_PER_USER_PER_HOUR) {
149
180
  return { ok: false, reason: 'rate-limited' };
150
181
  }
182
+ claimAttempts.record(claimer_user_id);
151
183
 
152
184
  const row = findCodeStmt.get(norm);
153
185
  if (!row) return { ok: false, reason: 'not-found' };
package/lib/prompt.js CHANGED
@@ -43,6 +43,10 @@ function buildReplyToBlock(input) {
43
43
  if (!input) return '';
44
44
  const { telegram, dbRow, replyToId } = input;
45
45
 
46
+ // Defense-in-depth: msg_id, ts, file sizes are numeric or system-generated
47
+ // in normal flows, but we run them through xmlEscape anyway so an unexpected
48
+ // upstream change (Telegram payload shape, DB type drift) can't introduce
49
+ // an attribute-injection vector through one missed escape site.
46
50
  if (telegram) {
47
51
  const msgId = telegram.message_id;
48
52
  const user = telegram.from?.first_name || telegram.from?.username || 'Unknown';
@@ -52,9 +56,9 @@ function buildReplyToBlock(input) {
52
56
  const summary = hasMedia ? summarizeTelegramAttachments(telegram) : '';
53
57
  const body = [text, summary].filter(Boolean).join('\n');
54
58
  const editedAttr = telegram.edit_date
55
- ? ` edited_ts="${new Date(telegram.edit_date * 1000).toISOString()}"`
59
+ ? ` edited_ts="${xmlEscape(new Date(telegram.edit_date * 1000).toISOString())}"`
56
60
  : '';
57
- return `<reply_to msg_id="${msgId}" user="${xmlEscape(user)}" ts="${ts}"${editedAttr} source="telegram">
61
+ return `<reply_to msg_id="${xmlEscape(msgId)}" user="${xmlEscape(user)}" ts="${xmlEscape(ts)}"${editedAttr} source="telegram">
58
62
  ${xmlEscape(body)}
59
63
  </reply_to>`;
60
64
  }
@@ -72,15 +76,15 @@ ${xmlEscape(body)}
72
76
  const ts = dbRow.ts ? new Date(dbRow.ts).toISOString() : '';
73
77
  const text = truncateReplyText(dbRow.text || '');
74
78
  const editedAttr = dbRow.edited_ts
75
- ? ` edited_ts="${new Date(dbRow.edited_ts).toISOString()}"`
79
+ ? ` edited_ts="${xmlEscape(new Date(dbRow.edited_ts).toISOString())}"`
76
80
  : '';
77
- return `<reply_to msg_id="${dbRow.msg_id}" user="${xmlEscape(dbRow.user || 'Unknown')}" ts="${ts}"${editedAttr} source="bridge-db">
81
+ return `<reply_to msg_id="${xmlEscape(dbRow.msg_id)}" user="${xmlEscape(dbRow.user || 'Unknown')}" ts="${xmlEscape(ts)}"${editedAttr} source="bridge-db">
78
82
  ${xmlEscape(text)}
79
83
  </reply_to>`;
80
84
  }
81
85
 
82
86
  if (replyToId) {
83
- return `<reply_to msg_id="${replyToId}" source="unresolvable">
87
+ return `<reply_to msg_id="${xmlEscape(replyToId)}" source="unresolvable">
84
88
  [original message not in transcript]
85
89
  </reply_to>`;
86
90
  }
@@ -126,7 +130,7 @@ function buildAttachmentTags(attachments) {
126
130
  if (a.error || !a.path) {
127
131
  return `<attachment-failed kind="${xmlEscape(a.kind)}" name="${xmlEscape(a.name)}" mime="${xmlEscape(a.mime_type)}" reason="${xmlEscape(a.error || 'no local path')}" />`;
128
132
  }
129
- return `<attachment kind="${xmlEscape(a.kind)}" name="${xmlEscape(a.name)}" mime="${xmlEscape(a.mime_type)}" size="${a.size || 0}" path="${xmlEscape(a.path)}" />`;
133
+ return `<attachment kind="${xmlEscape(a.kind)}" name="${xmlEscape(a.name)}" mime="${xmlEscape(a.mime_type)}" size="${xmlEscape(a.size || 0)}" path="${xmlEscape(a.path)}" />`;
130
134
  }).join('\n');
131
135
  }
132
136
 
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.13",
3
+ "version": "0.6.15",
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
@@ -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 {
@@ -210,6 +211,12 @@ function logEvent(kind, detail) {
210
211
  }
211
212
 
212
213
  function recordInbound(msg) {
214
+ // 0.6.4 wrapped the body in db.raw.transaction(...) for atomicity, but
215
+ // the wrap itself runs at call time — before dbWrite's null-db guard
216
+ // kicks in. A late-arriving inbound during shutdown (after db.raw.close())
217
+ // would TypeError and unhandled-reject grammy's update handler. Restore
218
+ // best-effort semantics with an explicit early-out.
219
+ if (!db) return;
213
220
  const chatId = msg.chat.id.toString();
214
221
  const threadId = msg.message_thread_id?.toString() || null;
215
222
  const user = msg.from?.first_name || msg.from?.username || null;
@@ -286,6 +293,22 @@ function sanitizeFilename(name) {
286
293
  return name.replace(/[\/\\:\0]/g, '_').slice(0, 120);
287
294
  }
288
295
 
296
+ // Short, filesystem-safe handle from the file_unique_id for auto-gen names.
297
+ // Telegram guarantees file_unique_id is stable per file across sessions; 8
298
+ // chars is collision-safe within a chat (~48 bits) and short enough to read.
299
+ // Falls back to msg.message_id for the rare case where file_unique_id is
300
+ // unset (very old Bot API rows). The pre-0.6.15 pattern embedded message_id
301
+ // directly, which then went stale after media-group reassignment rewrote
302
+ // the row's msg_id → name and msg_id disagreed about which Telegram message
303
+ // the file came from. Using file_unique_id makes the name stable across
304
+ // reassignment.
305
+ function shortFileTag(fileUniqueId, fallback) {
306
+ if (fileUniqueId) {
307
+ return String(fileUniqueId).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 8) || String(fallback);
308
+ }
309
+ return String(fallback);
310
+ }
311
+
289
312
  function extractAttachments(msg) {
290
313
  // Media-group bundling path: when we synthesised a single message from
291
314
  // several siblings sharing a media_group_id, the merged attachment list
@@ -299,7 +322,7 @@ function extractAttachments(msg) {
299
322
  items.push({
300
323
  file_id: d.file_id,
301
324
  file_unique_id: d.file_unique_id,
302
- name: d.file_name || `document-${msg.message_id}`,
325
+ name: d.file_name || `document-${shortFileTag(d.file_unique_id, msg.message_id)}`,
303
326
  mime_type: d.mime_type || 'application/octet-stream',
304
327
  size: d.file_size || 0,
305
328
  kind: 'document',
@@ -310,7 +333,7 @@ function extractAttachments(msg) {
310
333
  items.push({
311
334
  file_id: largest.file_id,
312
335
  file_unique_id: largest.file_unique_id,
313
- name: `photo-${msg.message_id}.jpg`,
336
+ name: `photo-${shortFileTag(largest.file_unique_id, msg.message_id)}.jpg`,
314
337
  mime_type: 'image/jpeg',
315
338
  size: largest.file_size || 0,
316
339
  kind: 'photo',
@@ -320,7 +343,7 @@ function extractAttachments(msg) {
320
343
  items.push({
321
344
  file_id: msg.voice.file_id,
322
345
  file_unique_id: msg.voice.file_unique_id,
323
- name: `voice-${msg.message_id}.ogg`,
346
+ name: `voice-${shortFileTag(msg.voice.file_unique_id, msg.message_id)}.ogg`,
324
347
  mime_type: msg.voice.mime_type || 'audio/ogg',
325
348
  size: msg.voice.file_size || 0,
326
349
  kind: 'voice',
@@ -331,7 +354,7 @@ function extractAttachments(msg) {
331
354
  items.push({
332
355
  file_id: a.file_id,
333
356
  file_unique_id: a.file_unique_id,
334
- name: a.file_name || `audio-${msg.message_id}.mp3`,
357
+ name: a.file_name || `audio-${shortFileTag(a.file_unique_id, msg.message_id)}.mp3`,
335
358
  mime_type: a.mime_type || 'audio/mpeg',
336
359
  size: a.file_size || 0,
337
360
  kind: 'audio',
@@ -342,7 +365,7 @@ function extractAttachments(msg) {
342
365
  items.push({
343
366
  file_id: v.file_id,
344
367
  file_unique_id: v.file_unique_id,
345
- name: v.file_name || `video-${msg.message_id}.mp4`,
368
+ name: v.file_name || `video-${shortFileTag(v.file_unique_id, msg.message_id)}.mp4`,
346
369
  mime_type: v.mime_type || 'video/mp4',
347
370
  size: v.file_size || 0,
348
371
  kind: 'video',
@@ -449,15 +472,46 @@ async function downloadOneAttachment(bot, token, chatId, msg, chatDir, att) {
449
472
  const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
450
473
  const res = await fetch(url);
451
474
  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.
475
+ // Three-layer size enforcement, in order of cheapness:
476
+ // 1. Content-Length header fail-fast before reading any body.
477
+ // 2. Streaming chunk-by-chunk accumulation abort the moment the
478
+ // cumulative byte count crosses the cap. This is the layer that
479
+ // protects against an attacker omitting Content-Length: pre-0.6.14
480
+ // we read the whole `res.arrayBuffer()` into RAM first and only
481
+ // then checked the size. With the per-bot ATTACHMENT_DOWNLOAD_
482
+ // CONCURRENCY default of 6, six unbounded reads in flight could
483
+ // pin arbitrary RSS — real OOM angle for a malicious upload.
484
+ // 3. Final post-buffer check is now redundant but cheap; left as
485
+ // defense-in-depth in case the streaming logic is ever changed.
456
486
  const cl = parseInt(res.headers.get('content-length') || '0', 10);
457
487
  if (cl > MAX_FILE_BYTES) {
458
488
  throw new Error(`content-length ${cl} exceeds per-file cap ${MAX_FILE_BYTES}`);
459
489
  }
460
- const buf = Buffer.from(await res.arrayBuffer());
490
+ let total = 0;
491
+ const chunks = [];
492
+ if (res.body && typeof res.body.getReader === 'function') {
493
+ const reader = res.body.getReader();
494
+ while (true) {
495
+ const { done, value } = await reader.read();
496
+ if (done) break;
497
+ total += value.byteLength;
498
+ if (total > MAX_FILE_BYTES) {
499
+ try { await reader.cancel(); } catch {}
500
+ throw new Error(`stream ${total}+ bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
501
+ }
502
+ chunks.push(value);
503
+ }
504
+ } else {
505
+ // Fallback for runtimes without WHATWG streams (shouldn't fire on
506
+ // Node 22+). Same arrayBuffer path as before, with the post-check.
507
+ const ab = await res.arrayBuffer();
508
+ if (ab.byteLength > MAX_FILE_BYTES) {
509
+ throw new Error(`body ${ab.byteLength} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
510
+ }
511
+ chunks.push(new Uint8Array(ab));
512
+ total = ab.byteLength;
513
+ }
514
+ const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
461
515
  if (buf.length > MAX_FILE_BYTES) {
462
516
  throw new Error(`body ${buf.length} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
463
517
  }
@@ -503,10 +557,11 @@ async function downloadOneAttachment(bot, token, chatId, msg, chatDir, att) {
503
557
  // requirement) and some undici/network error variants stringify
504
558
  // the request including the URL into err.message. Persisting that
505
559
  // 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.
560
+ // token. 0.6.14: centralized in net-errors.redactBotToken which
561
+ // also handles URL-encoded (%3A) variants and bare token shapes
562
+ // missed by the original regex.
508
563
  const raw = (err.message || 'unknown').slice(0, 200);
509
- const reason = raw.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot<redacted>');
564
+ const reason = redactBotToken(raw);
510
565
  console.error(`[attach] download failed for ${att.name}: ${reason}`);
511
566
  dbWrite(() => db.markAttachmentFailed(att.id, reason),
512
567
  `markAttachmentFailed ${att.id}`);
@@ -779,17 +834,20 @@ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
779
834
  console.error(`[${sessionKey}] Error:`, err.message);
780
835
  // Mark the row terminal so the right thing happens on next boot:
781
836
  // - 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.
837
+ // - shutting down on a NEW turn: 'replay-pending' so the next
838
+ // boot picks it up via getReplayCandidates. Pre-0.6.12 we marked
839
+ // 'failed' here, which excluded the row from replay a clean
840
+ // restart between user send and reply silently dropped the turn.
841
+ // - shutting down on a REPLAY turn (msg._isReplay=true): keep
842
+ // 'replay-attempted' so the one-shot guard from 0.6.4 still
843
+ // holds. Without this, a replay interrupted by another shutdown
844
+ // would be promoted to 'replay-pending' and the next boot would
845
+ // replay it AGAIN — infinite loop on chained shutdowns.
788
846
  // - everything else: 'failed' (genuine claude crash / timeout etc).
789
847
  const status = wasAborted
790
848
  ? 'aborted'
791
849
  : isShuttingDown
792
- ? 'replay-pending'
850
+ ? (isReplay ? 'replay-attempted' : 'replay-pending')
793
851
  : 'failed';
794
852
  dbWrite(() => db.setInboundHandlerStatus({
795
853
  chat_id: chatId, msg_id: msg.message_id, status,
@@ -1162,16 +1220,21 @@ async function handleApprovalCallback(ctx) {
1162
1220
  id, status, by: userId, user, bot: BOT_NAME,
1163
1221
  });
1164
1222
 
1165
- // Edit the card to show the decision.
1223
+ // Edit the card to show the decision. 0.6.14: routed through tg() so
1224
+ // the edit gets the same write-before-send DB row, plain-text policy
1225
+ // (no parse_mode injection from tool input), and logged failure
1226
+ // surface as every other outbound. Pre-0.6.14 this called
1227
+ // ctx.api.editMessageText directly — same bypass class as the two
1228
+ // already-routed in 0.6.8 (approval-timeout and pair-onboarding).
1166
1229
  try {
1167
1230
  const fresh = approvals.getById(id);
1168
- await ctx.api.editMessageText(
1169
- row.approver_chat_id,
1170
- row.approver_msg_id,
1171
- approvalCardText(fresh, {
1231
+ await tg(bot, 'editMessageText', {
1232
+ chat_id: row.approver_chat_id,
1233
+ message_id: row.approver_msg_id,
1234
+ text: approvalCardText(fresh, {
1172
1235
  resolvedBy: `${status === 'approved' ? '✅ Approved' : '❌ Denied'} by ${user || userId}`,
1173
1236
  }),
1174
- );
1237
+ }, { source: 'approval-card-decision', botName: BOT_NAME, plainText: true });
1175
1238
  } catch (err) {
1176
1239
  console.error(`[${BOT_NAME}] edit approval card failed: ${err.message}`);
1177
1240
  }