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.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/{agent-loader.js → agents/loader.js} +6 -8
  3. package/lib/{approvals.js → approvals/store.js} +28 -5
  4. package/lib/{approval-ui.js → approvals/ui.js} +1 -17
  5. package/lib/config.js +121 -0
  6. package/lib/{error-classify.js → error/classify.js} +25 -34
  7. package/lib/handlers/abort.js +89 -0
  8. package/lib/handlers/approvals.js +361 -0
  9. package/lib/handlers/autosteer.js +94 -0
  10. package/lib/handlers/config-callback.js +118 -0
  11. package/lib/handlers/config-ui.js +104 -0
  12. package/lib/handlers/dispatcher.js +263 -0
  13. package/lib/handlers/download.js +182 -0
  14. package/lib/handlers/extract-attachments.js +97 -0
  15. package/lib/handlers/ipc-send.js +80 -0
  16. package/lib/handlers/poll.js +140 -0
  17. package/lib/handlers/record-inbound.js +88 -0
  18. package/lib/handlers/slash-commands.js +319 -0
  19. package/lib/handlers/voice.js +107 -0
  20. package/lib/pm-interface.js +27 -29
  21. package/lib/sdk/build-options.js +177 -0
  22. package/lib/sdk/callbacks.js +213 -0
  23. package/lib/{process-manager-sdk.js → sdk/process-manager.js} +19 -31
  24. package/lib/{telegram.js → telegram/api.js} +2 -2
  25. package/lib/{telegram-prompt.js → telegram/display-hint.js} +0 -14
  26. package/lib/{stream-reply.js → telegram/streamer.js} +4 -4
  27. package/package.json +2 -3
  28. package/polygram.js +347 -2581
  29. package/scripts/doctor.js +1 -1
  30. package/scripts/ipc-smoke.js +1 -10
  31. package/bin/approval-hook.js +0 -113
  32. package/lib/approval-waiters.js +0 -201
  33. package/lib/pm-router.js +0 -201
  34. package/lib/process-manager.js +0 -806
  35. /package/lib/{auto-resume.js → db/auto-resume.js} +0 -0
  36. /package/lib/{inbox.js → db/inbox.js} +0 -0
  37. /package/lib/{pairings.js → db/pairings.js} +0 -0
  38. /package/lib/{replay-window.js → db/replay-window.js} +0 -0
  39. /package/lib/{sent-cache.js → db/sent-cache.js} +0 -0
  40. /package/lib/{sessions.js → db/sessions.js} +0 -0
  41. /package/lib/{net-errors.js → error/net.js} +0 -0
  42. /package/lib/{ipc-client.js → ipc/client.js} +0 -0
  43. /package/lib/{ipc-file-validator.js → ipc/file-validator.js} +0 -0
  44. /package/lib/{ipc-server.js → ipc/server.js} +0 -0
  45. /package/lib/{telegram-chunk.js → telegram/chunk.js} +0 -0
  46. /package/lib/{deliver.js → telegram/deliver.js} +0 -0
  47. /package/lib/{telegram-format.js → telegram/format.js} +0 -0
  48. /package/lib/{parse-response.js → telegram/parse.js} +0 -0
  49. /package/lib/{status-reactions.js → telegram/reactions.js} +0 -0
  50. /package/lib/{typing-indicator.js → telegram/typing.js} +0 -0
  51. /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
+ };