polygram 0.12.0-rc.6 → 0.12.0-rc.7

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",
@@ -22,8 +22,13 @@
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; override per-bot via config.bot.maxInboundFileBytes if so.
30
+ const MAX_FILE_BYTES = 20 * 1024 * 1024;
31
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
27
32
  const MIME_ALLOW = [
28
33
  /^image\//, /^audio\//, /^video\//,
29
34
  /^application\/pdf$/, /^text\/plain$/,
@@ -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);
@@ -203,7 +203,10 @@ function createChannelsToolDispatcher({
203
203
  const fieldName = isImage ? 'photo' : 'document';
204
204
  const params = {
205
205
  chat_id: chatId,
206
- [fieldName]: { source: check.resolved },
206
+ // { source } envelope → grammy InputFile in tg()'s coerceFileParams.
207
+ // Pre-fix this bare object reached grammy unrecognized and every
208
+ // upload 400'd with "Wrong port number" (file-send never worked).
209
+ [fieldName]: { source: check.resolved, filename: path.basename(check.resolved) },
207
210
  };
208
211
  if (threadId) params.message_thread_id = threadId;
209
212
  await send(bot, method, params, { source: 'channels-tool-dispatcher', sessionKey });
@@ -48,6 +48,10 @@ 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');
51
55
  const { runStartupGate } = require('../tmux/startup-gate');
52
56
  const { POLYGRAM_DISPLAY_HINT } = require('../telegram/display-hint');
53
57
 
@@ -297,6 +301,23 @@ class CliProcess extends Process {
297
301
  // permit files under the agent's workspace.
298
302
  this.sessionCwd = opts.cwd || null;
299
303
 
304
+ // File-send staging dir (2026-06 file-send feature). The dispatcher
305
+ // allowlist always permits <DEFAULT_ATTACHMENT_BASE>/<sessionKey>/, but
306
+ // nothing ever CREATED it — so claude's reply(files) attempts at
307
+ // /tmp/polygram-attachments failed (dir absent / realpath mismatch) and
308
+ // it flailed across other paths. Create it here and surface it to the
309
+ // prompt so claude has one blessed, always-allowed place to stage a file
310
+ // before sending. realpathSync so the stored path matches what the
311
+ // validator resolves (the /tmp ↔ /private/tmp fix).
312
+ try {
313
+ const dir = path.join(DEFAULT_ATTACHMENT_BASE, String(this.sessionKey));
314
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
315
+ this.attachmentStagingDir = fs.realpathSync(dir);
316
+ } catch (err) {
317
+ this.attachmentStagingDir = null;
318
+ this.logger.warn?.(`[${this.label}] channels: staging dir create failed: ${err.message}`);
319
+ }
320
+
300
321
  // Opaque random token for socket filename — do NOT leak sessionKey to /tmp.
301
322
  const socketToken = crypto.randomBytes(16).toString('hex');
302
323
  this.sockPath = path.join(os.tmpdir(), `polygram-${socketToken}.sock`);
@@ -598,6 +619,27 @@ class CliProcess extends Process {
598
619
  'Internal tool calls (Bash, Edit, Write, Read, etc.) are fine to use',
599
620
  'as normal — only the FINAL user-visible message needs to go through',
600
621
  'the reply tool.',
622
+ '',
623
+ '### Sending FILES (tracks, images, docs) to the user',
624
+ '',
625
+ 'The `mcp__polygram-bridge__reply` tool takes an optional `files` array of',
626
+ 'absolute paths. This is the ONLY way to send a file. Do NOT use Bash,',
627
+ 'curl, the Telegram Bot API, or polygram-ipc to send files — those fail.',
628
+ '',
629
+ ...(this.attachmentStagingDir ? [
630
+ `To send a file: COPY it into the staging dir \`${this.attachmentStagingDir}\`,`,
631
+ 'then call reply with its absolute path, e.g.:',
632
+ ` reply(chat_id="<id>", text="Here's the track", files=["${this.attachmentStagingDir}/track.flac"])`,
633
+ 'polygram auto-deletes staged files after the turn — you do not need to clean up.',
634
+ 'You may also send directly from the agent workspace (cwd); other paths are rejected.',
635
+ ] : [
636
+ 'Copy the file somewhere under your workspace (cwd) and pass its absolute',
637
+ 'path in `files`. Paths outside the workspace are rejected for safety.',
638
+ ]),
639
+ '',
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.',
601
643
  ].join('\n'));
602
644
 
603
645
  // Parity audit P6: honor isolateUserConfig — mirrors tmux pattern at
@@ -1203,6 +1245,27 @@ class CliProcess extends Process {
1203
1245
  pending.resolve(result);
1204
1246
  this.emit('result', { subtype: 'success' }, { streamText: text });
1205
1247
  this.emit('idle');
1248
+ // File-send staging auto-purge (your choice — no "claude must delete").
1249
+ // Once the LAST turn settles, wipe the staging dir's contents so files
1250
+ // claude copied in to send don't accumulate on disk across turns. Only
1251
+ // when fully idle, so a file staged for a still-pending concurrent turn
1252
+ // isn't yanked mid-send.
1253
+ if (this.pendingTurns.size === 0) this._purgeStagingDir();
1254
+ }
1255
+
1256
+ /**
1257
+ * Empty the per-session file-send staging dir (keep the dir itself).
1258
+ * Best-effort; never throws. Called when the session goes idle and on kill.
1259
+ */
1260
+ _purgeStagingDir() {
1261
+ if (!this.attachmentStagingDir) return;
1262
+ let entries;
1263
+ try { entries = fs.readdirSync(this.attachmentStagingDir); }
1264
+ catch { return; }
1265
+ for (const name of entries) {
1266
+ try { fs.rmSync(path.join(this.attachmentStagingDir, name), { recursive: true, force: true }); }
1267
+ catch { /* best-effort */ }
1268
+ }
1206
1269
  }
1207
1270
 
1208
1271
  // ─── public Process API ──────────────────────────────────────────
@@ -1861,6 +1924,12 @@ class CliProcess extends Process {
1861
1924
  if (this.botName && this.claudeSessionId) {
1862
1925
  try { removeHookFiles({ botName: this.botName, sessionId: this.claudeSessionId }); } catch {}
1863
1926
  }
1927
+ // File-send staging: remove the whole per-session dir on kill (purge only
1928
+ // empties it between turns; kill is end-of-life so drop it entirely).
1929
+ if (this.attachmentStagingDir) {
1930
+ try { fs.rmSync(this.attachmentStagingDir, { recursive: true, force: true }); } catch {}
1931
+ this.attachmentStagingDir = null;
1932
+ }
1864
1933
 
1865
1934
  this.emit('close', 0);
1866
1935
  }
@@ -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.7",
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
@@ -1672,9 +1672,23 @@ function shouldHandle(msg, chatConfig, botUsername) {
1672
1672
  }
1673
1673
 
1674
1674
  function createBot(token) {
1675
+ // Optional self-hosted Telegram Bot API server. When config.bot.apiRoot is
1676
+ // set (e.g. "http://localhost:8081" from a local `telegram-bot-api`
1677
+ // process), grammy routes all Bot API calls there instead of
1678
+ // api.telegram.org — which lifts file send/receive from cloud's 50 MB-out /
1679
+ // 20 MB-in to 2 GB both ways. Omit it (default) → cloud Telegram, unchanged.
1680
+ // The local server is a separate companion daemon; this is just the knob
1681
+ // that points polygram at it. See docs/0.12.0-file-send.md.
1682
+ const apiRoot = config.bot?.apiRoot;
1675
1683
  const bot = new Bot(token, {
1676
- client: { timeoutSeconds: 60 },
1684
+ client: {
1685
+ timeoutSeconds: 60,
1686
+ ...(apiRoot ? { apiRoot } : {}),
1687
+ },
1677
1688
  });
1689
+ if (apiRoot) {
1690
+ console.log(`[polygram] using local Telegram Bot API server: ${apiRoot} (2GB file limit)`);
1691
+ }
1678
1692
  let botUsername = '';
1679
1693
  // Cached once @botUsername is known — was recompiling per inbound msg.
1680
1694
  let mentionRe = null;