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.
@@ -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": {
@@ -22,8 +22,48 @@
22
22
  * extension — the fallback only kicks in when MIME is unhelpful.
23
23
  */
24
24
 
25
- const MAX_FILE_BYTES = 10 * 1024 * 1024;
26
- const MAX_TOTAL_BYTES = 20 * 1024 * 1024;
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
- 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 });
@@ -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
  }
@@ -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.6",
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
- const { accepted, rejected } = filterAttachments(rawAtts);
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: { timeoutSeconds: 60 },
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;