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.
- package/lib/attachments.js +40 -0
- package/lib/handlers/download.js +11 -6
- package/lib/process/cli-process.js +14 -8
- package/lib/telegram/api.js +30 -4
- package/lib/telegram/input-file.js +29 -0
- package/package.json +1 -1
- package/polygram.js +12 -11
package/lib/attachments.js
CHANGED
|
@@ -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,
|
package/lib/handlers/download.js
CHANGED
|
@@ -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
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
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-
|
|
612
|
-
//
|
|
613
|
-
// dispatcher (live size-check) and the system prompt (so claude
|
|
614
|
-
// the right limit).
|
|
615
|
-
//
|
|
616
|
-
|
|
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
|
|
811
|
-
|
|
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}\`,`,
|
package/lib/telegram/api.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
479
|
-
//
|
|
480
|
-
//
|
|
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
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
const _inTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
|
|
793
|
+
// Backend-derived inbound cap with override (topic → chat → bot → default),
|
|
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:
|
|
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);
|