polygram 0.12.0-rc.7 → 0.12.0-rc.9

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,7 +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
+ "_comment_apiRoot": "Optional. Point grammy at a self-hosted Telegram Bot API server (e.g. 'http://localhost:8082' 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.",
8
8
  "allowConfigCommands": true,
9
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.",
10
10
  "adminChatId": "123456789",
@@ -71,7 +71,8 @@
71
71
  "model": "opus",
72
72
  "effort": "medium",
73
73
  "cwd": "/Users/you/admin-agent",
74
- "timeout": 600
74
+ "timeout": 600,
75
+ "_comment_maxFileBytes": "OPTIONAL per-chat (or per-topic; topic wins) file-size cap in BYTES. There is NO fixed default — the default is backend-derived: cloud Telegram = 50MB send / 20MB receive; with a local Bot API server (bot.apiRoot set) = 2GB both ways. This key only LOWERS that ceiling for this chat (Telegram rejects anything above the backend limit regardless); omit it to use the full backend default. To set one, add e.g. \"maxFileBytes\": 104857600 (=100MB) — only meaningful when apiRoot is set, since cloud already clamps to 50/20MB."
75
76
  },
76
77
 
77
78
  "-1000000000001": {
@@ -26,9 +26,44 @@
26
26
  // bot file DOWNLOADS (getFile) at 20 MB, so 20 MB is the real ceiling on
27
27
  // cloud — raised from 10 MB so users can send larger tracks/docs. With a
28
28
  // self-hosted Bot API server (config.bot.apiRoot) the Telegram limit rises
29
- // to 2 GB; override per-bot via config.bot.maxInboundFileBytes if so.
29
+ // to 2 GB; resolveFileCaps() raises the default accordingly.
30
30
  const MAX_FILE_BYTES = 20 * 1024 * 1024;
31
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
+ }
32
67
  const MIME_ALLOW = [
33
68
  /^image\//, /^audio\//, /^video\//,
34
69
  /^application\/pdf$/, /^text\/plain$/,
@@ -114,8 +149,12 @@ function filterAttachments(attachments, opts = {}) {
114
149
 
115
150
  module.exports = {
116
151
  filterAttachments,
152
+ resolveFileCaps,
117
153
  MAX_FILE_BYTES,
118
154
  MAX_TOTAL_BYTES,
155
+ CLOUD_MAX_IN_BYTES,
156
+ CLOUD_MAX_OUT_BYTES,
157
+ LOCAL_MAX_BYTES,
119
158
  MIME_ALLOW,
120
159
  EXTENSION_ALLOW,
121
160
  FALLBACK_MIMES,
@@ -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);
@@ -52,6 +52,7 @@ const { createHookTail } = require('./hook-event-tail');
52
52
  // create exactly matches the realpath the validator accepts (no /tmp vs
53
53
  // /private/tmp drift — one of the original Music-topic failures).
54
54
  const { DEFAULT_ATTACHMENT_BASE } = require('./channels-tool-dispatcher');
55
+ const { resolveFileCaps } = require('../attachments');
55
56
  const { runStartupGate } = require('../tmux/startup-gate');
56
57
  const { POLYGRAM_DISPLAY_HINT } = require('../telegram/display-hint');
57
58
 
@@ -255,6 +256,10 @@ class CliProcess extends Process {
255
256
  // pending turn(s): turn_id → { resolve, reject, replies: [], quietTimer, hardTimer, startedAt }
256
257
  this.pendingTurns = new Map();
257
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
+
258
263
  // P1 security (review #8): track resolved permission request_ids so a
259
264
  // double-fire of respond() can't write a second perm_verdict for the same
260
265
  // request. TmuxProcess gates on _pendingApprovalId; this is the channels
@@ -493,6 +498,18 @@ class CliProcess extends Process {
493
498
  const effort = topicConfig?.effort || opts.chatConfig?.effort || opts.effort;
494
499
  const resolvedCwd = topicConfig?.cwd || opts.chatConfig?.cwd || opts.cwd;
495
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
+
496
513
  // Parity audit P8 + rc.8 fs-guard (2026-05-26 shumorobot Music topic):
497
514
  // `--session-id <id>` creates a NEW claude session with that id;
498
515
  // `--resume <id>` resumes the EXISTING conversation. Lazy-respawn after
@@ -637,9 +654,10 @@ class CliProcess extends Process {
637
654
  'path in `files`. Paths outside the workspace are rejected for safety.',
638
655
  ]),
639
656
  '',
640
- 'Telegram caps bot file uploads at 50 MB (cloud). For larger lossless',
641
- 'audio, convert to FLAC/MP3 under 50 MB first, or tell the user it exceeds',
642
- 'the limit. Images go as photos; everything else as documents.',
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.',
643
661
  ].join('\n'));
644
662
 
645
663
  // Parity audit P6: honor isolateUserConfig — mirrors tmux pattern at
@@ -984,6 +1002,7 @@ class CliProcess extends Process {
984
1002
  text: args.text,
985
1003
  files: args.files,
986
1004
  sessionCwd: this.sessionCwd, // P0 #2: dispatcher uses this to allowlist file roots
1005
+ maxOutboundFileBytes: this.maxOutboundFileBytes, // backend/chat-derived upload cap
987
1006
  });
988
1007
  } catch (err) {
989
1008
  this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: false, error: err.message });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0-rc.7",
3
+ "version": "0.12.0-rc.9",
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 });