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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/lib/db.js +19 -8
- package/lib/pairings.js +37 -5
- package/lib/prompt.js +10 -6
- package/package.json +1 -1
- package/polygram.js +40 -6
|
@@ -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.
|
|
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
|
+
[](https://github.com/shumkov/polygram/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/shumkov/polygram)
|
|
5
|
+
[](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 #
|
|
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
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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 = '
|
|
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 = '
|
|
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 = '
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|