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