polygram 0.12.0-rc.1 → 0.12.0-rc.11

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: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.",
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,8 @@
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 (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."
74
76
  },
75
77
 
76
78
  "-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,
@@ -42,13 +42,37 @@ function createHandleAbort({
42
42
  const threadId = msg.message_thread_id?.toString();
43
43
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
44
44
  const proc = pm.has(sessionKey) ? pm.get(sessionKey) : null;
45
- const hadActive = !!proc?.inFlight;
45
+ let hadActive = !!proc?.inFlight;
46
46
 
47
47
  // Mark BEFORE killing: the 'close' event fires almost immediately
48
48
  // after interrupt, and the surrounding handleMessage's catch
49
49
  // needs to see the flag to skip the generic error-reply.
50
50
  if (hadActive) markSessionAborted(sessionKey);
51
51
 
52
+ // "Stop" incident (shumorobot Music, 2026-05-31 13:08): on the
53
+ // CliProcess/channels backend a turn resolves on the quiet-window
54
+ // after claude's last reply tool call (inFlight → false), but claude
55
+ // can still be working (subagent, long Bash). Keying the ack on
56
+ // inFlight alone made "Stop" say "Nothing to stop" while a subagent
57
+ // download churned. probeBusyState() reads the TUI "esc to interrupt"
58
+ // hint — the truthful signal — so detection, the abort mark, and the
59
+ // ack all agree. The probe result is logged below (forensics) so the
60
+ // heuristic can be refined against real states later. Channels analog
61
+ // of the (deleted) tmux hasBackgroundShell branch; typeof-guarded so
62
+ // it's a no-op on backends without it.
63
+ let busyProbe = null;
64
+ if (!hadActive && proc && typeof proc.probeBusyState === 'function') {
65
+ try {
66
+ busyProbe = await proc.probeBusyState();
67
+ if (busyProbe?.busy) {
68
+ hadActive = true;
69
+ markSessionAborted(sessionKey);
70
+ }
71
+ } catch (err) {
72
+ logger.error?.(`[${botName}] busy-probe failed: ${err.message}`);
73
+ }
74
+ }
75
+
52
76
  // Bug 1 (incident 2026-05-18): "Stop" was turn-scoped — it only
53
77
  // looked at an in-flight TURN. But the agent can leave a DETACHED
54
78
  // background shell running (a `run_in_background:true` Bash) that
@@ -87,6 +111,19 @@ function createHandleAbort({
87
111
  chat_id: chatId, user_id: msg.from?.id || null,
88
112
  had_active: hadActive,
89
113
  killed_background_shell: killedBackgroundShell,
114
+ // "Stop" incident forensics: the raw busy-probe signals at decision
115
+ // time. Lets us query, across real aborts, where the esc-hint /
116
+ // inFlight / pending-turn signals agreed vs diverged and refine the
117
+ // heuristic later. null when no probe ran (turn was already inFlight,
118
+ // or the backend has no probeBusyState).
119
+ busy_probe: busyProbe ? {
120
+ busy: busyProbe.busy,
121
+ streaming: busyProbe.streaming,
122
+ in_flight: busyProbe.inFlight,
123
+ pending_turns: busyProbe.pendingTurns,
124
+ captured: busyProbe.captured,
125
+ pane_tail: busyProbe.paneTail,
126
+ } : null,
90
127
  trigger: cleanText.slice(0, 40),
91
128
  });
92
129
 
@@ -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 });