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.
@@ -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
- 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
- }
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
- // 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)`);
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
- 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)`);
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
- logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${buf.length} bytes) → ${localPath}`);
171
+
129
172
  dbWrite(() => db.markAttachmentDownloaded(att.id, {
130
- local_path: localPath, size_bytes: att.size_bytes || buf.length,
173
+ local_path: localPath, size_bytes: att.size_bytes || size,
131
174
  }), `markAttachmentDownloaded ${att.id}`);
132
- return { ...att, path: localPath, size: att.size_bytes || buf.length, error: null };
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: dispatcher has ALREADY delivered text to Telegram on each
1270
- // reply tool call (incremental real-time UX is the channels delivery
1271
- // model). polygram.js's post-pm.send pipeline must short-circuit its
1272
- // streamer.finalize / deliverReplies branch otherwise every turn
1273
- // delivers twice. Logging + DB transcript still use result.text.
1274
- alreadyDelivered: true,
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 (TODO) will use this as the authoritative turn-end
1745
- // signal with stopGraceMs. For now: pass through as 'stop-hook'
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.14",
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
- const params = {
1002
- chat_id: chatId,
1003
- message_id: msg.message_id,
1004
- reaction: emoji ? [{ type: 'emoji', emoji }] : [],
1005
- };
1006
- await tg(bot, 'setMessageReaction', params,
1007
- { source: 'status-reaction', botName: BOT_NAME });
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
- timeoutSeconds: 60,
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;