polygram 0.7.0 → 0.7.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.7.0",
4
+ "version": "0.7.1",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
package/README.md CHANGED
@@ -364,7 +364,7 @@ foreign-chat clicks are rejected. Default-deny on IPC error.
364
364
  ## Development
365
365
 
366
366
  ```bash
367
- npm test # 619 tests, 153 suites, node:test, no external services
367
+ npm test # 638 tests, 158 suites, node:test, no external services
368
368
  npm run coverage # native test coverage (Node 22+, no devDeps)
369
369
  npm start -- --bot my-bot
370
370
  npm run split-db -- --config config.json --dry-run
package/lib/announces.js CHANGED
@@ -12,28 +12,102 @@
12
12
  * or `config.chats.<id>.announceSubagents`), so existing chats see
13
13
  * no behavior change.
14
14
  *
15
- * Note: this is the minimal MVP of OpenClaw's full subagent-announce
16
- * queue (which has debounce, drop policies, multi-channel routing).
17
- * Polygram's "subagents" are in-process (Claude Code Task tool), so
18
- * the announce path is just a one-shot informational sendMessage.
15
+ * 0.7.1 redesign: factory-based with split read/write predicates
16
+ * (canAnnounce / markAnnounced) and lazy GC. Pre-0.7.1 had a
17
+ * module-scoped Map and a mutate-on-check `shouldAnnounce` predicate
18
+ * both anti-patterns flagged in design review. The free-function
19
+ * API (`shouldAnnounce`, `announce`) is preserved for back-compat,
20
+ * delegating to a default singleton.
19
21
  */
20
22
 
21
23
  const SUBAGENT_DEBOUNCE_MS = 30_000;
22
24
 
23
25
  /**
24
- * Per-chat debounce so a turn that spawns 5 subagents back-to-back
25
- * doesn't post 5 announces. Module-scoped Map keyed by chatId →
26
- * timestamp of last announce.
26
+ * Per-chat debounce tracker. Returns:
27
+ * - canAnnounce(chatId): true if this chat hasn't announced within
28
+ * the debounce window. Pure read, NO mutation — safe for
29
+ * speculative checks.
30
+ * - markAnnounced(chatId): records `now` as the last announce time
31
+ * for this chat. Caller invokes after a successful send.
32
+ * - sweep(): drops entries older than `2 * debounceMs`. Called lazily
33
+ * on every canAnnounce check past a soft threshold.
34
+ * - size(): for tests / diagnostics.
35
+ * - clear(): for test isolation.
27
36
  */
28
- const lastAnnounceByChat = new Map();
37
+ function createAnnouncer({
38
+ debounceMs = SUBAGENT_DEBOUNCE_MS,
39
+ clock = Date.now,
40
+ sweepThreshold = 1000,
41
+ } = {}) {
42
+ const lastAnnounceByChat = new Map();
29
43
 
44
+ function key(chatId) { return String(chatId); }
45
+
46
+ function sweep() {
47
+ const cutoff = clock() - 2 * debounceMs;
48
+ for (const [k, ts] of lastAnnounceByChat) {
49
+ if (ts < cutoff) lastAnnounceByChat.delete(k);
50
+ }
51
+ }
52
+
53
+ function canAnnounce(chatId) {
54
+ if (lastAnnounceByChat.size > sweepThreshold) sweep();
55
+ const prev = lastAnnounceByChat.get(key(chatId));
56
+ return prev == null || (clock() - prev) >= debounceMs;
57
+ }
58
+
59
+ function markAnnounced(chatId) {
60
+ lastAnnounceByChat.set(key(chatId), clock());
61
+ }
62
+
63
+ return {
64
+ canAnnounce, markAnnounced, sweep,
65
+ get size() { return lastAnnounceByChat.size; },
66
+ clear() { lastAnnounceByChat.clear(); },
67
+ };
68
+ }
69
+
70
+ // Default per-process state for the back-compat free-function API.
71
+ // Pre-0.7.1, this was the only API. Long-running daemons should still
72
+ // prefer createAnnouncer() for tests / multi-bot isolation, but
73
+ // polygram.js's single-bot-per-process model means the singleton works
74
+ // fine for the production path. The Map is pruned lazily inside
75
+ // shouldAnnounce when it grows past the sweep threshold.
76
+ const _defaultLastAnnouncements = new Map();
77
+ const _DEFAULT_SWEEP_THRESHOLD = 1000;
78
+
79
+ /**
80
+ * Back-compat: pre-0.7.1 callers used `shouldAnnounce(chatId, now?,
81
+ * debounceMs?)` which is a "predicate that mutates" — call site is
82
+ * `if (shouldAnnounce(id)) await sendAnnounce()`. The mutation happens
83
+ * eagerly. Preserved verbatim for callers; new code should use
84
+ * `createAnnouncer()` and the explicit canAnnounce/markAnnounced split.
85
+ *
86
+ * 0.7.1: added lazy sweep so the Map doesn't grow unbounded over a
87
+ * multi-week-uptime daemon.
88
+ */
30
89
  function shouldAnnounce(chatId, now = Date.now(), debounceMs = SUBAGENT_DEBOUNCE_MS) {
31
- const prev = lastAnnounceByChat.get(String(chatId));
90
+ if (_defaultLastAnnouncements.size > _DEFAULT_SWEEP_THRESHOLD) {
91
+ const cutoff = now - 2 * debounceMs;
92
+ for (const [k, ts] of _defaultLastAnnouncements) {
93
+ if (ts < cutoff) _defaultLastAnnouncements.delete(k);
94
+ }
95
+ }
96
+ const key = String(chatId);
97
+ const prev = _defaultLastAnnouncements.get(key);
32
98
  if (prev != null && now - prev < debounceMs) return false;
33
- lastAnnounceByChat.set(String(chatId), now);
99
+ _defaultLastAnnouncements.set(key, now);
34
100
  return true;
35
101
  }
36
102
 
103
+ /**
104
+ * Reset the default singleton state (for tests). Not exported in
105
+ * production docs.
106
+ */
107
+ function _resetDefaultAnnouncerForTests() {
108
+ _defaultLastAnnouncements.clear();
109
+ }
110
+
37
111
  /**
38
112
  * Send a plain-text announce (no markdown processing, no reply linkage).
39
113
  * Caller passes `tg(bot, method, params, meta)` as `send` so we don't
@@ -67,4 +141,10 @@ async function announce({
67
141
  }
68
142
  }
69
143
 
70
- module.exports = { announce, shouldAnnounce, SUBAGENT_DEBOUNCE_MS };
144
+ module.exports = {
145
+ announce,
146
+ shouldAnnounce,
147
+ createAnnouncer,
148
+ SUBAGENT_DEBOUNCE_MS,
149
+ _resetDefaultAnnouncerForTests,
150
+ };
package/lib/deliver.js CHANGED
@@ -37,8 +37,13 @@ async function deliverReplies({
37
37
  logger = console,
38
38
  }) {
39
39
  if (!Array.isArray(chunks) || chunks.length === 0) {
40
- return { sent: [], failed: [] };
40
+ return { sent: [], failed: [], results: [] };
41
41
  }
42
+ // 0.7.1: results[] preserves correspondence with chunks[] — results[i]
43
+ // describes what happened to chunks[i]. sent[]/failed[] are projections
44
+ // for back-compat with callers that already use them; they no longer
45
+ // ambiguously mean "the i-th success/failure" vs "chunk i's outcome".
46
+ const results = [];
42
47
  const sent = [];
43
48
  const failed = [];
44
49
  for (let i = 0; i < chunks.length; i++) {
@@ -56,14 +61,18 @@ async function deliverReplies({
56
61
  try {
57
62
  const res = await send(bot, 'sendMessage', params, meta);
58
63
  const msgId = res?.message_id ?? null;
64
+ results.push({ index: i, status: 'ok', messageId: msgId });
59
65
  sent.push(msgId);
60
66
  } catch (err) {
61
67
  logger.error?.(`[deliver] chunk ${i + 1}/${chunks.length} failed: ${err.message}`);
68
+ results.push({ index: i, status: 'fail', error: err.message });
62
69
  failed.push({ index: i, error: err.message });
63
- // Keep going — partial delivery is better than total loss.
70
+ // Keep going — partial delivery is better than total loss. Caller
71
+ // should inspect failed.length and surface a warning to the user
72
+ // (see polygram.js handleMessage's stream-redeliver event log).
64
73
  }
65
74
  }
66
- return { sent, failed };
75
+ return { sent, failed, results };
67
76
  }
68
77
 
69
78
  module.exports = { deliverReplies };
package/lib/sent-cache.js CHANGED
@@ -17,13 +17,54 @@
17
17
 
18
18
  const TTL_MS = 24 * 60 * 60 * 1000;
19
19
  const CLEANUP_THRESHOLD = 100;
20
+ // 0.7.1: hard cap on per-chat Map size. CLEANUP_THRESHOLD only drops
21
+ // EXPIRED entries — if a busy chat sends >100 fresh messages within
22
+ // 24h, GC finds nothing to drop and the inner Map grows unbounded.
23
+ // Cap evicts oldest entries past this point regardless of TTL.
24
+ const MAX_PER_CHAT = 500;
25
+ // 0.7.1: outer Map sweep — drop chats whose inner Map has been empty
26
+ // long enough that we're sure no live caller still references it.
27
+ const OUTER_SWEEP_THRESHOLD = 1000;
20
28
 
21
- function createSentCache() {
29
+ function createSentCache({
30
+ ttlMs = TTL_MS,
31
+ cleanupThreshold = CLEANUP_THRESHOLD,
32
+ maxPerChat = MAX_PER_CHAT,
33
+ outerSweepThreshold = OUTER_SWEEP_THRESHOLD,
34
+ clock = Date.now,
35
+ } = {}) {
22
36
  // chatKey → Map<msgId, ts>
23
37
  const sentMessages = new Map();
24
38
 
25
39
  function chatKey(chatId) { return String(chatId); }
26
40
 
41
+ function gcInner(entry) {
42
+ const cutoff = clock() - ttlMs;
43
+ for (const [id, ts] of entry) if (ts < cutoff) entry.delete(id);
44
+ // After TTL prune, if still over the hard cap, drop oldest entries
45
+ // (insertion order in Map iteration). This handles the busy-chat
46
+ // case where 1000 messages all sent within 24h would otherwise
47
+ // leak.
48
+ if (entry.size > maxPerChat) {
49
+ const dropCount = entry.size - maxPerChat;
50
+ let i = 0;
51
+ for (const id of entry.keys()) {
52
+ if (i >= dropCount) break;
53
+ entry.delete(id);
54
+ i += 1;
55
+ }
56
+ }
57
+ }
58
+
59
+ function gcOuter() {
60
+ // Drop chat entries that are entirely empty (their inner Map was
61
+ // drained by gcInner). Without this the outer Map's chatId set
62
+ // grows by one per ever-active-then-idle chat, slowly leaking.
63
+ for (const [k, entry] of sentMessages) {
64
+ if (entry.size === 0) sentMessages.delete(k);
65
+ }
66
+ }
67
+
27
68
  function record(chatId, messageId) {
28
69
  if (chatId == null || messageId == null) return;
29
70
  const key = chatKey(chatId);
@@ -32,13 +73,10 @@ function createSentCache() {
32
73
  entry = new Map();
33
74
  sentMessages.set(key, entry);
34
75
  }
35
- entry.set(messageId, Date.now());
36
- // Lazy GC: when the per-chat map gets crowded, drop expired
37
- // entries. Cheap (O(n) over n 100 + a bit) and amortises to O(1).
38
- if (entry.size > CLEANUP_THRESHOLD) {
39
- const cutoff = Date.now() - TTL_MS;
40
- for (const [id, ts] of entry) if (ts < cutoff) entry.delete(id);
41
- }
76
+ entry.set(messageId, clock());
77
+ if (entry.size > cleanupThreshold) gcInner(entry);
78
+ // Periodic outer sweep runs only when the outer Map gets crowded.
79
+ if (sentMessages.size > outerSweepThreshold) gcOuter();
42
80
  }
43
81
 
44
82
  function wasSent(chatId, messageId) {
@@ -48,8 +86,10 @@ function createSentCache() {
48
86
  if (!entry) return false;
49
87
  const ts = entry.get(messageId);
50
88
  if (ts == null) return false;
51
- if (Date.now() - ts > TTL_MS) {
89
+ if (clock() - ts > ttlMs) {
52
90
  entry.delete(messageId);
91
+ // If we just emptied the inner Map, drop the outer entry too.
92
+ if (entry.size === 0) sentMessages.delete(key);
53
93
  return false;
54
94
  }
55
95
  return true;
@@ -61,11 +101,19 @@ function createSentCache() {
61
101
  return total;
62
102
  }
63
103
 
104
+ function chatCount() { return sentMessages.size; }
105
+
64
106
  function clear() {
65
107
  sentMessages.clear();
66
108
  }
67
109
 
68
- return { record, wasSent, size, clear };
110
+ return { record, wasSent, size, chatCount, clear };
69
111
  }
70
112
 
71
- module.exports = { createSentCache, TTL_MS, CLEANUP_THRESHOLD };
113
+ module.exports = {
114
+ createSentCache,
115
+ TTL_MS,
116
+ CLEANUP_THRESHOLD,
117
+ MAX_PER_CHAT,
118
+ OUTER_SWEEP_THRESHOLD,
119
+ };
@@ -1,24 +1,31 @@
1
1
  /**
2
2
  * Live streaming-reply state machine for a single turn.
3
3
  *
4
- * Lifecycle (0.7.0):
4
+ * Lifecycle:
5
5
  * idle -> (text >= minChars) -> live
6
6
  * live -> (subsequent chunks) -> live (throttled edits)
7
+ * live -> flushDraft() -> live (drains pending edit)
7
8
  * live -> forceNewMessage() -> idle (next chunk = new bubble)
8
9
  * live -> discard() -> finalized (bubble deleted)
9
10
  * any -> finalize(finalText) -> finalized
10
11
  *
11
- * The streamer never talks to Telegram directly — callers inject `send(text)`,
12
- * `edit(msg_id, text)`, and (new in 0.7.0) optional `deleteMessage(msg_id)`.
12
+ * The streamer never talks to Telegram directly — callers inject
13
+ * `send(text)`, `edit(msg_id, text)`, and (optional) `deleteMessage(msg_id)`.
13
14
  * That keeps polygram.js in charge of transcript writes, sticker/reaction
14
15
  * routing, and error handling; this module is just a cadence machine.
15
16
  *
16
- * 0.7.0 finalize() returns rich result so the caller can decide whether the
17
- * preview's last edit IS the final reply, or whether to discard the preview
18
- * and redeliver via deliverReplies (overflow / final edit failed). This is
19
- * the OpenClaw pattern: short replies preview-becomes-final (no flicker),
20
- * long replies preview-deleted-redelivered (single coherent bubble flow at
21
- * chat bottom).
17
+ * `finalize()` returns a rich result so the caller can decide whether the
18
+ * preview's last edit IS the final reply, or whether to discard the
19
+ * preview and redeliver via deliverReplies (overflow / final edit failed):
20
+ *
21
+ * { kind: implicit, see flags below }
22
+ * { streamed: false } — never went live
23
+ * { streamed: true, finalEditOk: true } — preview = final
24
+ * { streamed: true, finalEditOk: false, overflow: true } — body too long
25
+ * { streamed: true, finalEditOk: false, overflow: false } — edit failed
26
+ *
27
+ * Short replies preview-becomes-final (no flicker, single bubble); long
28
+ * replies preview-deleted-redelivered (chunks land at chat bottom).
22
29
  *
23
30
  * Test-friendly: inject `clock` (now() fn) and `schedule` (setTimeout-like)
24
31
  * so a fake clock can drive throttle timing deterministically.
@@ -51,11 +58,11 @@ function createStreamer({
51
58
  let pendingEdit = null; // timer id
52
59
  let flushPromise = null; // ongoing edit promise (for back-pressure)
53
60
 
54
- // 0.7.0: this is the LIVE-EDIT truncation, used during streaming
55
- // when latestText overshoots maxLen. The trailing "..." signals to
56
- // the user that more is coming. At finalize time, we DON'T truncate
57
- // we either edit-to-final-as-is (caller already chunked correctly)
58
- // or signal overflow back to the caller.
61
+ // LIVE-EDIT truncation only — used during streaming when latestText
62
+ // overshoots maxLen. The trailing "..." signals to the user that more
63
+ // is coming. Finalize doesn't truncate: overflow is handled by
64
+ // signalling the caller to discard-and-redeliver via chunkMarkdownText,
65
+ // which preserves all content without any byte-cut.
59
66
  function truncateForLive(s) {
60
67
  if (s.length <= maxLen) return s;
61
68
  return s.slice(0, maxLen - 3) + '...';
@@ -132,11 +139,11 @@ function createStreamer({
132
139
  if (flushPromise) { try { await flushPromise; } catch {} }
133
140
  }
134
141
 
135
- // 0.7.0: reset bubble state so the next onChunk creates a NEW message.
136
- // Used by the upcoming Phase 7 F (forceNewMessage on assistant-
137
- // message-start) — when Claude emits a new top-level assistant message
138
- // mid-turn (post tool-result), we want it in its own bubble below
139
- // the previous one, not appended via edit.
142
+ // Reset bubble state so the next onChunk creates a NEW message.
143
+ // Used by `onAssistantMessageStart` in process-manager.js when Claude
144
+ // emits a new top-level assistant message mid-turn (post tool-result):
145
+ // we want it in its own bubble below the previous one, not appended
146
+ // via editMessageText to the original.
140
147
  function forceNewMessage() {
141
148
  if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
142
149
  // Don't await flushPromise — the caller has decided to start a new
@@ -1,24 +1,26 @@
1
1
  /**
2
2
  * Markdown-aware chunking for Telegram-bound replies.
3
3
  *
4
- * Direct port of OpenClaw's chunkMarkdownText (`extensions/telegram` uses
5
- * `chunkerMode: 'markdown'`). The naive byte-cut chunker we shipped pre-0.7.0
6
- * landed boundaries mid-word and mid-HTML-tag, which Telegram's parse_mode=HTML
7
- * rejected with `400 can't parse entities` — bubbles froze and content got
8
- * dropped (see msg 10794 incident). This algorithm guarantees:
4
+ * Direct port of OpenClaw's chunkMarkdownText. The naive byte-cut
5
+ * chunker we shipped before this would land boundaries mid-word and
6
+ * mid-HTML-tag, which Telegram's parse_mode=HTML rejected with
7
+ * `400 can't parse entities` — bubbles froze and content got dropped.
8
+ *
9
+ * Guarantees:
9
10
  *
