polygram 0.12.0-rc.2 → 0.12.0-rc.21

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
@@ -50,7 +50,14 @@ function validateIpcFileParam(method, params = {}) {
50
50
  const fileParam = FILE_PARAM_BY_METHOD[method];
51
51
  if (!fileParam) return null;
52
52
  const val = params[fileParam];
53
- if (typeof val !== 'string') return null; // envelope/Buffer/etcpass through
53
+ // { source: '/abs/path' } envelope — now coerced to a grammy InputFile in
54
+ // tg() (coerceFileParams). Validate it has a usable absolute source, else
55
+ // pass through (Buffer / stream / InputFile shapes).
56
+ if (val && typeof val === 'object' && typeof val.source === 'string') {
57
+ if (val.source.length === 0) return `polygram IPC: ${fileParam}.source is empty`;
58
+ return null;
59
+ }
60
+ if (typeof val !== 'string') return null; // Buffer/InputFile/etc — pass through
54
61
  if (val.length === 0) return `polygram IPC: ${fileParam} is empty`;
55
62
 
56
63
  const looksUrl = /^(https?|ftp):\/\//i.test(val);
@@ -125,7 +125,7 @@ function createChannelsToolDispatcher({
125
125
  || require('../telegram/process-agent-reply').processAndDeliverAgentText;
126
126
 
127
127
  return async function channelsToolDispatcher(call) {
128
- const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId } = call;
128
+ const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId, maxOutboundFileBytes } = call;
129
129
 
130
130
  if (toolName !== 'reply') {
131
131
  // 0.11.0 Phase 1 ships `reply` only — react and edit_message are
@@ -196,6 +196,21 @@ function createChannelsToolDispatcher({
196
196
  failedAttachments.push({ path: filePath, error: check.error });
197
197
  continue;
198
198
  }
199
+ // Backend/chat-derived upload cap. Reject oversize BEFORE upload with
200
+ // a clear error (vs Telegram's cryptic 413/"file is too big") so
201
+ // claude can convert/compress and retry. maxOutboundFileBytes is
202
+ // undefined for non-channels callers → no cap (Telegram still gates).
203
+ if (typeof maxOutboundFileBytes === 'number' && maxOutboundFileBytes > 0) {
204
+ let size = 0;
205
+ try { size = fs.statSync(check.resolved).size; } catch {}
206
+ if (size > maxOutboundFileBytes) {
207
+ const mb = (n) => (n / (1024 * 1024)).toFixed(1);
208
+ const err = `file too large to send: ${mb(size)}MB > ${mb(maxOutboundFileBytes)}MB limit`;
209
+ logger.warn?.(`[channels-tool-dispatcher] ${err} (${check.resolved})`);
210
+ failedAttachments.push({ path: filePath, error: err });
211
+ continue;
212
+ }
213
+ }
199
214
  try {
200
215
  const ext = path.extname(check.resolved).toLowerCase();
201
216
  const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
@@ -203,7 +218,10 @@ function createChannelsToolDispatcher({
203
218
  const fieldName = isImage ? 'photo' : 'document';
204
219
  const params = {
205
220
  chat_id: chatId,
206
- [fieldName]: { source: check.resolved },
221
+ // { source } envelope → grammy InputFile in tg()'s coerceFileParams.
222
+ // Pre-fix this bare object reached grammy unrecognized and every
223
+ // upload 400'd with "Wrong port number" (file-send never worked).
224
+ [fieldName]: { source: check.resolved, filename: path.basename(check.resolved) },
207
225
  };
208
226
  if (threadId) params.message_thread_id = threadId;
209
227
  await send(bot, method, params, { source: 'channels-tool-dispatcher', sessionKey });