polygram 0.8.0 → 0.9.0-rc.2
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/.claude-plugin/plugin.json +1 -1
- package/lib/{agent-loader.js → agents/loader.js} +6 -8
- package/lib/{approvals.js → approvals/store.js} +28 -5
- package/lib/{approval-ui.js → approvals/ui.js} +1 -17
- package/lib/config.js +121 -0
- package/lib/{error-classify.js → error/classify.js} +25 -34
- package/lib/handlers/abort.js +89 -0
- package/lib/handlers/approvals.js +361 -0
- package/lib/handlers/autosteer.js +94 -0
- package/lib/handlers/config-callback.js +118 -0
- package/lib/handlers/config-ui.js +104 -0
- package/lib/handlers/dispatcher.js +263 -0
- package/lib/handlers/download.js +182 -0
- package/lib/handlers/extract-attachments.js +97 -0
- package/lib/handlers/ipc-send.js +80 -0
- package/lib/handlers/poll.js +140 -0
- package/lib/handlers/record-inbound.js +88 -0
- package/lib/handlers/slash-commands.js +319 -0
- package/lib/handlers/voice.js +107 -0
- package/lib/pm-interface.js +27 -29
- package/lib/sdk/build-options.js +177 -0
- package/lib/sdk/callbacks.js +213 -0
- package/lib/{process-manager-sdk.js → sdk/process-manager.js} +19 -31
- package/lib/{telegram.js → telegram/api.js} +2 -2
- package/lib/{telegram-prompt.js → telegram/display-hint.js} +0 -14
- package/lib/{stream-reply.js → telegram/streamer.js} +4 -4
- package/package.json +2 -3
- package/polygram.js +347 -2581
- package/scripts/doctor.js +1 -1
- package/scripts/ipc-smoke.js +1 -10
- package/bin/approval-hook.js +0 -113
- package/lib/approval-waiters.js +0 -201
- package/lib/pm-router.js +0 -201
- package/lib/process-manager.js +0 -806
- /package/lib/{auto-resume.js → db/auto-resume.js} +0 -0
- /package/lib/{inbox.js → db/inbox.js} +0 -0
- /package/lib/{pairings.js → db/pairings.js} +0 -0
- /package/lib/{replay-window.js → db/replay-window.js} +0 -0
- /package/lib/{sent-cache.js → db/sent-cache.js} +0 -0
- /package/lib/{sessions.js → db/sessions.js} +0 -0
- /package/lib/{net-errors.js → error/net.js} +0 -0
- /package/lib/{ipc-client.js → ipc/client.js} +0 -0
- /package/lib/{ipc-file-validator.js → ipc/file-validator.js} +0 -0
- /package/lib/{ipc-server.js → ipc/server.js} +0 -0
- /package/lib/{telegram-chunk.js → telegram/chunk.js} +0 -0
- /package/lib/{deliver.js → telegram/deliver.js} +0 -0
- /package/lib/{telegram-format.js → telegram/format.js} +0 -0
- /package/lib/{parse-response.js → telegram/parse.js} +0 -0
- /package/lib/{status-reactions.js → telegram/reactions.js} +0 -0
- /package/lib/{typing-indicator.js → telegram/typing.js} +0 -0
- /package/lib/{voice.js → telegram/voice.js} +0 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-message handler dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* `dispatchHandleMessage(sessionKey, chatId, msg, bot)` is what
|
|
5
|
+
* grammy's `bot.on('message')` calls per inbound — it runs
|
|
6
|
+
* handleMessage in a fire-and-forget manner with centralised
|
|
7
|
+
* error handling, in-flight-counter telemetry, and auto-resume
|
|
8
|
+
* recovery on no-activity timeouts.
|
|
9
|
+
*
|
|
10
|
+
* Owned state:
|
|
11
|
+
* - inFlightHandlers (Map<sessionKey, count>) — per-session
|
|
12
|
+
* concurrent handler count. queue-depth-warning fires when
|
|
13
|
+
* this crosses queueWarnThreshold.
|
|
14
|
+
* - autoResumeTracker — per-session cooldown to prevent
|
|
15
|
+
* infinite resume-loop on permanently wedged tools.
|
|
16
|
+
*
|
|
17
|
+
* Auto-resume contract: on a 300s no-activity timeout (Claude
|
|
18
|
+
* never emits a chunk), spawn a fresh Query resuming the same
|
|
19
|
+
* claude_session_id and inject a continuation nudge. Falls back
|
|
20
|
+
* to the standard error reply if the resume itself fails.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const CONCURRENT_WARN_THRESHOLD_DEFAULT = 20;
|
|
26
|
+
|
|
27
|
+
function createDispatcher({
|
|
28
|
+
config,
|
|
29
|
+
db,
|
|
30
|
+
dbWrite,
|
|
31
|
+
tg,
|
|
32
|
+
botName,
|
|
33
|
+
logEvent,
|
|
34
|
+
// Closures that polygram.js owns; passed in:
|
|
35
|
+
handleMessage, // async (sessionKey, chatId, msg, bot)
|
|
36
|
+
sendToProcess, // async (sessionKey, prompt, ctx)
|
|
37
|
+
// Cross-cutting helpers:
|
|
38
|
+
classifyError, // (err) → { kind, userMessage, isTransient, autoRecover }
|
|
39
|
+
isAutoResumable, // ({ error, aborted, replay, shuttingDown }) → boolean
|
|
40
|
+
abortGrace, // lib/abort-grace.js instance
|
|
41
|
+
autoResumeTracker, // lib/db/auto-resume.js instance
|
|
42
|
+
chunkMarkdownText, // lib/telegram/chunk.js
|
|
43
|
+
deliverReplies, // lib/telegram/deliver.js
|
|
44
|
+
TG_MAX_LEN = 4096,
|
|
45
|
+
// State accessors (need late binding because polygram.js mutates):
|
|
46
|
+
getIsShuttingDown, // () → boolean
|
|
47
|
+
logger = console,
|
|
48
|
+
} = {}) {
|
|
49
|
+
// Per-session in-flight handler count.
|
|
50
|
+
const inFlightHandlers = new Map();
|
|
51
|
+
|
|
52
|
+
function queueWarnThreshold() {
|
|
53
|
+
const v = Number(config.bot?.queueWarnThreshold);
|
|
54
|
+
return (Number.isInteger(v) && v > 0) ? v : CONCURRENT_WARN_THRESHOLD_DEFAULT;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function errorReplyText(err) {
|
|
58
|
+
const { userMessage } = classifyError(err);
|
|
59
|
+
return userMessage; // may be null — "suppress reply" signal
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// rc.54: spawn a fresh Query resuming the same session_id and ask
|
|
63
|
+
// Claude to continue the timed-out work. The killed pm Query has
|
|
64
|
+
// already torn down the wedged subprocess; getOrSpawnForChat creates
|
|
65
|
+
// a new entry that picks up the saved session_id and sets
|
|
66
|
+
// `--resume <id>` on the SDK Options.
|
|
67
|
+
async function attemptAutoResume(sessionKey, chatId, originalMsg, bot) {
|
|
68
|
+
const threadId = originalMsg.message_thread_id || null;
|
|
69
|
+
|
|
70
|
+
// 1. Tell the user we're auto-resuming so they don't think
|
|
71
|
+
// nothing happened. Threaded under their original message.
|
|
72
|
+
await tg(bot, 'sendMessage', {
|
|
73
|
+
chat_id: chatId,
|
|
74
|
+
text: '🔁 Auto-resuming after timeout — continuing where the previous turn left off.',
|
|
75
|
+
reply_parameters: { message_id: originalMsg.message_id },
|
|
76
|
+
...(threadId && { message_thread_id: threadId }),
|
|
77
|
+
}, { source: 'auto-resume-indicator', botName }).catch((sendErr) => {
|
|
78
|
+
logger.error?.(`[${sessionKey}] auto-resume indicator send failed: ${sendErr.message}`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// 2. Continuation prompt. Plain text — the SDK Query resumes
|
|
82
|
+
// the saved session_id, so Claude has full prior transcript
|
|
83
|
+
// context including its own partially-streamed text and
|
|
84
|
+
// tool calls. We just need to tell it WHAT happened.
|
|
85
|
+
const continuation = '[polygram] Your previous turn timed out at 300s with no Claude activity (likely a wedged tool call — long Bash, hanging MCP, or stuck subagent). Continue from where you left off; do not restart from scratch. If the same operation would just hang again, abort it and tell me.';
|
|
86
|
+
|
|
87
|
+
// 3. No-op streamer + reactor. We don't stream the resume
|
|
88
|
+
// turn's response (we'll send it as one message at the
|
|
89
|
+
// end). pm invokes streamer/reactor methods only when
|
|
90
|
+
// present; minimal stubs keep pm happy.
|
|
91
|
+
const noopStreamer = {
|
|
92
|
+
onChunk: async () => {},
|
|
93
|
+
forceNewMessage: () => {},
|
|
94
|
+
finalize: async () => ({ streamed: false }),
|
|
95
|
+
flushDraft: async () => {},
|
|
96
|
+
discard: async () => {},
|
|
97
|
+
};
|
|
98
|
+
const noopReactor = {
|
|
99
|
+
setState: () => {},
|
|
100
|
+
heartbeat: () => {},
|
|
101
|
+
clear: async () => {},
|
|
102
|
+
stop: () => {},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const result = await sendToProcess(sessionKey, continuation, {
|
|
106
|
+
streamer: noopStreamer,
|
|
107
|
+
reactor: noopReactor,
|
|
108
|
+
sourceMsgId: originalMsg.message_id,
|
|
109
|
+
threadId,
|
|
110
|
+
onFirstStream: () => {},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (result?.error) {
|
|
114
|
+
throw new Error(`auto-resume turn errored: ${String(result.error).slice(0, 200)}`);
|
|
115
|
+
}
|
|
116
|
+
if (!result?.text) {
|
|
117
|
+
throw new Error('auto-resume turn produced no text');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 4. Send the continuation reply as regular Telegram messages,
|
|
121
|
+
// threaded under the original user message.
|
|
122
|
+
const chunks = chunkMarkdownText(result.text, TG_MAX_LEN);
|
|
123
|
+
await deliverReplies({
|
|
124
|
+
bot,
|
|
125
|
+
send: (b, method, params, m) => tg(b, method, params, m),
|
|
126
|
+
chatId,
|
|
127
|
+
threadId,
|
|
128
|
+
chunks,
|
|
129
|
+
replyToMessageId: originalMsg.message_id,
|
|
130
|
+
meta: { source: 'auto-resume-reply', botName },
|
|
131
|
+
logger: { error: (m) => logger.error?.(`[${sessionKey}] auto-resume deliver: ${m}`) },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return result.text;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
|
|
138
|
+
const count = (inFlightHandlers.get(sessionKey) || 0) + 1;
|
|
139
|
+
inFlightHandlers.set(sessionKey, count);
|
|
140
|
+
const warnAt = queueWarnThreshold();
|
|
141
|
+
if (count === warnAt) {
|
|
142
|
+
logEvent('queue-depth-warning', {
|
|
143
|
+
chat_id: chatId, session_key: sessionKey,
|
|
144
|
+
in_flight: count, threshold: warnAt,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
handleMessage(sessionKey, chatId, msg, bot).catch((err) => {
|
|
148
|
+
const wasAborted = abortGrace.isRecent(sessionKey);
|
|
149
|
+
const isReplay = msg._isReplay === true;
|
|
150
|
+
const isShuttingDown = getIsShuttingDown();
|
|
151
|
+
logger.error?.(`[${sessionKey}] Error: ${err.message}`);
|
|
152
|
+
// Mark the row terminal so the right thing happens on next
|
|
153
|
+
// boot:
|
|
154
|
+
// aborted — user explicitly stopped → not replayable
|
|
155
|
+
// shutdown + new — 'replay-pending' so next boot re-dispatches
|
|
156
|
+
// shutdown + replay — keep 'replay-attempted' (one-shot guard
|
|
157
|
+
// prevents infinite replay-on-replay)
|
|
158
|
+
// else — 'failed' (genuine claude crash / timeout)
|
|
159
|
+
const status = wasAborted
|
|
160
|
+
? 'aborted'
|
|
161
|
+
: isShuttingDown
|
|
162
|
+
? (isReplay ? 'replay-attempted' : 'replay-pending')
|
|
163
|
+
: 'failed';
|
|
164
|
+
dbWrite(() => db.setInboundHandlerStatus({
|
|
165
|
+
chat_id: chatId, msg_id: msg.message_id, status,
|
|
166
|
+
}), `set handler_status=${status}`);
|
|
167
|
+
logEvent('handler-error', {
|
|
168
|
+
chat_id: chatId, session_key: sessionKey,
|
|
169
|
+
msg_id: msg?.message_id,
|
|
170
|
+
error: err.message?.slice(0, 500),
|
|
171
|
+
stack: err.stack?.split('\n').slice(0, 5).join('\n'),
|
|
172
|
+
aborted: wasAborted || undefined,
|
|
173
|
+
replay: isReplay || undefined,
|
|
174
|
+
});
|
|
175
|
+
// rc.55: surface replay failures with a meaningful message.
|
|
176
|
+
// Pre-rc.55 any boot-replay turn that failed for ANY reason
|
|
177
|
+
// was silently dropped. The rc.51-onward boot-replay path is
|
|
178
|
+
// a recovery primitive, not stale-message handling — when it
|
|
179
|
+
// fails, the user IS still waiting.
|
|
180
|
+
if (isReplay && !wasAborted && !isShuttingDown) {
|
|
181
|
+
tg(bot, 'sendMessage', {
|
|
182
|
+
chat_id: chatId,
|
|
183
|
+
text: '⚠️ This turn was interrupted and didn\'t complete on retry — please rephrase or simplify, or split into smaller steps.',
|
|
184
|
+
reply_parameters: { message_id: msg.message_id },
|
|
185
|
+
}, { source: 'error-reply', botName }).catch((replyErr) => {
|
|
186
|
+
logger.error?.(`[${sessionKey}] failed to send replay-failure reply: ${replyErr.message}`);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Suppress the user-facing error reply when:
|
|
190
|
+
// - boot replay (handled above),
|
|
191
|
+
// - shutting down ("Process killed" isn't a real error),
|
|
192
|
+
// - user just /stop'd (already saw their abort ack).
|
|
193
|
+
if (!wasAborted && !isReplay && !isShuttingDown) {
|
|
194
|
+
// rc.54: auto-resume on 300s no-activity timeout. The
|
|
195
|
+
// resume turn itself runs through sendToProcess directly
|
|
196
|
+
// (not handleMessage), so its errors don't re-enter this
|
|
197
|
+
// catch block — autoResumeTracker.isInCooldown() is the
|
|
198
|
+
// only guard needed against runaway loops.
|
|
199
|
+
const resumable = isAutoResumable({
|
|
200
|
+
error: err, aborted: wasAborted, replay: isReplay, shuttingDown: isShuttingDown,
|
|
201
|
+
});
|
|
202
|
+
if (resumable && !autoResumeTracker.isInCooldown(sessionKey)) {
|
|
203
|
+
autoResumeTracker.markAttempt(sessionKey);
|
|
204
|
+
logEvent('auto-resume-attempted', {
|
|
205
|
+
chat_id: chatId, session_key: sessionKey, msg_id: msg.message_id,
|
|
206
|
+
original_error: err.message?.slice(0, 200),
|
|
207
|
+
});
|
|
208
|
+
attemptAutoResume(sessionKey, chatId, msg, bot)
|
|
209
|
+
.then(() => {
|
|
210
|
+
logEvent('auto-resume-success', {
|
|
211
|
+
chat_id: chatId, session_key: sessionKey, msg_id: msg.message_id,
|
|
212
|
+
});
|
|
213
|
+
autoResumeTracker.clear(sessionKey);
|
|
214
|
+
})
|
|
215
|
+
.catch((resumeErr) => {
|
|
216
|
+
logger.error?.(`[${sessionKey}] auto-resume failed: ${resumeErr?.message}`);
|
|
217
|
+
logEvent('auto-resume-failed', {
|
|
218
|
+
chat_id: chatId, session_key: sessionKey, msg_id: msg.message_id,
|
|
219
|
+
error: resumeErr?.message?.slice(0, 200),
|
|
220
|
+
});
|
|
221
|
+
const fallbackText = errorReplyText(err);
|
|
222
|
+
if (fallbackText) {
|
|
223
|
+
tg(bot, 'sendMessage', {
|
|
224
|
+
chat_id: chatId, text: fallbackText,
|
|
225
|
+
reply_parameters: { message_id: msg.message_id },
|
|
226
|
+
}, { source: 'error-reply', botName }).catch(() => {});
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// 0.7.7: errorReplyText may return null (suppress reply
|
|
232
|
+
// signal — INTERRUPTED inside abort grace).
|
|
233
|
+
const replyText = errorReplyText(err);
|
|
234
|
+
if (replyText) {
|
|
235
|
+
tg(bot, 'sendMessage', {
|
|
236
|
+
chat_id: chatId,
|
|
237
|
+
text: replyText,
|
|
238
|
+
reply_parameters: { message_id: msg.message_id },
|
|
239
|
+
}, { source: 'error-reply', botName }).catch((replyErr) => {
|
|
240
|
+
logger.error?.(`[${sessionKey}] failed to send error reply: ${replyErr.message}`);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}).finally(() => {
|
|
245
|
+
const n = (inFlightHandlers.get(sessionKey) || 1) - 1;
|
|
246
|
+
if (n <= 0) inFlightHandlers.delete(sessionKey);
|
|
247
|
+
else inFlightHandlers.set(sessionKey, n);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
dispatchHandleMessage,
|
|
253
|
+
attemptAutoResume,
|
|
254
|
+
errorReplyText,
|
|
255
|
+
queueWarnThreshold,
|
|
256
|
+
inFlightHandlers, // exposed so polygram.js can introspect for shutdown drain
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = {
|
|
261
|
+
createDispatcher,
|
|
262
|
+
CONCURRENT_WARN_THRESHOLD_DEFAULT,
|
|
263
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram attachment downloader with bounded concurrency.
|
|
3
|
+
*
|
|
4
|
+
* Per inbound message with attachments, this fans out parallel
|
|
5
|
+
* fetches against Telegram's CDN, writes each to disk atomically
|
|
6
|
+
* (temp file + rename), and updates the attachments DB row with
|
|
7
|
+
* download_status / local_path / error. The per-file 10MB cap is
|
|
8
|
+
* enforced THREE ways: content-length header (cheap), streaming
|
|
9
|
+
* accumulator (DOS protection), final post-buffer check (defense
|
|
10
|
+
* in depth).
|
|
11
|
+
*
|
|
12
|
+
* Reuse path: if a row already says downloaded AND the file is on
|
|
13
|
+
* disk with non-zero size, the fetch is skipped (idempotent on
|
|
14
|
+
* boot-replay).
|
|
15
|
+
*
|
|
16
|
+
* Token redaction: the fetch URL embeds bot${TOKEN}; some undici
|
|
17
|
+
* error variants stringify the URL into err.message. We pipe every
|
|
18
|
+
* persisted error through redactBotToken so the bot token never
|
|
19
|
+
* lands in attachments.download_error or stderr.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const { redactBotToken } = require('../error/net');
|
|
27
|
+
const { MAX_FILE_BYTES } = require('../attachments');
|
|
28
|
+
|
|
29
|
+
const ATTACHMENT_DOWNLOAD_CONCURRENCY_DEFAULT = 6;
|
|
30
|
+
|
|
31
|
+
function sanitizeFilename(name) {
|
|
32
|
+
if (!name) return 'file';
|
|
33
|
+
return name.replace(/[\/\\:\0]/g, '_').slice(0, 120);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createDownloadAttachments({
|
|
37
|
+
config,
|
|
38
|
+
db,
|
|
39
|
+
dbWrite,
|
|
40
|
+
inboxDir,
|
|
41
|
+
logger = console,
|
|
42
|
+
fetchImpl = (typeof fetch === 'function' ? fetch : null),
|
|
43
|
+
} = {}) {
|
|
44
|
+
|
|
45
|
+
function attachmentConcurrency() {
|
|
46
|
+
const v = Number(config.bot?.attachmentConcurrency);
|
|
47
|
+
return (Number.isInteger(v) && v > 0) ? v : ATTACHMENT_DOWNLOAD_CONCURRENCY_DEFAULT;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Per-attachment download. Pure function over (att, deps) → result.
|
|
51
|
+
// Pulled out of the loop so downloadAttachments can run several in
|
|
52
|
+
// parallel.
|
|
53
|
+
async function downloadOneAttachment(bot, token, chatId, msg, chatDir, att) {
|
|
54
|
+
// Reuse path: row already says downloaded AND the file is on disk.
|
|
55
|
+
if (att.download_status === 'downloaded' && att.local_path) {
|
|
56
|
+
try {
|
|
57
|
+
if (fs.statSync(att.local_path).size > 0) {
|
|
58
|
+
return { ...att, path: att.local_path, size: att.size_bytes || 0, error: null };
|
|
59
|
+
}
|
|
60
|
+
} catch { /* fall through to refetch */ }
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const fileInfo = await bot.api.getFile(att.file_id);
|
|
64
|
+
if (!fileInfo?.file_path) throw new Error('no file_path from getFile');
|
|
65
|
+
const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
|
|
66
|
+
const res = await fetchImpl(url);
|
|
67
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
68
|
+
// Three-layer size enforcement, in order of cheapness:
|
|
69
|
+
// 1. Content-Length header — fail-fast before reading body.
|
|
70
|
+
// 2. Streaming accumulator — abort the moment cumulative byte
|
|
71
|
+
// count crosses the cap. Defends against attackers omitting
|
|
72
|
+
// Content-Length: pre-cap the whole body could pin RSS.
|
|
73
|
+
// 3. Final post-buffer check — defense in depth.
|
|
74
|
+
const cl = parseInt(res.headers.get('content-length') || '0', 10);
|
|
75
|
+
if (cl > MAX_FILE_BYTES) {
|
|
76
|
+
throw new Error(`content-length ${cl} exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
77
|
+
}
|
|
78
|
+
let total = 0;
|
|
79
|
+
const chunks = [];
|
|
80
|
+
if (res.body && typeof res.body.getReader === 'function') {
|
|
81
|
+
const reader = res.body.getReader();
|
|
82
|
+
while (true) {
|
|
83
|
+
const { done, value } = await reader.read();
|
|
84
|
+
if (done) break;
|
|
85
|
+
total += value.byteLength;
|
|
86
|
+
if (total > MAX_FILE_BYTES) {
|
|
87
|
+
try { await reader.cancel(); } catch {}
|
|
88
|
+
throw new Error(`stream ${total}+ bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
89
|
+
}
|
|
90
|
+
chunks.push(value);
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
// Fallback for runtimes without WHATWG streams (shouldn't fire
|
|
94
|
+
// on Node 22+).
|
|
95
|
+
const ab = await res.arrayBuffer();
|
|
96
|
+
if (ab.byteLength > MAX_FILE_BYTES) {
|
|
97
|
+
throw new Error(`body ${ab.byteLength} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
98
|
+
}
|
|
99
|
+
chunks.push(new Uint8Array(ab));
|
|
100
|
+
total = ab.byteLength;
|
|
101
|
+
}
|
|
102
|
+
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
103
|
+
if (buf.length > MAX_FILE_BYTES) {
|
|
104
|
+
throw new Error(`body ${buf.length} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
105
|
+
}
|
|
106
|
+
const safeName = sanitizeFilename(att.name);
|
|
107
|
+
// Embed file_unique_id so two attachments with the same msg_id+name
|
|
108
|
+
// (album, resend) can't silently overwrite each other.
|
|
109
|
+
const uniq = att.file_unique_id ? `-${att.file_unique_id}` : '';
|
|
110
|
+
const localName = `${msg.message_id}${uniq}-${safeName}`;
|
|
111
|
+
const localPath = path.join(chatDir, localName);
|
|
112
|
+
// Atomic write: temp file + rename. A crash mid-write leaves a
|
|
113
|
+
// .tmp.* file (swept later) rather than a truncated canonical
|
|
114
|
+
// file the EEXIST dedup branch would happily serve next time.
|
|
115
|
+
if (fs.existsSync(localPath)) {
|
|
116
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
|
|
117
|
+
} else {
|
|
118
|
+
const tmpPath = `${localPath}.tmp.${process.pid}.${Date.now()}`;
|
|
119
|
+
try {
|
|
120
|
+
fs.writeFileSync(tmpPath, buf, { flag: 'wx' });
|
|
121
|
+
fs.renameSync(tmpPath, localPath);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
124
|
+
if (e.code !== 'EEXIST') throw e;
|
|
125
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${buf.length} bytes) → ${localPath}`);
|
|
129
|
+
dbWrite(() => db.markAttachmentDownloaded(att.id, {
|
|
130
|
+
local_path: localPath, size_bytes: att.size_bytes || buf.length,
|
|
131
|
+
}), `markAttachmentDownloaded ${att.id}`);
|
|
132
|
+
return { ...att, path: localPath, size: att.size_bytes || buf.length, error: null };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// Don't drop the attachment silently — push it through with the
|
|
135
|
+
// failure noted. buildAttachmentTags renders this as
|
|
136
|
+
// <attachment-failed reason="..." /> so claude tells the user
|
|
137
|
+
// "I couldn't see your <kind>" instead of pretending it received
|
|
138
|
+
// text only. Token redaction is mandatory: the fetch URL embeds
|
|
139
|
+
// bot${TOKEN} and some undici variants stringify it into err.message.
|
|
140
|
+
const raw = (err.message || 'unknown').slice(0, 200);
|
|
141
|
+
const reason = redactBotToken(raw);
|
|
142
|
+
logger.error?.(`[attach] download failed for ${att.name}: ${reason}`);
|
|
143
|
+
dbWrite(() => db.markAttachmentFailed(att.id, reason),
|
|
144
|
+
`markAttachmentFailed ${att.id}`);
|
|
145
|
+
return { ...att, path: null, error: reason };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 0.6.7: parallel fetches with bounded concurrency. Inner work is
|
|
150
|
+
// stateless per-attachment (writes keyed on file_unique_id so two
|
|
151
|
+
// parallel downloads can't collide). Order of results is preserved
|
|
152
|
+
// by writing into a fixed-size array at the original index —
|
|
153
|
+
// important so the prompt sees attachments in album order.
|
|
154
|
+
async function downloadAttachments(bot, token, chatId, msg, rows) {
|
|
155
|
+
if (!rows.length) return [];
|
|
156
|
+
const chatDir = path.join(inboxDir, String(chatId));
|
|
157
|
+
fs.mkdirSync(chatDir, { recursive: true });
|
|
158
|
+
|
|
159
|
+
const results = new Array(rows.length);
|
|
160
|
+
let cursor = 0;
|
|
161
|
+
const workers = Array.from(
|
|
162
|
+
{ length: Math.min(attachmentConcurrency(), rows.length) },
|
|
163
|
+
async () => {
|
|
164
|
+
while (true) {
|
|
165
|
+
const idx = cursor++;
|
|
166
|
+
if (idx >= rows.length) return;
|
|
167
|
+
results[idx] = await downloadOneAttachment(bot, token, chatId, msg, chatDir, rows[idx]);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
await Promise.all(workers);
|
|
172
|
+
return results;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return downloadAttachments;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = {
|
|
179
|
+
createDownloadAttachments,
|
|
180
|
+
sanitizeFilename,
|
|
181
|
+
ATTACHMENT_DOWNLOAD_CONCURRENCY_DEFAULT,
|
|
182
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract attachment metadata from a Telegram message.
|
|
3
|
+
*
|
|
4
|
+
* Returns the canonical row shape that the rest of polygram's
|
|
5
|
+
* pipeline (recordInbound → filterAttachments → downloadAttachments
|
|
6
|
+
* → buildAttachmentTags) consumes. Pure function: no DB, no fs,
|
|
7
|
+
* no network.
|
|
8
|
+
*
|
|
9
|
+
* Media-group bundling: when polygram pre-merges several
|
|
10
|
+
* siblings sharing a `media_group_id` into a single message, the
|
|
11
|
+
* merged attachment list lives at `msg._mergedAttachments`; we
|
|
12
|
+
* return it verbatim. Otherwise the per-field extractors run.
|
|
13
|
+
*
|
|
14
|
+
* Auto-generated names use `shortFileTag(file_unique_id)` —
|
|
15
|
+
* file_unique_id is stable per file across sessions, so a 6-char
|
|
16
|
+
* prefix gives stable names that survive media-group reassignment.
|
|
17
|
+
* Falls back to msg.message_id when file_unique_id is missing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 8-char filesystem-safe handle from a Telegram file_unique_id.
|
|
24
|
+
* Falls back to fallback (typically msg.message_id) when no
|
|
25
|
+
* unique id is available.
|
|
26
|
+
*/
|
|
27
|
+
function shortFileTag(fileUniqueId, fallback) {
|
|
28
|
+
if (fileUniqueId) {
|
|
29
|
+
return String(fileUniqueId).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 8)
|
|
30
|
+
|| String(fallback);
|
|
31
|
+
}
|
|
32
|
+
return String(fallback);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractAttachments(msg) {
|
|
36
|
+
// Media-group bundling: pre-merged list takes precedence.
|
|
37
|
+
if (Array.isArray(msg._mergedAttachments)) return msg._mergedAttachments;
|
|
38
|
+
|
|
39
|
+
const items = [];
|
|
40
|
+
if (msg.document) {
|
|
41
|
+
const d = msg.document;
|
|
42
|
+
items.push({
|
|
43
|
+
file_id: d.file_id,
|
|
44
|
+
file_unique_id: d.file_unique_id,
|
|
45
|
+
name: d.file_name || `document-${shortFileTag(d.file_unique_id, msg.message_id)}`,
|
|
46
|
+
mime_type: d.mime_type || 'application/octet-stream',
|
|
47
|
+
size: d.file_size || 0,
|
|
48
|
+
kind: 'document',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (msg.photo && msg.photo.length > 0) {
|
|
52
|
+
const largest = msg.photo[msg.photo.length - 1];
|
|
53
|
+
items.push({
|
|
54
|
+
file_id: largest.file_id,
|
|
55
|
+
file_unique_id: largest.file_unique_id,
|
|
56
|
+
name: `photo-${shortFileTag(largest.file_unique_id, msg.message_id)}.jpg`,
|
|
57
|
+
mime_type: 'image/jpeg',
|
|
58
|
+
size: largest.file_size || 0,
|
|
59
|
+
kind: 'photo',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (msg.voice) {
|
|
63
|
+
items.push({
|
|
64
|
+
file_id: msg.voice.file_id,
|
|
65
|
+
file_unique_id: msg.voice.file_unique_id,
|
|
66
|
+
name: `voice-${shortFileTag(msg.voice.file_unique_id, msg.message_id)}.ogg`,
|
|
67
|
+
mime_type: msg.voice.mime_type || 'audio/ogg',
|
|
68
|
+
size: msg.voice.file_size || 0,
|
|
69
|
+
kind: 'voice',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (msg.audio) {
|
|
73
|
+
const a = msg.audio;
|
|
74
|
+
items.push({
|
|
75
|
+
file_id: a.file_id,
|
|
76
|
+
file_unique_id: a.file_unique_id,
|
|
77
|
+
name: a.file_name || `audio-${shortFileTag(a.file_unique_id, msg.message_id)}.mp3`,
|
|
78
|
+
mime_type: a.mime_type || 'audio/mpeg',
|
|
79
|
+
size: a.file_size || 0,
|
|
80
|
+
kind: 'audio',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (msg.video) {
|
|
84
|
+
const v = msg.video;
|
|
85
|
+
items.push({
|
|
86
|
+
file_id: v.file_id,
|
|
87
|
+
file_unique_id: v.file_unique_id,
|
|
88
|
+
name: v.file_name || `video-${shortFileTag(v.file_unique_id, msg.message_id)}.mp4`,
|
|
89
|
+
mime_type: v.mime_type || 'video/mp4',
|
|
90
|
+
size: v.file_size || 0,
|
|
91
|
+
kind: 'video',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return items;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { extractAttachments, shortFileTag };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron / IPC `send` handler — wraps tg() for external callers
|
|
3
|
+
* (cron jobs, CLI scripts) that talk to polygram via the Unix
|
|
4
|
+
* socket IPC.
|
|
5
|
+
*
|
|
6
|
+
* Allowlist: only non-destructive Telegram Bot API methods. cron
|
|
7
|
+
* has no business calling deleteMessage / banChatMember etc.
|
|
8
|
+
*
|
|
9
|
+
* Cross-bot guard: chat_id MUST belong to this bot (after
|
|
10
|
+
* filterConfigToBot, config.chats only contains our chats — so
|
|
11
|
+
* lookup-by-chat-id naturally enforces ownership).
|
|
12
|
+
*
|
|
13
|
+
* File-param validation: validateIpcFileParam catches the most
|
|
14
|
+
* common upload-shape mistakes before Telegram rejects with a
|
|
15
|
+
* confusing error.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const { validateIpcFileParam } = require('../ipc/file-validator');
|
|
21
|
+
|
|
22
|
+
// Allowed Telegram Bot API methods. Broader than sendMessage to
|
|
23
|
+
// cover receipts, error reports, quick replies. Deliberately
|
|
24
|
+
// excludes destructive ops.
|
|
25
|
+
const IPC_SEND_ALLOWED_METHODS = new Set([
|
|
26
|
+
'sendMessage',
|
|
27
|
+
'sendPhoto',
|
|
28
|
+
'sendDocument',
|
|
29
|
+
'sendSticker',
|
|
30
|
+
'sendChatAction',
|
|
31
|
+
'editMessageText',
|
|
32
|
+
'setMessageReaction',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function createHandleSendOverIpc({ config, bot, tg, botName } = {}) {
|
|
36
|
+
return async function handleSendOverIpc(req) {
|
|
37
|
+
const { method, params = {}, source } = req || {};
|
|
38
|
+
if (!method) throw new Error('method required');
|
|
39
|
+
if (!IPC_SEND_ALLOWED_METHODS.has(method)) {
|
|
40
|
+
throw new Error(`method not allowed: ${method}`);
|
|
41
|
+
}
|
|
42
|
+
if (!bot) throw new Error('bot process not ready');
|
|
43
|
+
|
|
44
|
+
// Enforce: chat_id must belong to this bot (no cross-bot sends).
|
|
45
|
+
const chatId = params.chat_id != null ? String(params.chat_id) : null;
|
|
46
|
+
if (chatId && !config.chats[chatId]) {
|
|
47
|
+
throw new Error(`chat not owned by ${botName}: ${chatId}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// editMessageText accepts (chat_id+message_id) OR
|
|
51
|
+
// inline_message_id as the addressing mode. The chat_id branch
|
|
52
|
+
// above gates the first; inline_message_id has no owner field
|
|
53
|
+
// a cron caller can be checked against, so a malicious or buggy
|
|
54
|
+
// caller could edit any inline message system-wide. Polygram
|
|
55
|
+
// never sends inline-mode messages (we have no inline-bot
|
|
56
|
+
// handlers), so reject inline_message_id outright.
|
|
57
|
+
if (params.inline_message_id != null) {
|
|
58
|
+
throw new Error('inline_message_id editing not supported by polygram IPC');
|
|
59
|
+
}
|
|
60
|
+
// editMessageText with neither chat_id nor inline_message_id
|
|
61
|
+
// would silently succeed-with-error from Telegram; fail fast.
|
|
62
|
+
if (method === 'editMessageText' && !chatId) {
|
|
63
|
+
throw new Error('editMessageText requires chat_id');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fileParamErr = validateIpcFileParam(method, params);
|
|
67
|
+
if (fileParamErr) throw new Error(fileParamErr);
|
|
68
|
+
|
|
69
|
+
const sendRes = await tg(bot, method, params, {
|
|
70
|
+
source: source || 'ipc',
|
|
71
|
+
botName,
|
|
72
|
+
});
|
|
73
|
+
return { result: sendRes };
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
createHandleSendOverIpc,
|
|
79
|
+
IPC_SEND_ALLOWED_METHODS,
|
|
80
|
+
};
|