10
11
  * 1. No chunk exceeds `limit`.
11
12
  * 2. Breaks prefer newlines over whitespace over hard-cut.
12
- * 3. Code fences (```...```) are never broken silently — if a chunk would
13
- * land inside a fence, we close it on chunk N and re-open with the same
14
- * marker + language on chunk N+1, so each chunk is independently
15
- * parseable.
16
- * 4. Parenthesised expressions `(...)` aren't broken at whitespace inside
17
- * the parens (avoids splitting `[markdown link](http://example.com/...)`).
13
+ * 3. Code fences (```...```) are never broken silently — if a chunk
14
+ * would land inside a fence, we close it on chunk N and re-open
15
+ * with the same marker + language on chunk N+1, so each chunk is
16
+ * independently parseable.
17
+ * 4. Parenthesised expressions `(...)` aren't broken at whitespace
18
+ * inside the parens (avoids splitting markdown-link syntax like
19
+ * `[label](http://example.com/...)`).
18
20
  *
19
- * Plain `chunkText` (no fence handling) is exported for callers that already
20
- * know the input has no markdown — primarily code paths handling raw user
21
- * input echoes or non-text payloads.
21
+ * Plain `chunkText` (no fence handling) is exported for callers that
22
+ * already know the input has no markdown — primarily code paths
23
+ * handling raw user input echoes or non-text payloads.
22
24
  */
