polygram 0.12.9 → 0.13.0

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.
@@ -22,6 +22,8 @@
22
22
  * extension — the fallback only kicks in when MIME is unhelpful.
23
23
  */
24
24
 
25
+ const { getTopicConfig } = require('./session-key');
26
+
25
27
  // Inbound (user → bot) per-file cap. Telegram's cloud Bot API hard-caps
26
28
  // bot file DOWNLOADS (getFile) at 20 MB, so 20 MB is the real ceiling on
27
29
  // cloud — raised from 10 MB so users can send larger tracks/docs. With a
@@ -52,6 +54,43 @@ const LOCAL_MAX_BYTES = 2000 * 1024 * 1024; // --local server, both w
52
54
  * backend ceiling.
53
55
  * @returns {{ inBytes:number, outBytes:number, ceiling:number, localApi:boolean }}
54
56
  */
57
+ /**
58
+ * Resolve the per-file maxFileBytes override for a (chat, topic) from config,
59
+ * with precedence: topic → chat → bot → default → null. The returned value is
60
+ * fed to resolveFileCaps(), which clamps it to the backend ceiling. Returns
61
+ * null when no tier sets it (→ backend default).
62
+ *
63
+ * Single source of truth for every enforcement site (inbound filter, inbound
64
+ * download, outbound send() choke point, CLI pre-check) so precedence can't
65
+ * drift between them.
66
+ *
67
+ * `config.bot` is the active bot after filterConfigToBot (config.bots[name]);
68
+ * `config.defaults.maxFileBytes` is the global default. A non-positive or
69
+ * non-numeric value at any tier is treated as "no override" by resolveFileCaps,
70
+ * so it transparently falls through to the next tier.
71
+ *
72
+ * @param {object} config bot-filtered config (has .chats, .bot, .defaults)
73
+ * @param {string|number} chatId
74
+ * @param {string|number|null} threadId
75
+ * @returns {number|null} bytes, or null for "use backend default"
76
+ */
77
+ function resolveMaxFileOverride(config, chatId, threadId = null) {
78
+ if (!config) return null;
79
+ const chat = config.chats?.[String(chatId)] || null;
80
+ const topicCfg = (chat && threadId != null)
81
+ ? getTopicConfig(chat, String(threadId))
82
+ : null;
83
+ // A non-positive / non-numeric value at a tier means "unset" → fall through
84
+ // to the next tier (NOT 0-bytes "block all", and NOT short-circuiting to the
85
+ // backend default the way `??` on a literal 0 would).
86
+ const pick = (v) => (typeof v === 'number' && v > 0) ? v : undefined;
87
+ return pick(topicCfg?.maxFileBytes)
88
+ ?? pick(chat?.maxFileBytes)
89
+ ?? pick(config.bot?.maxFileBytes)
90
+ ?? pick(config.defaults?.maxFileBytes)
91
+ ?? null;
92
+ }
93
+
55
94
  function resolveFileCaps({ localApi = false, override = null } = {}) {
56
95
  const ceiling = localApi ? LOCAL_MAX_BYTES : null;
57
96
  const defIn = localApi ? LOCAL_MAX_BYTES : CLOUD_MAX_IN_BYTES;
@@ -150,6 +189,7 @@ function filterAttachments(attachments, opts = {}) {
150
189
  module.exports = {
151
190
  filterAttachments,
152
191
  resolveFileCaps,
192
+ resolveMaxFileOverride,
153
193
  MAX_FILE_BYTES,
154
194
  MAX_TOTAL_BYTES,
155
195
  CLOUD_MAX_IN_BYTES,
@@ -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, resolveFileCaps } = require('../attachments');
27
+ const { MAX_FILE_BYTES, resolveFileCaps, resolveMaxFileOverride } = require('../attachments');
28
28
 
29
29
  const ATTACHMENT_DOWNLOAD_CONCURRENCY_DEFAULT = 6;
30
30
 
@@ -60,11 +60,16 @@ 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;
63
+ // Inbound per-file cap: BACKEND-derived (20 MB cloud getFile ceiling,
64
+ // 2 GB local Bot API server) and lowered by the override (topic chat →
65
+ // bot default) via the SAME resolver as the inbound filter. Without the
66
+ // override here, a file whose Telegram-reported file_size is 0/absent
67
+ // slips past filterAttachments' per-file reject and would stream up to
68
+ // the full 2 GB ceiling before rejection (disk-DoS gap).
69
+ const cap = resolveFileCaps({
70
+ localApi: !!config.bot?.apiRoot,
71
+ override: resolveMaxFileOverride(config, chatId, msg?.message_thread_id?.toString() || null),
72
+ }).inBytes;
68
73
 
69
74
  const fileInfo = await bot.api.getFile(att.file_id);
70
75
  if (!fileInfo?.file_path) throw new Error('no file_path from getFile');
@@ -608,12 +608,14 @@ class CliProcess extends Process {
608
608
  this.effort = effort;
609
609
 
610
610
  // File-send outbound cap (bot → user). Backend-derived (cloud 50MB vs
611
- // local Bot API server 2GB via opts.localApi) with per-topic/chat
612
- // maxFileBytes override, clamped to the backend ceiling. Stored for the
613
- // dispatcher (live size-check) and the system prompt (so claude states
614
- // the right limit). Resolved here so it follows the same topic→chat
615
- // precedence as cwd/agent above.
616
- const _capOverride = topicConfig?.maxFileBytes ?? opts.chatConfig?.maxFileBytes ?? null;
611
+ // local Bot API server 2GB via opts.localApi) with the per-file override
612
+ // (topic → chat → bot → default), clamped to the backend ceiling. Stored
613
+ // for the dispatcher (live size-check) and the system prompt (so claude
614
+ // states the right limit). opts.outboundCapOverride is pre-resolved by
615
+ // buildSpawnContext via the shared resolver so this matches actual send()
616
+ // enforcement; the inline fallback keeps legacy/test callers working.
617
+ const _capOverride = opts.outboundCapOverride
618
+ ?? topicConfig?.maxFileBytes ?? opts.chatConfig?.maxFileBytes ?? null;
617
619
  this.maxOutboundFileBytes = resolveFileCaps({
618
620
  localApi: !!opts.localApi,
619
621
  override: _capOverride,
@@ -807,8 +809,12 @@ class CliProcess extends Process {
807
809
  '### Sending FILES (tracks, images, docs) to the user',
808
810
  '',
809
811
  'The `mcp__polygram-bridge__reply` tool takes an optional `files` array of',
810
- 'absolute paths. This is the ONLY way to send a file. Do NOT use Bash,',
811
- 'curl, the Telegram Bot API, or polygram-ipc to send files — those fail.',
812
+ 'absolute paths. This is the ONLY correct way to send a file: reply delivers',
813
+ "it to the user's CURRENT topic/thread automatically. Do NOT use Bash, curl,",
814
+ 'the Telegram Bot API, or polygram-ipc to send files: they do NOT know your',
815
+ 'current thread, so they deliver to the WRONG topic (and skip size/safety',
816
+ 'checks). A raw Bot API call may LOOK like it worked — the upload returns 200 —',
817
+ "but it lands in the wrong topic the user isn't looking at. Always use reply(files).",
812
818
  '',
813
819
  ...(this.attachmentStagingDir ? [
814
820
  `To send a file: COPY it into the staging dir \`${this.attachmentStagingDir}\`,`,
@@ -28,7 +28,8 @@ const {
28
28
  getRetryAfterMs,
29
29
  } = require('./format');
30
30
  const { isSafeToRetry, redactBotToken } = require('../error/net');
31
- const { coerceFileParams } = require('./input-file');
31
+ const { coerceFileParams, localFileBytes, FILE_FIELD_BY_METHOD } = require('./input-file');
32
+ const { resolveFileCaps, resolveMaxFileOverride } = require('../attachments');
32
33
 
33
34
  // Topic deletion race: a user can delete a forum topic while a turn is in
34
35
  // flight, turning a valid `message_thread_id` into a 404. Telegram's error
@@ -109,10 +110,35 @@ function deriveOutboundText(method, params, meta) {
109
110
  return '';
110
111
  }
111
112
 
112
- async function send({ bot, method, params, db = null, meta = {}, logger = console }) {
113
+ async function send({ bot, method, params, db = null, meta = {}, logger = console, config = null }) {
113
114
  const chatId = params.chat_id != null ? String(params.chat_id) : null;
114
115
  const threadId = params.message_thread_id != null ? String(params.message_thread_id) : null;
115
116
 
117
+ // Outbound per-file size cap (topic → chat → bot → default → backend ceiling).
118
+ // Enforced HERE, the single send choke point, so every path is capped
119
+ // uniformly: CLI reply(files), IPC/cron job sends, and any media polygram
120
+ // itself emits. Runs BEFORE coerceFileParams while the param is still a raw
121
+ // statable path/Buffer (coerceFileParams turns {source} into an opaque
122
+ // InputFile). Only locally-statable files are checked — file_id / URL sends
123
+ // can't be sized and pass through (Telegram's own limit applies). Throwing
124
+ // here is before insertOutboundPending, so no orphan DB row is left.
125
+ if (config) {
126
+ const fileField = FILE_FIELD_BY_METHOD[method];
127
+ if (fileField && params[fileField] != null) {
128
+ const bytes = localFileBytes(params[fileField]);
129
+ if (bytes != null) {
130
+ const cap = resolveFileCaps({
131
+ localApi: !!config.bot?.apiRoot,
132
+ override: resolveMaxFileOverride(config, chatId, threadId),
133
+ }).outBytes;
134
+ if (bytes > cap) {
135
+ const mb = (n) => (n / (1024 * 1024)).toFixed(1);
136
+ throw new Error(`telegram ${method}: file ${mb(bytes)}MB exceeds the ${mb(cap)}MB send limit`);
137
+ }
138
+ }
139
+ }
140
+ }
141
+
116
142
  // File-upload bug fix (2026-05-31): coerce a `{ source: '/abs/path' }`
117
143
  // file param into a grammy InputFile so local-file uploads actually work.
118
144
  // grammy doesn't recognize the bare envelope → it failed every send with
@@ -365,8 +391,8 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
365
391
  return res;
366
392
  }
367
393
 
368
- function createSender(db, logger = console) {
369
- return (bot, method, params, meta) => send({ bot, method, params, db, meta, logger });
394
+ function createSender(db, logger = console, config = null) {
395
+ return (bot, method, params, meta) => send({ bot, method, params, db, meta, logger, config });
370
396
  }
371
397
 
372
398
  module.exports = { send, createSender, nextPendingId };
@@ -25,6 +25,7 @@
25
25
 
26
26
  'use strict';
27
27
 
28
+ const fs = require('fs');
28
29
  const { InputFile } = require('grammy');
29
30
 
30
31
  // method → the params field that carries the file.
@@ -69,8 +70,36 @@ function coerceFileParams(method, params) {
69
70
  return params;
70
71
  }
71
72
 
73
+ /**
74
+ * Bytes for a LOCALLY statable file param, else null (= "not sizable here,
75
+ * skip the cap"). Used by the outbound size cap at the send() choke point.
76
+ *
77
+ * Only two shapes are local files we can measure:
78
+ * - { source: '<path>' } → fs.statSync (abs OR relative — resolved against
79
+ * cwd exactly as grammy's InputFile does, so the
80
+ * size and the upload read the same file). Excludes
81
+ * https/http URLs (remote, not sizable here).
82
+ * - Buffer → .length
83
+ * Everything else returns null: a bare string is a Telegram file_id (never
84
+ * stat it — coerceFileValue's deliberate rule), an existing InputFile/stream
85
+ * can't be sized, and a missing/unreadable path must not crash the send
86
+ * (statSync wrapped → null, let grammy/Telegram error naturally).
87
+ *
88
+ * @param {*} val the raw file param BEFORE coerceFileParams runs
89
+ * @returns {number|null}
90
+ */
91
+ function localFileBytes(val) {
92
+ if (Buffer.isBuffer(val)) return val.length;
93
+ if (val && typeof val === 'object' && typeof val.source === 'string'
94
+ && !/^https?:\/\//i.test(val.source)) {
95
+ try { return fs.statSync(val.source).size; } catch { return null; }
96
+ }
97
+ return null;
98
+ }
99
+
72
100
  module.exports = {
73
101
  coerceFileParams,
74
102
  coerceFileValue,
103
+ localFileBytes,
75
104
  FILE_FIELD_BY_METHOD,
76
105
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.9",
3
+ "version": "0.13.0",
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, resolveFileCaps, MAX_TOTAL_BYTES } = require('./lib/attachments');
31
+ const { filterAttachments, resolveFileCaps, resolveMaxFileOverride, 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 +
@@ -475,10 +475,12 @@ function buildSpawnContext(sessionKey) {
475
475
  threadId: threadId || null,
476
476
  label: getSessionLabel(chatConfig, threadId),
477
477
  existingSessionId,
478
- // File-send outbound cap inputs: localApi (bot-level) so CliProcess can
479
- // resolve the per-chat/topic outbound cap (resolveFileCaps) the same way
480
- // it resolves cwd/agent. Override itself lives in chatConfig/topic.
478
+ // File-send outbound cap inputs: localApi (backend ceiling) + the resolved
479
+ // per-file override (topic chat → bot → default) from the SAME resolver
480
+ // the inbound filter and the send() choke point use, so CliProcess's
481
+ // pre-check + system-prompt line can't drift from actual enforcement.
481
482
  localApi: !!config.bot?.apiRoot,
483
+ outboundCapOverride: resolveMaxFileOverride(config, chatId, threadId || null),
482
484
  };
483
485
  }
484
486
 
@@ -788,14 +790,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
788
790
  const sessionCtx = !pm.has(sessionKey) ? await readSessionContext(sessionKey, chatConfig.cwd) : '';
789
791
 
790
792
  const rawAtts = extractAttachments(msg);
791
- // Backend-derived inbound cap with per-topic/chat override. Cloud20MB;
792
- // a local Bot API server (config.bot.apiRoot) → 2GB; override via
793
- // chats[id].maxFileBytes or topics[t].maxFileBytes, clamped to the
794
- // backend ceiling. Bytes-valued config; resolveFileCaps does the clamp.
795
- const _inTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
793
+ // Backend-derived inbound cap with override (topicchat botdefault),
794
+ // clamped to the backend ceiling (cloud 20MB / local Bot API server 2GB).
795
+ // resolveMaxFileOverride is the single precedence source shared with the
796
+ // outbound send() cap and the download path; resolveFileCaps does the clamp.
796
797
  const _fileCaps = resolveFileCaps({
797
798
  localApi: !!config.bot?.apiRoot,
798
- override: _inTopicCfg.maxFileBytes ?? chatConfig.maxFileBytes ?? null,
799
+ override: resolveMaxFileOverride(config, chatId, threadIdStr || null),
799
800
  });
800
801
  const { accepted, rejected } = filterAttachments(rawAtts, {
801
802
  maxFileBytes: _fileCaps.inBytes,
@@ -2174,7 +2175,7 @@ async function main() {
2174
2175
  try {
2175
2176
  db = dbClient.open(DB_PATH);
2176
2177
  console.log(`[db] opened ${DB_PATH}`);
2177
- tg = createSender(db, console);
2178
+ tg = createSender(db, console, config);
2178
2179
  pairings = createPairingsStore(db.raw);
2179
2180
  approvals = createApprovalsStore(db.raw);
2180
2181
  const migration = migrateJsonToDb(db, SESSIONS_JSON_PATH, config.chats);