polygram 0.12.0-rc.6 → 0.12.0-rc.8
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 +4 -1
- package/lib/attachments.js +46 -2
- package/lib/ipc/file-validator.js +8 -1
- package/lib/process/channels-tool-dispatcher.js +20 -2
- package/lib/process/cli-process.js +88 -0
- package/lib/telegram/api.js +9 -0
- package/lib/telegram/input-file.js +76 -0
- package/package.json +1 -1
- package/polygram.js +33 -3
package/config.example.json
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"bots": {
|
|
5
5
|
"admin-bot": {
|
|
6
6
|
"token": "REPLACE_WITH_BOT_TOKEN_FROM_BOTFATHER",
|
|
7
|
+
"_comment_apiRoot": "Optional. Point grammy at a self-hosted Telegram Bot API server (e.g. 'http://localhost:8081' from a local `telegram-bot-api --local` process) to raise file send/receive limits from cloud's 50MB-out / 20MB-in to 2GB both ways. Omit for cloud Telegram (default, unchanged). The server is a separate localhost-only companion daemon — see docs/0.12.0-file-send.md.",
|
|
7
8
|
"allowConfigCommands": true,
|
|
8
9
|
"_comment_adminChatId": "Required when allowConfigCommands is true for pairing commands (/pair-code, /pairings, /unpair) to work. These grant cross-chat trust and are gated to the admin chat only.",
|
|
9
10
|
"adminChatId": "123456789",
|
|
@@ -70,7 +71,9 @@
|
|
|
70
71
|
"model": "opus",
|
|
71
72
|
"effort": "medium",
|
|
72
73
|
"cwd": "/Users/you/admin-agent",
|
|
73
|
-
"timeout": 600
|
|
74
|
+
"timeout": 600,
|
|
75
|
+
"_comment_maxFileBytes": "Optional per-chat file-size cap in BYTES for send/receive, also settable per-topic (topic wins). Default follows the backend: cloud Telegram 50MB send / 20MB receive; with bot.apiRoot (local Bot API server) 2GB both ways. An override can only LOWER the cap below the backend ceiling — Telegram rejects anything larger regardless. Example: 104857600 = 100MB (only effective when apiRoot is set).",
|
|
76
|
+
"maxFileBytes": 104857600
|
|
74
77
|
},
|
|
75
78
|
|
|
76
79
|
"-1000000000001": {
|
package/lib/attachments.js
CHANGED
|
@@ -22,8 +22,48 @@
|
|
|
22
22
|
* extension — the fallback only kicks in when MIME is unhelpful.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// Inbound (user → bot) per-file cap. Telegram's cloud Bot API hard-caps
|
|
26
|
+
// bot file DOWNLOADS (getFile) at 20 MB, so 20 MB is the real ceiling on
|
|
27
|
+
// cloud — raised from 10 MB so users can send larger tracks/docs. With a
|
|
28
|
+
// self-hosted Bot API server (config.bot.apiRoot) the Telegram limit rises
|
|
29
|
+
// to 2 GB; resolveFileCaps() raises the default accordingly.
|
|
30
|
+
const MAX_FILE_BYTES = 20 * 1024 * 1024;
|
|
31
|
+
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
// ─── Backend-derived file-size caps (cloud vs local Bot API server) ──
|
|
34
|
+
//
|
|
35
|
+
// These are the HARD ceilings Telegram itself enforces — a per-chat
|
|
36
|
+
// override can lower them but never exceed them (Telegram rejects beyond
|
|
37
|
+
// regardless). NOT "adaptive": there is no intermediate tier. Cloud is a
|
|
38
|
+
// flat 20 in / 50 out; a local `telegram-bot-api --local` server is a flat
|
|
39
|
+
// 2 GB both ways.
|
|
40
|
+
const CLOUD_MAX_IN_BYTES = 20 * 1024 * 1024; // getFile download limit
|
|
41
|
+
const CLOUD_MAX_OUT_BYTES = 50 * 1024 * 1024; // sendDocument upload limit
|
|
42
|
+
const LOCAL_MAX_BYTES = 2000 * 1024 * 1024; // --local server, both ways
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the effective per-file caps for a chat/topic.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} opts
|
|
48
|
+
* @param {boolean} opts.localApi — true when config.bot.apiRoot is set
|
|
49
|
+
* (a local Bot API server is in use → 2 GB ceiling).
|
|
50
|
+
* @param {...number} opts.override — per-chat/topic maxFileBytes (bytes).
|
|
51
|
+
* Resolved by the caller from topic → chat → undefined; clamped to the
|
|
52
|
+
* backend ceiling.
|
|
53
|
+
* @returns {{ inBytes:number, outBytes:number, ceiling:number, localApi:boolean }}
|
|
54
|
+
*/
|
|
55
|
+
function resolveFileCaps({ localApi = false, override = null } = {}) {
|
|
56
|
+
const ceiling = localApi ? LOCAL_MAX_BYTES : null;
|
|
57
|
+
const defIn = localApi ? LOCAL_MAX_BYTES : CLOUD_MAX_IN_BYTES;
|
|
58
|
+
const defOut = localApi ? LOCAL_MAX_BYTES : CLOUD_MAX_OUT_BYTES;
|
|
59
|
+
// A numeric override sets BOTH directions to the same value, clamped to
|
|
60
|
+
// the backend hard ceiling (cloud uses the per-direction default as the
|
|
61
|
+
// clamp so an override can't push past Telegram's own limit).
|
|
62
|
+
const ovr = (typeof override === 'number' && override > 0) ? override : null;
|
|
63
|
+
const inBytes = ovr ? (localApi ? Math.min(ovr, ceiling) : Math.min(ovr, CLOUD_MAX_IN_BYTES)) : defIn;
|
|
64
|
+
const outBytes = ovr ? (localApi ? Math.min(ovr, ceiling) : Math.min(ovr, CLOUD_MAX_OUT_BYTES)) : defOut;
|
|
65
|
+
return { inBytes, outBytes, ceiling: ceiling ?? CLOUD_MAX_OUT_BYTES, localApi };
|
|
66
|
+
}
|
|
27
67
|
const MIME_ALLOW = [
|
|
28
68
|
/^image\//, /^audio\//, /^video\//,
|
|
29
69
|
/^application\/pdf$/, /^text\/plain$/,
|
|
@@ -109,8 +149,12 @@ function filterAttachments(attachments, opts = {}) {
|
|
|
109
149
|
|
|
110
150
|
module.exports = {
|
|
111
151
|
filterAttachments,
|
|
152
|
+
resolveFileCaps,
|
|
112
153
|
MAX_FILE_BYTES,
|
|
113
154
|
MAX_TOTAL_BYTES,
|
|
155
|
+
CLOUD_MAX_IN_BYTES,
|
|
156
|
+
CLOUD_MAX_OUT_BYTES,
|
|
157
|
+
LOCAL_MAX_BYTES,
|
|
114
158
|
MIME_ALLOW,
|
|
115
159
|
EXTENSION_ALLOW,
|
|
116
160
|
FALLBACK_MIMES,
|
|
@@ -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 });
|
|
@@ -48,6 +48,11 @@ const { Process, UnsupportedOperationError } = require('./process');
|
|
|
48
48
|
const { ChannelsBridgeServer } = require('./channels-bridge-server');
|
|
49
49
|
const { writeHookFiles, removeHookFiles } = require('./hook-settings');
|
|
50
50
|
const { createHookTail } = require('./hook-event-tail');
|
|
51
|
+
// File-send staging: reuse the dispatcher's allowlist root so the dir we
|
|
52
|
+
// create exactly matches the realpath the validator accepts (no /tmp vs
|
|
53
|
+
// /private/tmp drift — one of the original Music-topic failures).
|
|
54
|
+
const { DEFAULT_ATTACHMENT_BASE } = require('./channels-tool-dispatcher');
|
|
55
|
+
const { resolveFileCaps } = require('../attachments');
|
|
51
56
|
const { runStartupGate } = require('../tmux/startup-gate');
|
|
52
57
|
const { POLYGRAM_DISPLAY_HINT } = require('../telegram/display-hint');
|
|
53
58
|
|
|
@@ -251,6 +256,10 @@ class CliProcess extends Process {
|
|
|
251
256
|
// pending turn(s): turn_id → { resolve, reject, replies: [], quietTimer, hardTimer, startedAt }
|
|
252
257
|
this.pendingTurns = new Map();
|
|
253
258
|
|
|
259
|
+
// File-send outbound cap (bot → user). Safe cloud default; overwritten in
|
|
260
|
+
// _spawnTmuxClaude with the backend/chat-resolved value before any turn.
|
|
261
|
+
this.maxOutboundFileBytes = resolveFileCaps({ localApi: false }).outBytes;
|
|
262
|
+
|
|
254
263
|
// P1 security (review #8): track resolved permission request_ids so a
|
|
255
264
|
// double-fire of respond() can't write a second perm_verdict for the same
|
|
256
265
|
// request. TmuxProcess gates on _pendingApprovalId; this is the channels
|
|
@@ -297,6 +306,23 @@ class CliProcess extends Process {
|
|
|
297
306
|
// permit files under the agent's workspace.
|
|
298
307
|
this.sessionCwd = opts.cwd || null;
|
|
299
308
|
|
|
309
|
+
// File-send staging dir (2026-06 file-send feature). The dispatcher
|
|
310
|
+
// allowlist always permits <DEFAULT_ATTACHMENT_BASE>/<sessionKey>/, but
|
|
311
|
+
// nothing ever CREATED it — so claude's reply(files) attempts at
|
|
312
|
+
// /tmp/polygram-attachments failed (dir absent / realpath mismatch) and
|
|
313
|
+
// it flailed across other paths. Create it here and surface it to the
|
|
314
|
+
// prompt so claude has one blessed, always-allowed place to stage a file
|
|
315
|
+
// before sending. realpathSync so the stored path matches what the
|
|
316
|
+
// validator resolves (the /tmp ↔ /private/tmp fix).
|
|
317
|
+
try {
|
|
318
|
+
const dir = path.join(DEFAULT_ATTACHMENT_BASE, String(this.sessionKey));
|
|
319
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
320
|
+
this.attachmentStagingDir = fs.realpathSync(dir);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
this.attachmentStagingDir = null;
|
|
323
|
+
this.logger.warn?.(`[${this.label}] channels: staging dir create failed: ${err.message}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
300
326
|
// Opaque random token for socket filename — do NOT leak sessionKey to /tmp.
|
|
301
327
|
const socketToken = crypto.randomBytes(16).toString('hex');
|
|
302
328
|
this.sockPath = path.join(os.tmpdir(), `polygram-${socketToken}.sock`);
|
|
@@ -472,6 +498,18 @@ class CliProcess extends Process {
|
|
|
472
498
|
const effort = topicConfig?.effort || opts.chatConfig?.effort || opts.effort;
|
|
473
499
|
const resolvedCwd = topicConfig?.cwd || opts.chatConfig?.cwd || opts.cwd;
|
|
474
500
|
|
|
501
|
+
// File-send outbound cap (bot → user). Backend-derived (cloud 50MB vs
|
|
502
|
+
// local Bot API server 2GB via opts.localApi) with per-topic/chat
|
|
503
|
+
// maxFileBytes override, clamped to the backend ceiling. Stored for the
|
|
504
|
+
// dispatcher (live size-check) and the system prompt (so claude states
|
|
505
|
+
// the right limit). Resolved here so it follows the same topic→chat
|
|
506
|
+
// precedence as cwd/agent above.
|
|
507
|
+
const _capOverride = topicConfig?.maxFileBytes ?? opts.chatConfig?.maxFileBytes ?? null;
|
|
508
|
+
this.maxOutboundFileBytes = resolveFileCaps({
|
|
509
|
+
localApi: !!opts.localApi,
|
|
510
|
+
override: _capOverride,
|
|
511
|
+
}).outBytes;
|
|
512
|
+
|
|
475
513
|
// Parity audit P8 + rc.8 fs-guard (2026-05-26 shumorobot Music topic):
|
|
476
514
|
// `--session-id <id>` creates a NEW claude session with that id;
|
|
477
515
|
// `--resume <id>` resumes the EXISTING conversation. Lazy-respawn after
|
|
@@ -598,6 +636,28 @@ class CliProcess extends Process {
|
|
|
598
636
|
'Internal tool calls (Bash, Edit, Write, Read, etc.) are fine to use',
|
|
599
637
|
'as normal — only the FINAL user-visible message needs to go through',
|
|
600
638
|
'the reply tool.',
|
|
639
|
+
'',
|
|
640
|
+
'### Sending FILES (tracks, images, docs) to the user',
|
|
641
|
+
'',
|
|
642
|
+
'The `mcp__polygram-bridge__reply` tool takes an optional `files` array of',
|
|
643
|
+
'absolute paths. This is the ONLY way to send a file. Do NOT use Bash,',
|
|
644
|
+
'curl, the Telegram Bot API, or polygram-ipc to send files — those fail.',
|
|
645
|
+
'',
|
|
646
|
+
...(this.attachmentStagingDir ? [
|
|
647
|
+
`To send a file: COPY it into the staging dir \`${this.attachmentStagingDir}\`,`,
|
|
648
|
+
'then call reply with its absolute path, e.g.:',
|
|
649
|
+
` reply(chat_id="<id>", text="Here's the track", files=["${this.attachmentStagingDir}/track.flac"])`,
|
|
650
|
+
'polygram auto-deletes staged files after the turn — you do not need to clean up.',
|
|
651
|
+
'You may also send directly from the agent workspace (cwd); other paths are rejected.',
|
|
652
|
+
] : [
|
|
653
|
+
'Copy the file somewhere under your workspace (cwd) and pass its absolute',
|
|
654
|
+
'path in `files`. Paths outside the workspace are rejected for safety.',
|
|
655
|
+
]),
|
|
656
|
+
'',
|
|
657
|
+
`Max file size for sending: ${Math.round(this.maxOutboundFileBytes / (1024 * 1024))} MB. ` +
|
|
658
|
+
'For larger lossless audio, convert to FLAC/MP3 under the limit first, ' +
|
|
659
|
+
'or tell the user it exceeds the limit. Images go as photos; everything ' +
|
|
660
|
+
'else as documents.',
|
|
601
661
|
].join('\n'));
|
|
602
662
|
|
|
603
663
|
// Parity audit P6: honor isolateUserConfig — mirrors tmux pattern at
|
|
@@ -942,6 +1002,7 @@ class CliProcess extends Process {
|
|
|
942
1002
|
text: args.text,
|
|
943
1003
|
files: args.files,
|
|
944
1004
|
sessionCwd: this.sessionCwd, // P0 #2: dispatcher uses this to allowlist file roots
|
|
1005
|
+
maxOutboundFileBytes: this.maxOutboundFileBytes, // backend/chat-derived upload cap
|
|
945
1006
|
});
|
|
946
1007
|
} catch (err) {
|
|
947
1008
|
this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: false, error: err.message });
|
|
@@ -1203,6 +1264,27 @@ class CliProcess extends Process {
|
|
|
1203
1264
|
pending.resolve(result);
|
|
1204
1265
|
this.emit('result', { subtype: 'success' }, { streamText: text });
|
|
1205
1266
|
this.emit('idle');
|
|
1267
|
+
// File-send staging auto-purge (your choice — no "claude must delete").
|
|
1268
|
+
// Once the LAST turn settles, wipe the staging dir's contents so files
|
|
1269
|
+
// claude copied in to send don't accumulate on disk across turns. Only
|
|
1270
|
+
// when fully idle, so a file staged for a still-pending concurrent turn
|
|
1271
|
+
// isn't yanked mid-send.
|
|
1272
|
+
if (this.pendingTurns.size === 0) this._purgeStagingDir();
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Empty the per-session file-send staging dir (keep the dir itself).
|
|
1277
|
+
* Best-effort; never throws. Called when the session goes idle and on kill.
|
|
1278
|
+
*/
|
|
1279
|
+
_purgeStagingDir() {
|
|
1280
|
+
if (!this.attachmentStagingDir) return;
|
|
1281
|
+
let entries;
|
|
1282
|
+
try { entries = fs.readdirSync(this.attachmentStagingDir); }
|
|
1283
|
+
catch { return; }
|
|
1284
|
+
for (const name of entries) {
|
|
1285
|
+
try { fs.rmSync(path.join(this.attachmentStagingDir, name), { recursive: true, force: true }); }
|
|
1286
|
+
catch { /* best-effort */ }
|
|
1287
|
+
}
|
|
1206
1288
|
}
|
|
1207
1289
|
|
|
1208
1290
|
// ─── public Process API ──────────────────────────────────────────
|
|
@@ -1861,6 +1943,12 @@ class CliProcess extends Process {
|
|
|
1861
1943
|
if (this.botName && this.claudeSessionId) {
|
|
1862
1944
|
try { removeHookFiles({ botName: this.botName, sessionId: this.claudeSessionId }); } catch {}
|
|
1863
1945
|
}
|
|
1946
|
+
// File-send staging: remove the whole per-session dir on kill (purge only
|
|
1947
|
+
// empties it between turns; kill is end-of-life so drop it entirely).
|
|
1948
|
+
if (this.attachmentStagingDir) {
|
|
1949
|
+
try { fs.rmSync(this.attachmentStagingDir, { recursive: true, force: true }); } catch {}
|
|
1950
|
+
this.attachmentStagingDir = null;
|
|
1951
|
+
}
|
|
1864
1952
|
|
|
1865
1953
|
this.emit('close', 0);
|
|
1866
1954
|
}
|
package/lib/telegram/api.js
CHANGED
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
getRetryAfterMs,
|
|
29
29
|
} = require('./format');
|
|
30
30
|
const { isSafeToRetry, redactBotToken } = require('../error/net');
|
|
31
|
+
const { coerceFileParams } = require('./input-file');
|
|
31
32
|
|
|
32
33
|
// Topic deletion race: a user can delete a forum topic while a turn is in
|
|
33
34
|
// flight, turning a valid `message_thread_id` into a 404. Telegram's error
|
|
@@ -112,6 +113,14 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
|
|
|
112
113
|
const chatId = params.chat_id != null ? String(params.chat_id) : null;
|
|
113
114
|
const threadId = params.message_thread_id != null ? String(params.message_thread_id) : null;
|
|
114
115
|
|
|
116
|
+
// File-upload bug fix (2026-05-31): coerce a `{ source: '/abs/path' }`
|
|
117
|
+
// file param into a grammy InputFile so local-file uploads actually work.
|
|
118
|
+
// grammy doesn't recognize the bare envelope → it failed every send with
|
|
119
|
+
// "Wrong port number". Single choke point: fixes channels reply(files)
|
|
120
|
+
// AND the IPC send path at once. No-op for non-file methods / file_id /
|
|
121
|
+
// URL strings / existing InputFile instances.
|
|
122
|
+
coerceFileParams(method, params);
|
|
123
|
+
|
|
115
124
|
// 0.7.4: empty-text short-circuit. Pre-fix, an empty params.text on
|
|
116
125
|
// sendMessage/editMessageText reached Telegram and 400'd with
|
|
117
126
|
// "message text is empty"; the row was marked failed and propagated
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* input-file — coerce file-upload params into grammy InputFile instances.
|
|
3
|
+
*
|
|
4
|
+
* The bug (2026-05-31, shumorobot Music): callers passed a Telegraf-style
|
|
5
|
+
* `{ source: '/abs/path' }` envelope as the file param (document/photo/…).
|
|
6
|
+
* grammy 1.x does NOT recognize that shape — it's not an InputFile, so
|
|
7
|
+
* grammy serializes it as a plain object and Telegram tries to read it as
|
|
8
|
+
* a URL/file_id, failing with "invalid file HTTP URL: Wrong port number".
|
|
9
|
+
* Result: file-send NEVER worked (channels reply(files) AND the IPC path
|
|
10
|
+
* both produced this exact error). The existing dispatcher test used a fake
|
|
11
|
+
* `send` and only asserted the METHOD, so it couldn't catch the bad shape.
|
|
12
|
+
*
|
|
13
|
+
* grammy uploads a local file only when the param is `new InputFile(path)`.
|
|
14
|
+
* This helper normalizes, at the single send choke point (tg()), the
|
|
15
|
+
* `{ source: <abs path> }` envelope → `new InputFile(path)`, leaving every
|
|
16
|
+
* other shape untouched:
|
|
17
|
+
* - string file_id / https URL → pass through (Telegram resolves)
|
|
18
|
+
* - existing InputFile instance → pass through (already correct)
|
|
19
|
+
* - Buffer / stream → pass through (grammy handles)
|
|
20
|
+
*
|
|
21
|
+
* Only the explicit `{ source: string }` envelope is transformed — bare
|
|
22
|
+
* path strings are intentionally NOT coerced (a Telegram file_id is also a
|
|
23
|
+
* bare string; coercing would break sends-by-id).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const { InputFile } = require('grammy');
|
|
29
|
+
|
|
30
|
+
// method → the params field that carries the file.
|
|
31
|
+
const FILE_FIELD_BY_METHOD = {
|
|
32
|
+
sendPhoto: 'photo',
|
|
33
|
+
sendDocument: 'document',
|
|
34
|
+
sendAudio: 'audio',
|
|
35
|
+
sendVideo: 'video',
|
|
36
|
+
sendAnimation: 'animation',
|
|
37
|
+
sendVoice: 'voice',
|
|
38
|
+
sendVideoNote: 'video_note',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Return a grammy-uploadable value for a single file param, or the original
|
|
43
|
+
* value unchanged if it's not the `{ source }` envelope we coerce.
|
|
44
|
+
*/
|
|
45
|
+
function coerceFileValue(val) {
|
|
46
|
+
if (val && typeof val === 'object' && !(val instanceof InputFile)
|
|
47
|
+
&& typeof val.source === 'string' && val.source.length > 0) {
|
|
48
|
+
// { source: '/abs/path' } | { source: 'https://…', filename } → InputFile
|
|
49
|
+
return new InputFile(val.source, val.filename);
|
|
50
|
+
}
|
|
51
|
+
return val;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Mutate `params` in place so its file field (if any) is grammy-uploadable.
|
|
56
|
+
* No-op for non-file methods and for params with no file field set.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} method
|
|
59
|
+
* @param {object} params
|
|
60
|
+
* @returns {object} the same params object (for chaining)
|
|
61
|
+
*/
|
|
62
|
+
function coerceFileParams(method, params) {
|
|
63
|
+
if (!params || typeof params !== 'object') return params;
|
|
64
|
+
const field = FILE_FIELD_BY_METHOD[method];
|
|
65
|
+
if (!field) return params;
|
|
66
|
+
if (params[field] != null) {
|
|
67
|
+
params[field] = coerceFileValue(params[field]);
|
|
68
|
+
}
|
|
69
|
+
return params;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
coerceFileParams,
|
|
74
|
+
coerceFileValue,
|
|
75
|
+
FILE_FIELD_BY_METHOD,
|
|
76
|
+
};
|
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.8",
|
|
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
|
@@ -28,7 +28,7 @@ const {
|
|
|
28
28
|
migrateJsonToDb, getClaudeSessionId, resolveSessionForSpawn,
|
|
29
29
|
} = require('./lib/db/sessions');
|
|
30
30
|
const { buildPrompt } = require('./lib/prompt');
|
|
31
|
-
const { filterAttachments } = require('./lib/attachments');
|
|
31
|
+
const { filterAttachments, resolveFileCaps, MAX_TOTAL_BYTES } = require('./lib/attachments');
|
|
32
32
|
// 0.9.0: SDK ProcessManager is the only pm. CLI pm
|
|
33
33
|
// (lib/process-manager.js) deleted in commit 6.
|
|
34
34
|
// Both implementations expose the same public API (constructor +
|
|
@@ -461,6 +461,10 @@ function buildSpawnContext(sessionKey) {
|
|
|
461
461
|
threadId: threadId || null,
|
|
462
462
|
label: getSessionLabel(chatConfig, threadId),
|
|
463
463
|
existingSessionId,
|
|
464
|
+
// File-send outbound cap inputs: localApi (bot-level) so CliProcess can
|
|
465
|
+
// resolve the per-chat/topic outbound cap (resolveFileCaps) the same way
|
|
466
|
+
// it resolves cwd/agent. Override itself lives in chatConfig/topic.
|
|
467
|
+
localApi: !!config.bot?.apiRoot,
|
|
464
468
|
};
|
|
465
469
|
}
|
|
466
470
|
|
|
@@ -754,7 +758,19 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
754
758
|
const sessionCtx = !pm.has(sessionKey) ? await readSessionContext(sessionKey, chatConfig.cwd) : '';
|
|
755
759
|
|
|
756
760
|
const rawAtts = extractAttachments(msg);
|
|
757
|
-
|
|
761
|
+
// Backend-derived inbound cap with per-topic/chat override. Cloud → 20MB;
|
|
762
|
+
// a local Bot API server (config.bot.apiRoot) → 2GB; override via
|
|
763
|
+
// chats[id].maxFileBytes or topics[t].maxFileBytes, clamped to the
|
|
764
|
+
// backend ceiling. Bytes-valued config; resolveFileCaps does the clamp.
|
|
765
|
+
const _inTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
|
|
766
|
+
const _fileCaps = resolveFileCaps({
|
|
767
|
+
localApi: !!config.bot?.apiRoot,
|
|
768
|
+
override: _inTopicCfg.maxFileBytes ?? chatConfig.maxFileBytes ?? null,
|
|
769
|
+
});
|
|
770
|
+
const { accepted, rejected } = filterAttachments(rawAtts, {
|
|
771
|
+
maxFileBytes: _fileCaps.inBytes,
|
|
772
|
+
maxTotalBytes: Math.max(_fileCaps.inBytes, MAX_TOTAL_BYTES),
|
|
773
|
+
});
|
|
758
774
|
for (const { att, reason } of rejected) {
|
|
759
775
|
console.log(`[${label}] attachment skipped: ${att.name} (${reason})`);
|
|
760
776
|
logEvent('attachment-skipped', { chat_id: chatId, msg_id: msg.message_id, name: att.name, reason });
|
|
@@ -1672,9 +1688,23 @@ function shouldHandle(msg, chatConfig, botUsername) {
|
|
|
1672
1688
|
}
|
|
1673
1689
|
|
|
1674
1690
|
function createBot(token) {
|
|
1691
|
+
// Optional self-hosted Telegram Bot API server. When config.bot.apiRoot is
|
|
1692
|
+
// set (e.g. "http://localhost:8081" from a local `telegram-bot-api`
|
|
1693
|
+
// process), grammy routes all Bot API calls there instead of
|
|
1694
|
+
// api.telegram.org — which lifts file send/receive from cloud's 50 MB-out /
|
|
1695
|
+
// 20 MB-in to 2 GB both ways. Omit it (default) → cloud Telegram, unchanged.
|
|
1696
|
+
// The local server is a separate companion daemon; this is just the knob
|
|
1697
|
+
// that points polygram at it. See docs/0.12.0-file-send.md.
|
|
1698
|
+
const apiRoot = config.bot?.apiRoot;
|
|
1675
1699
|
const bot = new Bot(token, {
|
|
1676
|
-
client: {
|
|
1700
|
+
client: {
|
|
1701
|
+
timeoutSeconds: 60,
|
|
1702
|
+
...(apiRoot ? { apiRoot } : {}),
|
|
1703
|
+
},
|
|
1677
1704
|
});
|
|
1705
|
+
if (apiRoot) {
|
|
1706
|
+
console.log(`[polygram] using local Telegram Bot API server: ${apiRoot} (2GB file limit)`);
|
|
1707
|
+
}
|
|
1678
1708
|
let botUsername = '';
|
|
1679
1709
|
// Cached once @botUsername is known — was recompiling per inbound msg.
|
|
1680
1710
|
let mentionRe = null;
|