23
25
 
24
26
  // ─── Code-fence span detection ──────────────────────────────────────
@@ -105,11 +107,19 @@ function scanParenAwareBreakpoints(window, isAllowed = () => true) {
105
107
 
106
108
  // ─── Chunkers ────────────────────────────────────────────────────────
107
109
 
108
- // Common early-out: empty / non-positive limit / fits-in-one returns
109
- // directly so the loop bodies can assume there's real work to do.
110
+ // Common early-out: empty / fits-in-one returns directly so the loop
111
+ // bodies can assume there's real work to do. `limit ≤ 0` is treated as
112
+ // a programmer error and throws — silently returning [text] would let
113
+ // a misread config pass through a body that exceeds Telegram's actual
114
+ // 4096-char cap, which the chunker exists to prevent.
110
115
  function resolveChunkEarlyReturn(text, limit) {
111
- if (!text) return [];
112
- if (limit <= 0) return [text];
116
+ if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0) {
117
+ throw new RangeError(`chunk limit must be a positive number; got ${limit}`);
118
+ }
119
+ if (text == null || text === '') return [];
120
+ if (typeof text !== 'string') {
121
+ throw new TypeError(`chunk text must be a string; got ${typeof text}`);
122
+ }
113
123
  if (text.length <= limit) return [text];
114
124
  return undefined;
115
125
  }
