polygram 0.6.14 → 0.6.16

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.6.14",
4
+ "version": "0.6.16",
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
@@ -360,7 +364,7 @@ foreign-chat clicks are rejected. Default-deny on IPC error.
360
364
  ## Development
361
365
 
362
366
  ```bash
363
- npm test # 494 tests, 114 suites, node:test, no external services
367
+ npm test # 500 tests, 115 suites, node:test, no external services
364
368
  npm run coverage # native test coverage (Node 22+, no devDeps)
365
369
  npm start -- --bot my-bot
366
370
  npm run split-db -- --config config.json --dry-run
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
 
@@ -359,7 +370,7 @@ function wrap(db) {
359
370
  AND (
360
371
  status = 'sent'
361
372
  OR status = 'pending'
362
- OR (status = 'failed' AND error = 'crashed-mid-send')
373
+ OR (status = 'failed' AND error = '${CRASHED_MID_SEND}')
363
374
  )
364
375
  LIMIT 1
365
376
  `).get(chat_id, msg_id);
@@ -526,4 +537,4 @@ function wrap(db) {
526
537
  };
527
538
  }
528
539
 
529
- module.exports = { open };
540
+ module.exports = { open, CRASHED_MID_SEND };
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.6.14",
3
+ "version": "0.6.16",
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
@@ -211,6 +211,12 @@ function logEvent(kind, detail) {
211
211
  }
212
212
 
213
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;
214
220
  const chatId = msg.chat.id.toString();
215
221
  const threadId = msg.message_thread_id?.toString() || null;
216
222
  const user = msg.from?.first_name || msg.from?.username || null;
@@ -287,6 +293,22 @@ function sanitizeFilename(name) {
287
293
  return name.replace(/[\/\\:\0]/g, '_').slice(0, 120);
288
294
  }
289
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
+
290
312
  function extractAttachments(msg) {
291
313
  // Media-group bundling path: when we synthesised a single message from
292
314
  // several siblings sharing a media_group_id, the merged attachment list
@@ -300,7 +322,7 @@ function extractAttachments(msg) {
300
322
  items.push({
301
323
  file_id: d.file_id,
302
324
  file_unique_id: d.file_unique_id,
303
- name: d.file_name || `document-${msg.message_id}`,
325
+ name: d.file_name || `document-${shortFileTag(d.file_unique_id, msg.message_id)}`,
304
326
  mime_type: d.mime_type || 'application/octet-stream',
305
327
  size: d.file_size || 0,
306
328
  kind: 'document',
@@ -311,7 +333,7 @@ function extractAttachments(msg) {
311
333
  items.push({
312
334
  file_id: largest.file_id,
313
335
  file_unique_id: largest.file_unique_id,
314
- name: `photo-${msg.message_id}.jpg`,
336
+ name: `photo-${shortFileTag(largest.file_unique_id, msg.message_id)}.jpg`,
315
337
  mime_type: 'image/jpeg',
316
338
  size: largest.file_size || 0,
317
339
  kind: 'photo',
@@ -321,7 +343,7 @@ function extractAttachments(msg) {
321
343
  items.push({
322
344
  file_id: msg.voice.file_id,
323
345
  file_unique_id: msg.voice.file_unique_id,
324
- name: `voice-${msg.message_id}.ogg`,
346
+ name: `voice-${shortFileTag(msg.voice.file_unique_id, msg.message_id)}.ogg`,
325
347
  mime_type: msg.voice.mime_type || 'audio/ogg',
326
348
  size: msg.voice.file_size || 0,
327
349
  kind: 'voice',
@@ -332,7 +354,7 @@ function extractAttachments(msg) {
332
354
  items.push({
333
355
  file_id: a.file_id,
334
356
  file_unique_id: a.file_unique_id,
335
- name: a.file_name || `audio-${msg.message_id}.mp3`,
357
+ name: a.file_name || `audio-${shortFileTag(a.file_unique_id, msg.message_id)}.mp3`,
336
358
  mime_type: a.mime_type || 'audio/mpeg',
337
359
  size: a.file_size || 0,
338
360
  kind: 'audio',
@@ -343,7 +365,7 @@ function extractAttachments(msg) {
343
365
  items.push({
344
366
  file_id: v.file_id,
345
367
  file_unique_id: v.file_unique_id,
346
- name: v.file_name || `video-${msg.message_id}.mp4`,
368
+ name: v.file_name || `video-${shortFileTag(v.file_unique_id, msg.message_id)}.mp4`,
347
369
  mime_type: v.mime_type || 'video/mp4',
348
370
  size: v.file_size || 0,
349
371
  kind: 'video',
@@ -764,6 +786,9 @@ function errorReplyText(err) {
764
786
  if (/Process (exited|killed)/i.test(msg)) {
765
787
  return '💥 Something crashed on my end. Try again.';
766
788
  }
789
+ if (/error_during_execution/i.test(msg)) {
790
+ return '💥 Something went wrong mid-stream. Try again.';
791
+ }
767
792
  const reason = msg.split('\n')[0].slice(0, 120);
768
793
  return `Hit a snag: ${reason || 'unknown error'}. Try resending.`;
769
794
  }
@@ -1739,7 +1764,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1739
1764
  if (result.error) {
1740
1765
  console.error(`[${label}] Error (${elapsed}s):`, result.error);
1741
1766
  reactor.setState('ERROR');
1742
- if (!result.text) { markReplied(); return; }
1767
+ // 0.6.16: pre-fix, silently markReplied()+return — the user got an
1768
+ // error reaction emoji on their message but no actual reply text,
1769
+ // AND 'replied' status meant boot replay didn't re-dispatch on next
1770
+ // boot. Worst-case: shutdown-killed turn (e.g. polygram upgrade
1771
+ // mid-stream) → user sends "yes", sees 🤯, gets no answer ever,
1772
+ // the row is silently lost. Promote to a thrown error so
1773
+ // dispatchHandleMessage's catch correctly distinguishes shutdown
1774
+ // (→ 'replay-pending', boot replay retries) from runtime failure
1775
+ // (→ 'failed', user gets an apology with retry hint).
1776
+ if (!result.text) throw new Error(result.error);
1743
1777
  } else {
1744
1778
  // Clear the progress reaction instead of stamping 👍 — the reply
1745
1779
  // bubble itself is the "done" signal and a permanent thumbs-up on