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.
- package/config.example.json +5 -1
- package/lib/attachments.js +46 -2
- package/lib/claude-bin.js +8 -1
- package/lib/compaction-warn.js +59 -0
- package/lib/context-usage.js +93 -0
- package/lib/error/classify.js +12 -0
- package/lib/handlers/abort.js +38 -1
- package/lib/handlers/config-callback.js +8 -2
- package/lib/handlers/config-ui.js +23 -9
- package/lib/handlers/dispatcher.js +43 -0
- package/lib/handlers/download.js +101 -58
- package/lib/ipc/file-validator.js +8 -1
- package/lib/process/channels-tool-dispatcher.js +20 -2
- package/lib/process/cli-process.js +447 -73
- package/lib/process/factory.js +0 -5
- package/lib/process/hook-event-tail.js +7 -0
- package/lib/process/hook-settings.js +7 -0
- package/lib/process-manager.js +6 -0
- package/lib/sdk/callbacks.js +61 -7
- package/lib/telegram/album-reactions.js +50 -0
- package/lib/telegram/api.js +9 -0
- package/lib/telegram/input-file.js +76 -0
- package/lib/telegram/reactions.js +5 -0
- package/lib/tmux/log-tail.js +11 -1
- package/lib/tmux/startup-gate.js +65 -1
- package/package.json +1 -1
- package/polygram.js +64 -21
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 });
|