polygram 0.12.0-rc.4 → 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.
- package/config.example.json +1 -0
- package/lib/attachments.js +7 -2
- package/lib/handlers/abort.js +38 -1
- package/lib/ipc/file-validator.js +8 -1
- package/lib/process/channels-tool-dispatcher.js +4 -1
- package/lib/process/cli-process.js +126 -0
- package/lib/telegram/api.js +9 -0
- package/lib/telegram/input-file.js +76 -0
- package/package.json +1 -1
- package/polygram.js +15 -1
package/config.example.json
CHANGED
|
@@ -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",
|
package/lib/attachments.js
CHANGED
|
@@ -22,8 +22,13 @@
|
|
|
22
22
|
* extension — the fallback only kicks in when MIME is unhelpful.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
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$/,
|
package/lib/handlers/abort.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ──────────────────────────────────────────
|
|
@@ -1394,6 +1457,63 @@ class CliProcess extends Process {
|
|
|
1394
1457
|
this._interruptGraceTimer.unref?.();
|
|
1395
1458
|
}
|
|
1396
1459
|
|
|
1460
|
+
/**
|
|
1461
|
+
* Is claude actually still working, regardless of the resolved-turn flag?
|
|
1462
|
+
*
|
|
1463
|
+
* "Stop" incident (shumorobot Music, 2026-05-31 13:08): the channels
|
|
1464
|
+
* backend resolves a turn on the quiet-window after claude's last reply
|
|
1465
|
+
* tool call (inFlight → false), but claude can keep working afterwards
|
|
1466
|
+
* (a subagent, a long Bash). The abort handler keyed its ack on inFlight
|
|
1467
|
+
* alone, so "Stop" said "Nothing to stop" one second after the bot said
|
|
1468
|
+
* "On it — downloading…" while a subagent churned.
|
|
1469
|
+
*
|
|
1470
|
+
* The TUI prints "esc to interrupt" (STREAMING_HINT_RE) continuously
|
|
1471
|
+
* whenever claude is busy — capture-pane is the truthful signal, the
|
|
1472
|
+
* channels analog of the (deleted) tmux hasBackgroundShell() probe.
|
|
1473
|
+
*
|
|
1474
|
+
* Returns a STRUCTURED probe (not just a boolean) so the abort path can
|
|
1475
|
+
* log the raw signals — pane tail + flags — to the events DB. That lets
|
|
1476
|
+
* us later characterize which states the heuristic gets right/wrong and
|
|
1477
|
+
* refine it (e.g. add signals beyond the esc-hint) without guessing.
|
|
1478
|
+
*
|
|
1479
|
+
* Never throws — a failed capture returns captured:false, busy:false.
|
|
1480
|
+
*
|
|
1481
|
+
* @returns {Promise<{busy:boolean, streaming:boolean, inFlight:boolean,
|
|
1482
|
+
* pendingTurns:number, captured:boolean, paneTail:(string|null)}>}
|
|
1483
|
+
*/
|
|
1484
|
+
async probeBusyState() {
|
|
1485
|
+
const base = {
|
|
1486
|
+
busy: false, streaming: false,
|
|
1487
|
+
inFlight: this.inFlight, pendingTurns: this.pendingTurns.size,
|
|
1488
|
+
captured: false, paneTail: null,
|
|
1489
|
+
};
|
|
1490
|
+
if (this.closed || !this.tmuxSession || typeof this.runner?.captureWide !== 'function') {
|
|
1491
|
+
return base;
|
|
1492
|
+
}
|
|
1493
|
+
let pane;
|
|
1494
|
+
try {
|
|
1495
|
+
pane = await this.runner.captureWide(this.tmuxSession);
|
|
1496
|
+
} catch (err) {
|
|
1497
|
+
this.logger.warn?.(`[${this.label}] channels: probeBusyState captureWide failed: ${err.message}`);
|
|
1498
|
+
return base;
|
|
1499
|
+
}
|
|
1500
|
+
if (!pane) return base;
|
|
1501
|
+
const streaming = STREAMING_HINT_RE.test(pane);
|
|
1502
|
+
return {
|
|
1503
|
+
...base,
|
|
1504
|
+
busy: streaming,
|
|
1505
|
+
streaming,
|
|
1506
|
+
captured: true,
|
|
1507
|
+
paneTail: pane.slice(-200),
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** Boolean shorthand for probeBusyState().busy (abort-path convenience). */
|
|
1512
|
+
async isBusy() {
|
|
1513
|
+
const { busy } = await this.probeBusyState();
|
|
1514
|
+
return busy;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1397
1517
|
async kill(reason = 'kill') {
|
|
1398
1518
|
if (this.closed) return;
|
|
1399
1519
|
// Parity P19: re-entry guard for concurrent kill() calls. Mirrors
|
|
@@ -1804,6 +1924,12 @@ class CliProcess extends Process {
|
|
|
1804
1924
|
if (this.botName && this.claudeSessionId) {
|
|
1805
1925
|
try { removeHookFiles({ botName: this.botName, sessionId: this.claudeSessionId }); } catch {}
|
|
1806
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
|
+
}
|
|
1807
1933
|
|
|
1808
1934
|
this.emit('close', 0);
|
|
1809
1935
|
}
|
package/lib/telegram/api.js
CHANGED
|
@@ -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.
|
|
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: {
|
|
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;
|