polygram 0.12.0-rc.14 → 0.12.0-rc.16
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/handlers/download.js +101 -58
- package/lib/process/cli-process.js +64 -12
- package/lib/telegram/album-reactions.js +50 -0
- package/package.json +1 -1
- package/polygram.js +24 -8
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 } = require('../attachments');
|
|
27
|
+
const { MAX_FILE_BYTES, resolveFileCaps } = require('../attachments');
|
|
28
28
|
|
|
29
29
|
const ATTACHMENT_DOWNLOAD_CONCURRENCY_DEFAULT = 6;
|
|
30
30
|
|
|
@@ -60,76 +60,119 @@ function createDownloadAttachments({
|
|
|
60
60
|
} catch { /* fall through to refetch */ }
|
|
61
61
|
}
|
|
62
62
|
try {
|
|
63
|
+
// Inbound per-file cap is BACKEND-derived: 20 MB on cloud Telegram
|
|
64
|
+
// (Telegram's own getFile ceiling), 2 GB with the local Bot API server.
|
|
65
|
+
// rc.15: previously hardcoded to MAX_FILE_BYTES (20 MB), which rejected
|
|
66
|
+
// large lossless tracks even when the local server could handle them.
|
|
67
|
+
const cap = resolveFileCaps({ localApi: !!config.bot?.apiRoot }).inBytes;
|
|
68
|
+
|
|
63
69
|
const fileInfo = await bot.api.getFile(att.file_id);
|
|
64
70
|
if (!fileInfo?.file_path) throw new Error('no file_path from getFile');
|
|
65
|
-
|
|
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
|
-
}
|
|
71
|
+
|
|
106
72
|
const safeName = sanitizeFilename(att.name);
|
|
107
73
|
// Embed file_unique_id so two attachments with the same msg_id+name
|
|
108
74
|
// (album, resend) can't silently overwrite each other.
|
|
109
75
|
const uniq = att.file_unique_id ? `-${att.file_unique_id}` : '';
|
|
110
76
|
const localName = `${msg.message_id}${uniq}-${safeName}`;
|
|
111
77
|
const localPath = path.join(chatDir, localName);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
78
|
+
|
|
79
|
+
let size;
|
|
80
|
+
|
|
81
|
+
if (path.isAbsolute(fileInfo.file_path)) {
|
|
82
|
+
// ── Local Bot API server ────────────────────────────────────────
|
|
83
|
+
// rc.15: in `--local` mode getFile returns a LOCAL ABSOLUTE PATH —
|
|
84
|
+
// the server has already downloaded the file to its own disk. The
|
|
85
|
+
// previous code built a cloud URL (https://api.telegram.org/file/...)
|
|
86
|
+
// and HTTP-fetched it, which is nonsensical for a local path and
|
|
87
|
+
// failed every inbound file once apiRoot was set. Instead, link the
|
|
88
|
+
// file into the inbox directly (no HTTP, no buffering a 2 GB file
|
|
89
|
+
// through RAM). A hardlink is instant and shares the inode, so it
|
|
90
|
+
// survives the server pruning its own copy; fall back to a byte copy
|
|
91
|
+
// across filesystems.
|
|
92
|
+
const srcStat = fs.statSync(fileInfo.file_path);
|
|
93
|
+
if (srcStat.size > cap) {
|
|
94
|
+
throw new Error(`file ${srcStat.size} exceeds per-file cap ${cap}`);
|
|
95
|
+
}
|
|
96
|
+
if (fs.existsSync(localPath)) {
|
|
97
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
|
|
98
|
+
} else {
|
|
99
|
+
try {
|
|
100
|
+
fs.linkSync(fileInfo.file_path, localPath);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e.code === 'EEXIST') {
|
|
103
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
|
|
104
|
+
} else if (e.code === 'EXDEV') {
|
|
105
|
+
fs.copyFileSync(fileInfo.file_path, localPath); // cross-device fallback
|
|
106
|
+
} else {
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
size = srcStat.size;
|
|
112
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${size} bytes, local-api) → ${localPath}`);
|
|
117
113
|
} else {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
114
|
+
// ── Cloud Telegram ──────────────────────────────────────────────
|
|
115
|
+
// getFile returns a RELATIVE path; download it over HTTPS with the
|
|
116
|
+
// three-layer size guard (header → streaming accumulator → final).
|
|
117
|
+
const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
|
|
118
|
+
const res = await fetchImpl(url);
|
|
119
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
120
|
+
const cl = parseInt(res.headers.get('content-length') || '0', 10);
|
|
121
|
+
if (cl > cap) {
|
|
122
|
+
throw new Error(`content-length ${cl} exceeds per-file cap ${cap}`);
|
|
126
123
|
}
|
|
124
|
+
let total = 0;
|
|
125
|
+
const chunks = [];
|
|
126
|
+
if (res.body && typeof res.body.getReader === 'function') {
|
|
127
|
+
const reader = res.body.getReader();
|
|
128
|
+
while (true) {
|
|
129
|
+
const { done, value } = await reader.read();
|
|
130
|
+
if (done) break;
|
|
131
|
+
total += value.byteLength;
|
|
132
|
+
if (total > cap) {
|
|
133
|
+
try { await reader.cancel(); } catch {}
|
|
134
|
+
throw new Error(`stream ${total}+ bytes exceeds per-file cap ${cap}`);
|
|
135
|
+
}
|
|
136
|
+
chunks.push(value);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// Fallback for runtimes without WHATWG streams (shouldn't fire
|
|
140
|
+
// on Node 22+).
|
|
141
|
+
const ab = await res.arrayBuffer();
|
|
142
|
+
if (ab.byteLength > cap) {
|
|
143
|
+
throw new Error(`body ${ab.byteLength} bytes exceeds per-file cap ${cap}`);
|
|
144
|
+
}
|
|
145
|
+
chunks.push(new Uint8Array(ab));
|
|
146
|
+
total = ab.byteLength;
|
|
147
|
+
}
|
|
148
|
+
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
149
|
+
if (buf.length > cap) {
|
|
150
|
+
throw new Error(`body ${buf.length} bytes exceeds per-file cap ${cap}`);
|
|
151
|
+
}
|
|
152
|
+
// Atomic write: temp file + rename. A crash mid-write leaves a
|
|
153
|
+
// .tmp.* file (swept later) rather than a truncated canonical
|
|
154
|
+
// file the EEXIST dedup branch would happily serve next time.
|
|
155
|
+
if (fs.existsSync(localPath)) {
|
|
156
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
|
|
157
|
+
} else {
|
|
158
|
+
const tmpPath = `${localPath}.tmp.${process.pid}.${Date.now()}`;
|
|
159
|
+
try {
|
|
160
|
+
fs.writeFileSync(tmpPath, buf, { flag: 'wx' });
|
|
161
|
+
fs.renameSync(tmpPath, localPath);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
164
|
+
if (e.code !== 'EEXIST') throw e;
|
|
165
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
size = buf.length;
|
|
169
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${size} bytes) → ${localPath}`);
|
|
127
170
|
}
|
|
128
|
-
|
|
171
|
+
|
|
129
172
|
dbWrite(() => db.markAttachmentDownloaded(att.id, {
|
|
130
|
-
local_path: localPath, size_bytes: att.size_bytes ||
|
|
173
|
+
local_path: localPath, size_bytes: att.size_bytes || size,
|
|
131
174
|
}), `markAttachmentDownloaded ${att.id}`);
|
|
132
|
-
return { ...att, path: localPath, size: att.size_bytes ||
|
|
175
|
+
return { ...att, path: localPath, size: att.size_bytes || size, error: null };
|
|
133
176
|
} catch (err) {
|
|
134
177
|
// Don't drop the attachment silently — push it through with the
|
|
135
178
|
// failure noted. buildAttachmentTags renders this as
|
|
@@ -1249,6 +1249,7 @@ class CliProcess extends Process {
|
|
|
1249
1249
|
if (pending.hardTimer) clearTimeout(pending.hardTimer);
|
|
1250
1250
|
if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
|
|
1251
1251
|
if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer);
|
|
1252
|
+
const hadReplyToolCalls = pending.replies.length > 0;
|
|
1252
1253
|
let text = pending.replies.join('\n\n');
|
|
1253
1254
|
// 0.12 Phase 1.7 fallback: if no reply tool calls landed (claude ended
|
|
1254
1255
|
// the turn without calling mcp__polygram-bridge__reply), use the Stop
|
|
@@ -1266,12 +1267,14 @@ class CliProcess extends Process {
|
|
|
1266
1267
|
// to appear free in dashboards.
|
|
1267
1268
|
const result = {
|
|
1268
1269
|
text,
|
|
1269
|
-
// Review F#2:
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
|
|
1270
|
+
// Review F#2: when claude used reply tool calls, the dispatcher ALREADY
|
|
1271
|
+
// delivered that text to Telegram incrementally — polygram.js must
|
|
1272
|
+
// short-circuit its deliverReplies branch or every turn delivers twice.
|
|
1273
|
+
// BUT a turn finalized via the Stop fallback (no reply tool calls — the
|
|
1274
|
+
// stuck-turn case) has delivered NOTHING; marking it alreadyDelivered
|
|
1275
|
+
// would resolve the turn silently and the user still sees nothing. So
|
|
1276
|
+
// only claim already-delivered when reply tool calls actually fired.
|
|
1277
|
+
alreadyDelivered: hadReplyToolCalls,
|
|
1275
1278
|
sessionId: this.claudeSessionId,
|
|
1276
1279
|
cost: null, // Channels protocol doesn't expose per-turn cost
|
|
1277
1280
|
duration,
|
|
@@ -1632,6 +1635,22 @@ class CliProcess extends Process {
|
|
|
1632
1635
|
_handleHookEvent(ev) {
|
|
1633
1636
|
if (!ev || typeof ev !== 'object') return;
|
|
1634
1637
|
|
|
1638
|
+
// rc.16 observability: emit once when the FIRST hook event arrives for
|
|
1639
|
+
// this session, confirming the claude→ndjson→tail pipeline is actually
|
|
1640
|
+
// flowing. The 2026-06-02 stuck turn had a session whose hook ndjson was
|
|
1641
|
+
// 0 bytes — claude emitted no hooks polygram could see, so no Stop ever
|
|
1642
|
+
// arrived to finalize the turn. Without this signal that's invisible: a
|
|
1643
|
+
// turn that hangs with NO `cli-hook-stream-live` for its session means the
|
|
1644
|
+
// hook pipeline is dead for it (distinct from "Stop fired but wasn't
|
|
1645
|
+
// acted on", which `cli-turn-resolved-by-stop` now covers).
|
|
1646
|
+
if (!this._sawHookStream) {
|
|
1647
|
+
this._sawHookStream = true;
|
|
1648
|
+
this._logEvent('cli-hook-stream-live', {
|
|
1649
|
+
session_id: this.claudeSessionId,
|
|
1650
|
+
first_event: ev.type,
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1635
1654
|
// 0.12 Phase 1.8 (Finding 0.4.A): per-event lag measurement.
|
|
1636
1655
|
// polygram_received_at_ms is stamped by the helper subprocess at write
|
|
1637
1656
|
// time; subtracting from Date.now() gives the helper-write → tail-emit
|
|
@@ -1740,15 +1759,47 @@ class CliProcess extends Process {
|
|
|
1740
1759
|
return;
|
|
1741
1760
|
}
|
|
1742
1761
|
|
|
1743
|
-
case 'Stop':
|
|
1744
|
-
// Phase 1.7 (
|
|
1745
|
-
|
|
1746
|
-
// event so the resolver in Phase 1.7 can subscribe.
|
|
1747
|
-
this.emit('stop-hook', {
|
|
1762
|
+
case 'Stop': {
|
|
1763
|
+
// 0.12.0 Phase 1.7 (rc.16): Stop is the AUTHORITATIVE turn-end signal.
|
|
1764
|
+
const info = {
|
|
1748
1765
|
stopHookActive: ev.stopHookActive,
|
|
1749
1766
|
lastAssistantMessage: ev.lastAssistantMessage,
|
|
1750
1767
|
backend: this.backend,
|
|
1751
|
-
}
|
|
1768
|
+
};
|
|
1769
|
+
// Turns already resolving via a reply quiet-window consume this via
|
|
1770
|
+
// their per-turn onStop listener (the text-fallback rescue inside
|
|
1771
|
+
// _resolveTurn). Emit first so that path runs synchronously and any
|
|
1772
|
+
// grace-pending turn is finalized + removed before the check below.
|
|
1773
|
+
this.emit('stop-hook', info);
|
|
1774
|
+
|
|
1775
|
+
// THE FIX (2026-06-02 stuck-turn): a turn that ended WITHOUT a reply
|
|
1776
|
+
// tool call has no quiet-window to fire _resolveTurn — pre-fix it hung
|
|
1777
|
+
// until the 30-min wall-clock backstop while the unknown-prompt
|
|
1778
|
+
// watchdog spun. Stop IS the turn-end; resolve the single in-flight
|
|
1779
|
+
// turn now (reply text if any, else last_assistant_message). After the
|
|
1780
|
+
// emit above, a grace-pending turn is already gone, so this only fires
|
|
1781
|
+
// for the no-reply case. Gated on exactly one in-flight turn — Stop
|
|
1782
|
+
// carries no turn_id, so we cannot attribute it when turns are
|
|
1783
|
+
// concurrent (the M1 cross-attribution hazard).
|
|
1784
|
+
if (this.pendingTurns.size === 1) {
|
|
1785
|
+
const [turnId, p] = [...this.pendingTurns.entries()][0];
|
|
1786
|
+
if (!p._stopGracePending) {
|
|
1787
|
+
p._stopHookData = info;
|
|
1788
|
+
this._logEvent('cli-turn-resolved-by-stop', {
|
|
1789
|
+
turn_id: turnId,
|
|
1790
|
+
reply_count: p.replies?.length || 0,
|
|
1791
|
+
via_text_fallback: (p.replies?.length || 0) === 0,
|
|
1792
|
+
session_id: this.claudeSessionId,
|
|
1793
|
+
});
|
|
1794
|
+
this._finalizeTurn(turnId);
|
|
1795
|
+
}
|
|
1796
|
+
} else if (this.pendingTurns.size > 1) {
|
|
1797
|
+
// Can't attribute Stop to one of several concurrent turns — surface
|
|
1798
|
+
// it so a turn that waited for its grace timer (instead of resolving
|
|
1799
|
+
// on Stop) is explained in the events DB.
|
|
1800
|
+
this._logEvent('cli-stop-unattributed', { pending_count: this.pendingTurns.size });
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1752
1803
|
// 0.12.0-rc.13 proactive compaction warning: on turn-end, if enabled
|
|
1753
1804
|
// for this chat and not already warned this climb, sample context
|
|
1754
1805
|
// occupancy from the transcript and warn (propose /compact) BEFORE
|
|
@@ -1758,6 +1809,7 @@ class CliProcess extends Process {
|
|
|
1758
1809
|
this._maybeProactiveCompactionWarn(ev.transcriptPath);
|
|
1759
1810
|
}
|
|
1760
1811
|
return;
|
|
1812
|
+
}
|
|
1761
1813
|
|
|
1762
1814
|
case 'PreCompact':
|
|
1763
1815
|
// 0.12.0-rc.13: auto-compaction is the event that detaches the
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* album-reactions — apply one status reaction to every message of a Telegram
|
|
3
|
+
* album (the anchor + its siblings), so a multi-file send shows the same emoji
|
|
4
|
+
* on each item instead of only the first.
|
|
5
|
+
*
|
|
6
|
+
* Background: Telegram delivers an album as N separate messages sharing a
|
|
7
|
+
* media_group_id; polygram coalesces them into ONE turn anchored on the first.
|
|
8
|
+
* The status reactor therefore only ever reacted to that anchor, leaving the
|
|
9
|
+
* sibling files with no visible reaction (the rc.16 observation). This mirrors
|
|
10
|
+
* the reactor's emoji onto the siblings.
|
|
11
|
+
*
|
|
12
|
+
* Semantics:
|
|
13
|
+
* - The ANCHOR (first id) is awaited so a failure surfaces to the reactor's
|
|
14
|
+
* own error handling (same as the single-message path).
|
|
15
|
+
* - SIBLINGS are best-effort: a failure on one must not drop the anchor's
|
|
16
|
+
* reaction or the other siblings (and must not throw — reactions are
|
|
17
|
+
* cosmetic). They also can't share the anchor's fate of being retried.
|
|
18
|
+
* - Calls are sequential to respect Telegram's setMessageReaction rate limit
|
|
19
|
+
* (~5/s/chat) — an album is ≤10 items so this stays well within budget.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {Function} opts.tg async (bot, method, params, meta) => any
|
|
27
|
+
* @param {*} opts.bot
|
|
28
|
+
* @param {string} opts.chatId
|
|
29
|
+
* @param {number[]} opts.msgIds [anchor, ...siblings] — anchor first
|
|
30
|
+
* @param {string|null} opts.emoji emoji to set, or null/'' to clear
|
|
31
|
+
* @param {string} [opts.botName]
|
|
32
|
+
*/
|
|
33
|
+
async function applyReactionToMessages({ tg, bot, chatId, msgIds, emoji, botName } = {}) {
|
|
34
|
+
const reaction = emoji ? [{ type: 'emoji', emoji }] : [];
|
|
35
|
+
const ids = Array.isArray(msgIds) ? msgIds : [];
|
|
36
|
+
for (let i = 0; i < ids.length; i++) {
|
|
37
|
+
const params = { chat_id: chatId, message_id: ids[i], reaction };
|
|
38
|
+
const meta = {
|
|
39
|
+
source: i === 0 ? 'status-reaction' : 'status-reaction-album-sibling',
|
|
40
|
+
botName,
|
|
41
|
+
};
|
|
42
|
+
if (i === 0) {
|
|
43
|
+
await tg(bot, 'setMessageReaction', params, meta); // anchor: surface failure
|
|
44
|
+
} else {
|
|
45
|
+
await tg(bot, 'setMessageReaction', params, meta).catch(() => {}); // siblings: best-effort
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { applyReactionToMessages };
|
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.16",
|
|
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
|
@@ -97,6 +97,7 @@ const { startTyping } = require('./lib/telegram/typing');
|
|
|
97
97
|
// consumer is lib/handlers/download.js.
|
|
98
98
|
const { createReactionManager, classifyToolName } = require('./lib/telegram/reactions');
|
|
99
99
|
const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
|
|
100
|
+
const { applyReactionToMessages } = require('./lib/telegram/album-reactions');
|
|
100
101
|
const { classify: classifyError, detectWedgedSessionError, isTransientHttpError } = require('./lib/error/classify');
|
|
101
102
|
const { createAutoResumeTracker, isAutoResumable } = require('./lib/db/auto-resume');
|
|
102
103
|
const { resolveReplayWindowMs } = require('./lib/db/replay-window');
|
|
@@ -998,13 +999,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
998
999
|
const availableEmojis = await getReactionAllowlist(bot, chatId);
|
|
999
1000
|
const reactor = createReactionManager({
|
|
1000
1001
|
apply: async (emoji) => {
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
await
|
|
1007
|
-
|
|
1002
|
+
// rc.16: mirror the reaction onto album siblings too, so a multi-file
|
|
1003
|
+
// send shows the same status emoji on EVERY item, not just the anchor.
|
|
1004
|
+
// For a normal single message, _albumSiblingMsgIds is undefined and this
|
|
1005
|
+
// is exactly the prior single setMessageReaction. Anchor is awaited
|
|
1006
|
+
// (failure surfaces to the reactor); siblings are best-effort.
|
|
1007
|
+
await applyReactionToMessages({
|
|
1008
|
+
tg, bot, chatId,
|
|
1009
|
+
msgIds: [msg.message_id, ...(msg._albumSiblingMsgIds || [])],
|
|
1010
|
+
emoji,
|
|
1011
|
+
botName: BOT_NAME,
|
|
1012
|
+
});
|
|
1008
1013
|
},
|
|
1009
1014
|
availableEmojis,
|
|
1010
1015
|
logError: (m) => console.error(`[${label}] ${m}`),
|
|
@@ -1698,7 +1703,14 @@ function createBot(token) {
|
|
|
1698
1703
|
const apiRoot = config.bot?.apiRoot;
|
|
1699
1704
|
const bot = new Bot(token, {
|
|
1700
1705
|
client: {
|
|
1701
|
-
|
|
1706
|
+
// rc.15: with the local Bot API server, getFile DOWNLOADS the file
|
|
1707
|
+
// synchronously (server fetches it from Telegram's DC, then responds) —
|
|
1708
|
+
// a large lossless WAV can take >60s, so the cloud-tuned 60s timeout
|
|
1709
|
+
// fired before the download finished (the file still landed on the
|
|
1710
|
+
// server's disk, but polygram's getFile call already errored). The
|
|
1711
|
+
// local server is localhost, so non-download calls stay fast; the
|
|
1712
|
+
// higher ceiling only matters for big getFile downloads.
|
|
1713
|
+
timeoutSeconds: apiRoot ? 180 : 60,
|
|
1702
1714
|
...(apiRoot ? { apiRoot } : {}),
|
|
1703
1715
|
},
|
|
1704
1716
|
});
|
|
@@ -1885,6 +1897,10 @@ function createBot(token) {
|
|
|
1885
1897
|
}
|
|
1886
1898
|
|
|
1887
1899
|
const synthetic = { ...primary, _mergedAttachments: merged };
|
|
1900
|
+
// rc.16: carry the album sibling msg_ids so the status reactor can mirror
|
|
1901
|
+
// its emoji onto every item (not just the anchor) — see the reactor
|
|
1902
|
+
// `apply` closure + lib/telegram/album-reactions.js.
|
|
1903
|
+
if (siblingMsgIds.length) synthetic._albumSiblingMsgIds = siblingMsgIds;
|
|
1888
1904
|
// Carry the primary's text verbatim (dispatchRegularMessage re-cleans
|
|
1889
1905
|
// the mention). Caption → text so downstream sees it uniformly.
|
|
1890
1906
|
if (!synthetic.text && synthetic.caption) synthetic.text = synthetic.caption;
|