polygram 0.12.0-rc.13 → 0.12.0-rc.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/lib/handlers/download.js +101 -58
- package/lib/process/cli-process.js +19 -39
- package/package.json +1 -1
- package/polygram.js +8 -1
package/lib/handlers/download.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
const fs = require('fs');
|
|
25
25
|
const path = require('path');
|
|
26
26
|
const { redactBotToken } = require('../error/net');
|
|
27
|
-
const { MAX_FILE_BYTES } = require('../attachments');
|
|
27
|
+
const { MAX_FILE_BYTES, resolveFileCaps } = require('../attachments');
|
|
28
28
|
|
|
29
29
|
const ATTACHMENT_DOWNLOAD_CONCURRENCY_DEFAULT = 6;
|
|
30
30
|
|
|
@@ -60,76 +60,119 @@ function createDownloadAttachments({
|
|
|
60
60
|
} catch { /* fall through to refetch */ }
|
|
61
61
|
}
|
|
62
62
|
try {
|
|
63
|
+
// Inbound per-file cap is BACKEND-derived: 20 MB on cloud Telegram
|
|
64
|
+
// (Telegram's own getFile ceiling), 2 GB with the local Bot API server.
|
|
65
|
+
// rc.15: previously hardcoded to MAX_FILE_BYTES (20 MB), which rejected
|
|
66
|
+
// large lossless tracks even when the local server could handle them.
|
|
67
|
+
const cap = resolveFileCaps({ localApi: !!config.bot?.apiRoot }).inBytes;
|
|
68
|
+
|
|
63
69
|
const fileInfo = await bot.api.getFile(att.file_id);
|
|
64
70
|
if (!fileInfo?.file_path) throw new Error('no file_path from getFile');
|
|
65
|
-
|
|
66
|
-
const res = await fetchImpl(url);
|
|
67
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
68
|
-
// Three-layer size enforcement, in order of cheapness:
|
|
69
|
-
// 1. Content-Length header — fail-fast before reading body.
|
|
70
|
-
// 2. Streaming accumulator — abort the moment cumulative byte
|
|
71
|
-
// count crosses the cap. Defends against attackers omitting
|
|
72
|
-
// Content-Length: pre-cap the whole body could pin RSS.
|
|
73
|
-
// 3. Final post-buffer check — defense in depth.
|
|
74
|
-
const cl = parseInt(res.headers.get('content-length') || '0', 10);
|
|
75
|
-
if (cl > MAX_FILE_BYTES) {
|
|
76
|
-
throw new Error(`content-length ${cl} exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
77
|
-
}
|
|
78
|
-
let total = 0;
|
|
79
|
-
const chunks = [];
|
|
80
|
-
if (res.body && typeof res.body.getReader === 'function') {
|
|
81
|
-
const reader = res.body.getReader();
|
|
82
|
-
while (true) {
|
|
83
|
-
const { done, value } = await reader.read();
|
|
84
|
-
if (done) break;
|
|
85
|
-
total += value.byteLength;
|
|
86
|
-
if (total > MAX_FILE_BYTES) {
|
|
87
|
-
try { await reader.cancel(); } catch {}
|
|
88
|
-
throw new Error(`stream ${total}+ bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
89
|
-
}
|
|
90
|
-
chunks.push(value);
|
|
91
|
-
}
|
|
92
|
-
} else {
|
|
93
|
-
// Fallback for runtimes without WHATWG streams (shouldn't fire
|
|
94
|
-
// on Node 22+).
|
|
95
|
-
const ab = await res.arrayBuffer();
|
|
96
|
-
if (ab.byteLength > MAX_FILE_BYTES) {
|
|
97
|
-
throw new Error(`body ${ab.byteLength} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
98
|
-
}
|
|
99
|
-
chunks.push(new Uint8Array(ab));
|
|
100
|
-
total = ab.byteLength;
|
|
101
|
-
}
|
|
102
|
-
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
103
|
-
if (buf.length > MAX_FILE_BYTES) {
|
|
104
|
-
throw new Error(`body ${buf.length} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
105
|
-
}
|
|
71
|
+
|
|
106
72
|
const safeName = sanitizeFilename(att.name);
|
|
107
73
|
// Embed file_unique_id so two attachments with the same msg_id+name
|
|
108
74
|
// (album, resend) can't silently overwrite each other.
|
|
109
75
|
const uniq = att.file_unique_id ? `-${att.file_unique_id}` : '';
|
|
110
76
|
const localName = `${msg.message_id}${uniq}-${safeName}`;
|
|
111
77
|
const localPath = path.join(chatDir, localName);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
78
|
+
|
|
79
|
+
let size;
|
|
80
|
+
|
|
81
|
+
if (path.isAbsolute(fileInfo.file_path)) {
|
|
82
|
+
// ── Local Bot API server ────────────────────────────────────────
|
|
83
|
+
// rc.15: in `--local` mode getFile returns a LOCAL ABSOLUTE PATH —
|
|
84
|
+
// the server has already downloaded the file to its own disk. The
|
|
85
|
+
// previous code built a cloud URL (https://api.telegram.org/file/...)
|
|
86
|
+
// and HTTP-fetched it, which is nonsensical for a local path and
|
|
87
|
+
// failed every inbound file once apiRoot was set. Instead, link the
|
|
88
|
+
// file into the inbox directly (no HTTP, no buffering a 2 GB file
|
|
89
|
+
// through RAM). A hardlink is instant and shares the inode, so it
|
|
90
|
+
// survives the server pruning its own copy; fall back to a byte copy
|
|
91
|
+
// across filesystems.
|
|
92
|
+
const srcStat = fs.statSync(fileInfo.file_path);
|
|
93
|
+
if (srcStat.size > cap) {
|
|
94
|
+
throw new Error(`file ${srcStat.size} exceeds per-file cap ${cap}`);
|
|
95
|
+
}
|
|
96
|
+
if (fs.existsSync(localPath)) {
|
|
97
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
|
|
98
|
+
} else {
|
|
99
|
+
try {
|
|
100
|
+
fs.linkSync(fileInfo.file_path, localPath);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e.code === 'EEXIST') {
|
|
103
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
|
|
104
|
+
} else if (e.code === 'EXDEV') {
|
|
105
|
+
fs.copyFileSync(fileInfo.file_path, localPath); // cross-device fallback
|
|
106
|
+
} else {
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
size = srcStat.size;
|
|
112
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${size} bytes, local-api) → ${localPath}`);
|
|
117
113
|
} else {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
114
|
+
// ── Cloud Telegram ──────────────────────────────────────────────
|
|
115
|
+
// getFile returns a RELATIVE path; download it over HTTPS with the
|
|
116
|
+
// three-layer size guard (header → streaming accumulator → final).
|
|
117
|
+
const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
|
|
118
|
+
const res = await fetchImpl(url);
|
|
119
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
120
|
+
const cl = parseInt(res.headers.get('content-length') || '0', 10);
|
|
121
|
+
if (cl > cap) {
|
|
122
|
+
throw new Error(`content-length ${cl} exceeds per-file cap ${cap}`);
|
|
126
123
|
}
|
|
124
|
+
let total = 0;
|
|
125
|
+
const chunks = [];
|
|
126
|
+
if (res.body && typeof res.body.getReader === 'function') {
|
|
127
|
+
const reader = res.body.getReader();
|
|
128
|
+
while (true) {
|
|
129
|
+
const { done, value } = await reader.read();
|
|
130
|
+
if (done) break;
|
|
131
|
+
total += value.byteLength;
|
|
132
|
+
if (total > cap) {
|
|
133
|
+
try { await reader.cancel(); } catch {}
|
|
134
|
+
throw new Error(`stream ${total}+ bytes exceeds per-file cap ${cap}`);
|
|
135
|
+
}
|
|
136
|
+
chunks.push(value);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// Fallback for runtimes without WHATWG streams (shouldn't fire
|
|
140
|
+
// on Node 22+).
|
|
141
|
+
const ab = await res.arrayBuffer();
|
|
142
|
+
if (ab.byteLength > cap) {
|
|
143
|
+
throw new Error(`body ${ab.byteLength} bytes exceeds per-file cap ${cap}`);
|
|
144
|
+
}
|
|
145
|
+
chunks.push(new Uint8Array(ab));
|
|
146
|
+
total = ab.byteLength;
|
|
147
|
+
}
|
|
148
|
+
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
149
|
+
if (buf.length > cap) {
|
|
150
|
+
throw new Error(`body ${buf.length} bytes exceeds per-file cap ${cap}`);
|
|
151
|
+
}
|
|
152
|
+
// Atomic write: temp file + rename. A crash mid-write leaves a
|
|
153
|
+
// .tmp.* file (swept later) rather than a truncated canonical
|
|
154
|
+
// file the EEXIST dedup branch would happily serve next time.
|
|
155
|
+
if (fs.existsSync(localPath)) {
|
|
156
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
|
|
157
|
+
} else {
|
|
158
|
+
const tmpPath = `${localPath}.tmp.${process.pid}.${Date.now()}`;
|
|
159
|
+
try {
|
|
160
|
+
fs.writeFileSync(tmpPath, buf, { flag: 'wx' });
|
|
161
|
+
fs.renameSync(tmpPath, localPath);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
164
|
+
if (e.code !== 'EEXIST') throw e;
|
|
165
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
size = buf.length;
|
|
169
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${size} bytes) → ${localPath}`);
|
|
127
170
|
}
|
|
128
|
-
|
|
171
|
+
|
|
129
172
|
dbWrite(() => db.markAttachmentDownloaded(att.id, {
|
|
130
|
-
local_path: localPath, size_bytes: att.size_bytes ||
|
|
173
|
+
local_path: localPath, size_bytes: att.size_bytes || size,
|
|
131
174
|
}), `markAttachmentDownloaded ${att.id}`);
|
|
132
|
-
return { ...att, path: localPath, size: att.size_bytes ||
|
|
175
|
+
return { ...att, path: localPath, size: att.size_bytes || size, error: null };
|
|
133
176
|
} catch (err) {
|
|
134
177
|
// Don't drop the attachment silently — push it through with the
|
|
135
178
|
// failure noted. buildAttachmentTags renders this as
|
|
@@ -120,16 +120,17 @@ const STREAMING_HINT_RE = /esc to interrupt/i;
|
|
|
120
120
|
// — false positives surface as no-op telemetry, false negatives surface
|
|
121
121
|
// as the idle-ceiling timeout (~10min).
|
|
122
122
|
const UNKNOWN_PROMPT_HEURISTIC_RE = /(\?\s*$|\(y\/N\)|Yes\/No|❯\s|^\s*[12345]\.\s)/im;
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// `
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
123
|
+
// rc.14: a previous rc (rc.11) had a BRIDGE_DEAD_RE here that matched the pane
|
|
124
|
+
// line "server:polygram-bridge no MCP server configured with that name" and
|
|
125
|
+
// treated it as a dead bridge to recover from. That was a MISDIAGNOSIS: this
|
|
126
|
+
// line is a BENIGN, persistent banner that `--dangerously-load-development-
|
|
127
|
+
// channels` + `--strict-mcp-config` prints on EVERY healthy session — the
|
|
128
|
+
// channel still delivers messages and the reply tool still works (reproduced
|
|
129
|
+
// 2026-06-01 with a test MCP server that demonstrably functions). The pane
|
|
130
|
+
// matcher therefore false-fired ~5s into every channels turn and KILLED
|
|
131
|
+
// healthy sessions (the Music-topic "mid-turn detach" regression). Real bridge
|
|
132
|
+
// loss is caught by the socket-close path (bridgeServer 'bridge-disconnected'
|
|
133
|
+
// → _handleBridgeDisconnected). There is no reliable pane signal — removed.
|
|
133
134
|
// Per-pattern rate limit so a dialog that lingers across multiple polls
|
|
134
135
|
// doesn't spam sendControl/event emissions. Aligned with the 5s poll cadence.
|
|
135
136
|
const MID_TURN_DEDUP_WINDOW_MS = 30_000;
|
|
@@ -2411,35 +2412,14 @@ class CliProcess extends Process {
|
|
|
2411
2412
|
}
|
|
2412
2413
|
if (!pane) return;
|
|
2413
2414
|
|
|
2414
|
-
//
|
|
2415
|
-
//
|
|
2416
|
-
//
|
|
2417
|
-
//
|
|
2418
|
-
//
|
|
2419
|
-
//
|
|
2420
|
-
//
|
|
2421
|
-
//
|
|
2422
|
-
// orphans and would hang until the wall-clock cap while this watchdog
|
|
2423
|
-
// logged cli-mid-turn-unknown-prompt every 30s and recovered nothing.
|
|
2424
|
-
// Detect it from the pane and route through the SAME recovery as a real
|
|
2425
|
-
// socket disconnect: reject pending turns (user gets the 🔌 resend
|
|
2426
|
-
// message via BRIDGE_DISCONNECTED) and emit 'bridge-disconnected' so
|
|
2427
|
-
// process-manager kills + lazy-respawns the dead instance (next msg
|
|
2428
|
-
// recovers the conversation via `claude --resume`). Gated on
|
|
2429
|
-
// pendingTurns.size>0 by the early return above, so it can't fire on an
|
|
2430
|
-
// idle session that merely has the string in scrollback.
|
|
2431
|
-
if (BRIDGE_DEAD_RE.test(pane)) {
|
|
2432
|
-
this.logger.warn?.(
|
|
2433
|
-
`[${this.label}] cli: channels MCP registration lost mid-turn ` +
|
|
2434
|
-
`(pane-detected) — recovering ${this.pendingTurns.size} orphaned turn(s)`,
|
|
2435
|
-
);
|
|
2436
|
-
this._logEvent('cli-bridge-detached-midturn', {
|
|
2437
|
-
pending_count: this.pendingTurns.size,
|
|
2438
|
-
session_id: this.claudeSessionId,
|
|
2439
|
-
});
|
|
2440
|
-
this._handleBridgeDisconnected('mcp-registration-lost');
|
|
2441
|
-
return;
|
|
2442
|
-
}
|
|
2415
|
+
// rc.14: removed the rc.11 pane-based "dead bridge" detection here. It
|
|
2416
|
+
// matched the BENIGN banner "server:polygram-bridge no MCP server
|
|
2417
|
+
// configured with that name" — a cosmetic line that
|
|
2418
|
+
// `--dangerously-load-development-channels` + `--strict-mcp-config` prints
|
|
2419
|
+
// on EVERY healthy session (channel still delivers; reply tool still
|
|
2420
|
+
// works). The matcher false-fired ~5s into every channels turn and killed
|
|
2421
|
+
// healthy sessions. Real bridge loss is the socket-close path
|
|
2422
|
+
// (_handleBridgeDisconnected), not anything observable in the pane.
|
|
2443
2423
|
|
|
2444
2424
|
const now = Date.now();
|
|
2445
2425
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.12.0-rc.
|
|
3
|
+
"version": "0.12.0-rc.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
|
@@ -1698,7 +1698,14 @@ function createBot(token) {
|
|
|
1698
1698
|
const apiRoot = config.bot?.apiRoot;
|
|
1699
1699
|
const bot = new Bot(token, {
|
|
1700
1700
|
client: {
|
|
1701
|
-
|
|
1701
|
+
// rc.15: with the local Bot API server, getFile DOWNLOADS the file
|
|
1702
|
+
// synchronously (server fetches it from Telegram's DC, then responds) —
|
|
1703
|
+
// a large lossless WAV can take >60s, so the cloud-tuned 60s timeout
|
|
1704
|
+
// fired before the download finished (the file still landed on the
|
|
1705
|
+
// server's disk, but polygram's getFile call already errored). The
|
|
1706
|
+
// local server is localhost, so non-download calls stay fast; the
|
|
1707
|
+
// higher ceiling only matters for big getFile downloads.
|
|
1708
|
+
timeoutSeconds: apiRoot ? 180 : 60,
|
|
1702
1709
|
...(apiRoot ? { apiRoot } : {}),
|
|
1703
1710
|
},
|
|
1704
1711
|
});
|