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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +19 -7
- package/config.example.json +4 -0
- package/lib/attachments.js +12 -8
- package/lib/db.js +37 -19
- package/lib/media-group-buffer.js +40 -2
- package/lib/net-errors.js +27 -0
- package/lib/pairings.js +37 -5
- package/lib/prompt.js +10 -6
- package/lib/telegram.js +10 -5
- package/ops/polygram.plist.example +1 -1
- package/package.json +1 -1
- package/polygram.js +89 -26
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
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
|
+
[](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
|
|
@@ -256,11 +260,20 @@ Per-bot flags:
|
|
|
256
260
|
|
|
257
261
|
- `allowConfigCommands: true` — enables `/model`, `/effort`, `/pair-code`,
|
|
258
262
|
`/pairings`, `/unpair`.
|
|
259
|
-
- `
|
|
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 #
|
|
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
|
|
388
|
-
own GUI login — there's no auto-start without the keychain
|
|
389
|
-
unlocked, so a one-time Fast User Switch into the daemon's
|
|
390
|
-
after each reboot is the supported pattern.
|
|
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
|
|
package/config.example.json
CHANGED
|
@@ -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",
|
package/lib/attachments.js
CHANGED
|
@@ -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
|
|
39
|
-
// Telegram sometimes reports file_size=0 or omits it.
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
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 +
|
|
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 +=
|
|
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
|
|
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
|
|
|
@@ -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
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
// boot-time markStalePending sweep
|
|
346
|
-
// '
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
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 <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 (
|
|
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
|
-
|
|
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
|
|
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/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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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/
|
|
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.
|
|
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
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
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
|
-
|
|
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
|
|
507
|
-
//
|
|
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
|
|
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
|
|
783
|
-
//
|
|
784
|
-
// '
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
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
|
|
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
|
}
|