@@ -119,7 +129,7 @@ function resolveChunkEarlyReturn(text, limit) {
119
129
  // Negative / out-of-range break indices fall back to hard-cut at limit.
120
130
  function chunkTextByBreakResolver(text, limit, resolveBreakIndex) {
121
131
  if (!text) return [];
122
- if (limit <= 0 || text.length <= limit) return [text];
132
+ if (text.length <= limit) return [text];
123
133
  const chunks = [];
124
134
  let remaining = text;
125
135
  while (remaining.length > limit) {
package/lib/telegram.js CHANGED
@@ -173,20 +173,32 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
173
173
  let res;
174
174
  const rawAttempt = async (p) => bot.api.raw[method](p);
175
175
 
176
- // safeAttempt wraps every API call with three OpenClaw-style fallbacks:
177
- // 1. MESSAGE_NOT_MODIFIED on editMessageText → swallow as success.
178
- // The streamer's debounced edit can land on text that exactly
179
- // matches the bubble's current state (no-op edit). Telegram
180
- // returns 400; we treat it as success and skip the noise.
181
- // 2. HTML parse error (`can't parse entities` etc) → retry the
182
- // same call as plain text, no parse_mode, original raw value
183
- // restored to the formatted field. Saves the call when our
184
- // markdown→HTML conversion produces malformed output (the
185
- // msg-10794 case: streamer truncate cut mid `**bold**` marker).
186
- // 3. 429 rate limit → sleep retry_after seconds, retry once.
187
- // Telegram's per-bot limit is ~30 req/s; high-effort xhigh turns
188
- // with many parallel sessions can occasionally hit it. Honor
189
- // Telegram's hint rather than bombing the call.
176
+ // OpenClaw-style fallback layers, composing outermost-to-innermost:
177
+ //
178
+ // withThreadFallback (outer) — strips message_thread_id and
179
+ // retries on TOPIC_DELETED / "thread
180
+ // not found"
181
+ // withPreConnectRetry — single retry on transient pre-
182
+ // connect errors (DNS flap, TCP
183
+ // refused, ENETUNREACH); never
184
+ // retries post-connect errors that
185
+ // might have landed
186
+ // safeAttempt — sleeps `retry_after` and retries
187
+ // once on 429
188
+ // tryOnce (innermost) — handles two per-call recoveries:
189
+ // (a) MESSAGE_NOT_MODIFIED on
190
+ // editMessageText → synthetic
191
+ // success (streamer debounce often
192
+ // lands on no-op edits, Telegram
193
+ // returns 400 we don't want to
194
+ // propagate); (b) HTML parse error
195
+ // → retry as plain text with the
196
+ // raw pre-conversion field value
197
+ // restored
198
+ // rawAttempt — bot.api.raw[method](params)
199
+ //
200
+ // Each layer is a closure built per call; allocation cost is
201
+ // negligible vs. network RTT.
190
202
  const RETRY_AFTER_CAP_MS = 60_000;
191
203
  const tryOnce = async (p) => {
192
204
  try {
@@ -209,7 +221,20 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
209
221
  const plainParams = { ...p };
210
222
  delete plainParams.parse_mode;
211
223
  plainParams[formatField] = rawFieldValue;
212
- return await rawAttempt(plainParams);
224
+ try {
225
+ return await rawAttempt(plainParams);
226
+ } catch (plainErr) {
227
+ // 0.7.1: if the plain retry also fails, preserve BOTH errors
228
+ // in the message that propagates. Pre-fix, only `plainErr`
229
+ // bubbled up — operators investigating from markOutboundFailed
230
+ // saw e.g. "Forbidden: bot was kicked" and missed that the
231
+ // ORIGINAL failure was a markdown→HTML parse bug.
232
+ const origMsg = redactBotToken(err.message)?.slice(0, 200);
233
+ const wrapped = new Error(`plain-retry failed (after HTML parse error: ${origMsg}): ${plainErr.message}`);
234
+ if (plainErr.code) wrapped.code = plainErr.code;
235
+ if (plainErr.parameters) wrapped.parameters = plainErr.parameters;
236
+ throw wrapped;
237
+ }
213
238
  }
214
239
  throw err;
215
240
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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
@@ -1748,12 +1748,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1748
1748
  // at which point we flip to THINKING (🤔).
1749
1749
  reactor.setState('QUEUED');
1750
1750
 
1751
- // Mark the inbound row terminal so boot replay doesn't pick it up again.
1752
- // Must fire down EVERY non-throwing exit path (early returns for error/
1753
- // NO_REPLY, streamed-reply early return, regular reply at end). 0.5.4
1754
- // hardened this earlier versions only marked at the bottom of try, so
1755
- // streamed replies (which return at line ~1477) left handler_status
1756
- // stuck at 'dispatched' forever, causing replay loops on every restart.
1751
+ // Mark the inbound row terminal so boot replay doesn't pick it up
1752
+ // again. Must fire down EVERY non-throwing exit path (early returns
1753
+ // for error / NO_REPLY, streamed-reply preview-becomes-final, the
1754
+ // discard+redeliver branch, regular reply at end). Earlier versions
1755
+ // only marked at the bottom of try, so streamed-reply early returns
1756
+ // left handler_status stuck at 'dispatched' forever and the next
1757
+ // boot replayed every long turn.
1757
1758
  const markReplied = () => dbWrite(() => db.setInboundHandlerStatus({
1758
1759
  chat_id: chatId, msg_id: msg.message_id, status: 'replied',
1759
1760
  }), 'set handler_status=replied');
@@ -1800,6 +1801,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1800
1801
  // those still markReplied silently.
1801
1802
  if (result.text === 'NO_REPLY') { markReplied(); return; }
1802
1803
  if (!result.text) {
1804
+ // 0.7.1: if the fallback send itself fails, throw rather than
1805
+ // silently markReplied — the user gets nothing AND the inbound
1806
+ // is marked replied so boot replay won't redispatch. Same
1807
+ // anti-pattern that caused msg-10794. Promote to a thrown error
1808
+ // so dispatchHandleMessage's catch branches correctly:
1809
+ // shutdown → 'replay-pending' (boot replay retries)
1810
+ // runtime → 'failed' + user-visible apology via errorReplyText
1803
1811
  try {
1804
1812
  await tg(bot, 'sendMessage', {
1805
1813
  chat_id: chatId,
@@ -1808,7 +1816,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1808
1816
  reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
1809
1817
  }, { ...outMetaBase, source: 'empty-response-fallback' });
1810
1818
  } catch (err) {
1811
- console.error(`[${label}] empty-response fallback send failed: ${err.message}`);
1819
+ reactor.setState('ERROR');
1820
+ logEvent('telegram-empty-response-fallback-failed', {
1821
+ chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
1822
+ error: err.message?.slice(0, 200),
1823
+ });
1824
+ throw new Error(`empty-response fallback send failed: ${err.message}`);
1812
1825
  }
1813
1826
  logEvent('telegram-empty-response-fallback', {
1814
1827
  chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
@@ -1820,7 +1833,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1820
1833
  const parsed = parseResponse(result.text);
1821
1834
  const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
1822
1835
 
1823
- // 0.7.0 streamed text path: OpenClaw's preview-becomes-final flow.
1836
+ // OpenClaw's preview-becomes-final flow:
1824
1837
  //
1825
1838
  // 1. flushDraft() — drain any pending throttled edit so the
1826
1839
  // bubble's visible state is up-to-date before deciding.
@@ -1828,12 +1841,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1828
1841
  // final body. Returns rich result describing whether the
1829
1842
  // preview can stand as the final reply.
1830
1843
  // 3a. finalEditOk:true → preview IS final, done.
1831
- // 3b. overflow OR !finalEditOk → discard preview, redeliver via
1832
- // deliverReplies(chunkMarkdownText(...)). This is the path
1833
- // that fixes msg-10794: if the live bubble couldn't render
1834
- // the full body (size or parse error), we delete it cleanly
1835
- // and send the proper chunks fresh at chat bottom — no
1836
- // content lost, no stranded edit-failure bubble.
1844
+ // 3b. overflow OR !finalEditOk → discard preview, redeliver
1845
+ // via deliverReplies(chunkMarkdownText(...)). The bubble
1846
+ // couldn't render the full body (size or parse error), so
1847
+ // we delete it cleanly and send the proper chunks fresh at
1848
+ // chat bottom no content lost, no stranded bubble.
1837
1849
  if (parsed.text) {
1838
1850
  await streamer.flushDraft();
1839
1851
  const fin = await streamer.finalize(parsed.text);
@@ -1868,7 +1880,24 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1868
1880
  delivered: r.sent.length, failed: r.failed.length,
1869
1881
  bot: BOT_NAME,
1870
1882
  });
1871
- console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed-redeliver(${reason}, ${chunks.length} chunks) | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1883
+ // 0.7.1: surface partial-failure to the user. Without this,
1884
+ // a chunk-3-of-5 failure leaves a coherent-looking reply with
1885
+ // a silent gap (the user reads chunks 1, 2, 4, 5 unaware
1886
+ // that chunk 3 was dropped). Append a warning + flip the
1887
+ // reactor to ERROR so something visible signals "look here".
1888
+ if (r.failed.length > 0) {
1889
+ reactor.setState('ERROR');
1890
+ try {
1891
+ await tg(bot, 'sendMessage', {
1892
+ chat_id: chatId,
1893
+ text: `⚠️ ${r.failed.length} of ${chunks.length} message parts failed to deliver. The reply may be incomplete — please retry.`,
1894
+ ...(threadId && { message_thread_id: threadId }),
1895
+ }, { ...outMetaBase, source: 'partial-delivery-warning' });
1896
+ } catch (warnErr) {
1897
+ console.error(`[${label}] partial-delivery warning failed: ${warnErr.message}`);
1898
+ }
1899
+ }
1900
+ console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed-redeliver(${reason}, ${chunks.length} chunks${r.failed.length ? `, ${r.failed.length} failed` : ''}) | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1872
1901
  markReplied();
1873
1902
  return;
1874
1903
  }