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.
@@ -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
- const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
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
- // Atomic write: temp file + rename. A crash mid-write leaves a
113
- // .tmp.* file (swept later) rather than a truncated canonical
114
- // file the EEXIST dedup branch would happily serve next time.
115
- if (fs.existsSync(localPath)) {
116
- logger.log?.(`[attach] ${chatId} ${att.kind} ${safeName} (already on disk, reusing)`);
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
- const tmpPath = `${localPath}.tmp.${process.pid}.${Date.now()}`;
119
- try {
120
- fs.writeFileSync(tmpPath, buf, { flag: 'wx' });
121
- fs.renameSync(tmpPath, localPath);
122
- } catch (e) {
123
- try { fs.unlinkSync(tmpPath); } catch {}
124
- if (e.code !== 'EEXIST') throw e;
125
- logger.log?.(`[attach] ${chatId} ${att.kind} ${safeName} (race: already on disk)`);
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
- logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${buf.length} bytes) → ${localPath}`);
171
+
129
172
  dbWrite(() => db.markAttachmentDownloaded(att.id, {
130
- local_path: localPath, size_bytes: att.size_bytes || buf.length,
173
+ local_path: localPath, size_bytes: att.size_bytes || size,
131
174
  }), `markAttachmentDownloaded ${att.id}`);
132
- return { ...att, path: localPath, size: att.size_bytes || buf.length, error: null };
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
- // Dead-bridge signal. Claude Code prints "<source> no MCP server configured
124
- // with that name" when a channel source references an MCP server that isn't
125
- // registered. Music topic incident (2026-06-01): seen mid-turn after an
126
- // auto-/compact of a large resumed session redrew the TUI and dropped the
127
- // `server:polygram-bridge` binding the turn could no longer deliver its
128
- // reply and hung. The bridge name precedes the error on the same line, so
129
- // anchor on it: matching the bare phrase risks a false hit on prose that
130
- // merely quotes the error, while the healthy "polygram-bridge: <polygram-info>
131
- // …" connection line never contains "no MCP server configured".
132
- const BRIDGE_DEAD_RE = /polygram-bridge[^\n]*no MCP server configured/i;
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
- // Wedge recovery (Music topic incident, 2026-06-01). Claude Code's
2415
- // channel source can lose its MCP-server registration mid-turn most
2416
- // often after an auto-/compact of a large resumed session redraws the
2417
- // TUI and the `server:polygram-bridge` binding fails to re-resolve
2418
- // ("polygram-bridge no MCP server configured with that name"). The
2419
- // bridge SOCKET stays up, so the socket-close path
2420
- // (bridgeServer 'bridge-disconnected' _handleBridgeDisconnected) never
2421
- // fires but claude can no longer deliver the reply, so the turn
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.13",
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
- timeoutSeconds: 60,
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
  });