polygram 0.7.0 → 0.7.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.
@@ -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.2",
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 # 643 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.
@@ -50,12 +57,19 @@ function createStreamer({
50
57
  let lastEditTs = 0;
51
58
  let pendingEdit = null; // timer id
52
59
  let flushPromise = null; // ongoing edit promise (for back-pressure)
60
+ // 0.7.2: msg_ids of bubbles that have been superseded by
61
+ // forceNewMessage(). The caller (polygram.js handleMessage at
62
+ // end-of-turn) reads getArchived() and issues deleteMessage on
63
+ // each — matches OpenClaw's archivedAnswerPreviews cleanup so
64
+ // the user sees only the final answer's bubble, not every
65
+ // "thinking out loud" intermediate from a tool-heavy turn.
66
+ const archived = [];
53
67
 
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.
68
+ // LIVE-EDIT truncation only — used during streaming when latestText
69
+ // overshoots maxLen. The trailing "..." signals to the user that more
70
+ // is coming. Finalize doesn't truncate: overflow is handled by
71
+ // signalling the caller to discard-and-redeliver via chunkMarkdownText,
72
+ // which preserves all content without any byte-cut.
59
73
  function truncateForLive(s) {
60
74
  if (s.length <= maxLen) return s;
61
75
  return s.slice(0, maxLen - 3) + '...';
@@ -132,15 +146,20 @@ function createStreamer({
132
146
  if (flushPromise) { try { await flushPromise; } catch {} }
133
147
  }
134
148
 
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.
149
+ // Reset bubble state so the next onChunk creates a NEW message.
150
+ // Used by `onAssistantMessageStart` in process-manager.js when Claude
151
+ // emits a new top-level assistant message mid-turn (post tool-result):
152
+ // we want it in its own bubble below the previous one, not appended
153
+ // via editMessageText to the original.
140
154
  function forceNewMessage() {
141
155
  if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
142
156
  // Don't await flushPromise — the caller has decided to start a new
143
157
  // message; whatever the old bubble shows is "done".
158
+ // 0.7.2: track the previous bubble's msgId for end-of-turn cleanup.
159
+ // Without this, every intermediate "thinking out loud" assistant
160
+ // message in a tool-heavy turn leaves a permanent bubble in the
161
+ // chat — the user wants only the final answer's bubble visible.
162
+ if (msgId != null) archived.push(msgId);
144
163
  msgId = null;
145
164
  currentText = '';
146
165
  latestText = '';
@@ -232,6 +251,11 @@ function createStreamer({
232
251
  }
233
252
  }
234
253
 
254
+ // 0.7.2: snapshot of bubble msgIds that forceNewMessage() superseded.
255
+ // Returns a copy so callers can't mutate internal state. polygram.js
256
+ // reads this at end-of-turn and issues deleteMessage on each.
257
+ function getArchived() { return archived.slice(); }
258
+
235
259
  return {
236
260
  onChunk,
237
261
  finalize,
@@ -239,6 +263,7 @@ function createStreamer({
239
263
  forceNewMessage,
240
264
  discard,
241
265
  archive,
266
+ getArchived,
242
267
  // Introspection for tests:
243
268
  get state() { return state; },
244
269
  get msgId() { return msgId; },
@@ -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.2",
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
@@ -1664,21 +1664,34 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1664
1664
  ...(linkPreview === false ? { linkPreview: false } : {}),
1665
1665
  };
1666
1666
 
1667
+ // 0.7.2: only the FIRST bubble in a turn quotes the user's message
1668
+ // via reply_parameters. When a tool-heavy turn produces multiple
1669
+ // assistant messages (each spawning its own bubble via
1670
+ // forceNewMessage), subsequent bubbles shouldn't re-quote the user
1671
+ // — the chat would show N copies of the same quoted message stacked
1672
+ // vertically. After the first send, the flag flips and subsequent
1673
+ // initial-sends omit reply_parameters.
1674
+ let firstBubbleSent = false;
1667
1675
  // Streaming is unconditional as of 0.4.0 — matches OpenClaw's model and
1668
1676
  // eliminates the "stuck at 15min typing" complaint from the non-streaming
1669
1677
  // code path. For short responses the streamer stays idle and we fall
1670
1678
  // through to the normal send path via finalize() returning streamed=false.
1671
1679
  const streamer = createStreamer({
1672
- send: async (text) => tg(bot, 'sendMessage', {
1673
- chat_id: chatId, text,
1674
- // allow_sending_without_reply: long-running turns give the user
1675
- // plenty of time to delete their original message. Without this
1676
- // flag, Telegram rejects the reply with MESSAGE_NOT_FOUND and the
1677
- // whole streamed answer is lost. With it, the reply simply lands
1678
- // as a standalone message.
1679
- reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
1680
- ...(threadId && { message_thread_id: threadId }),
1681
- }, outMetaBase),
1680
+ send: async (text) => {
1681
+ const params = {
1682
+ chat_id: chatId, text,
1683
+ ...(threadId && { message_thread_id: threadId }),
1684
+ };
1685
+ if (!firstBubbleSent) {
1686
+ // allow_sending_without_reply: long-running turns give the user
1687
+ // plenty of time to delete their original message. Without this
1688
+ // flag, Telegram rejects the reply with MESSAGE_NOT_FOUND and the
1689
+ // whole streamed answer is lost.
1690
+ params.reply_parameters = { message_id: msg.message_id, allow_sending_without_reply: true };
1691
+ firstBubbleSent = true;
1692
+ }
1693
+ return tg(bot, 'sendMessage', params, outMetaBase);
1694
+ },
1682
1695
  edit: async (messageId, text) => {
1683
1696
  try {
1684
1697
  // Route edits through tg() so applyFormatting runs (MarkdownV2
@@ -1725,6 +1738,34 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1725
1738
  });
1726
1739
  // streamer is registered with this turn via pm.send's context (below)
1727
1740
 
1741
+ // 0.7.2: clean up bubbles superseded by forceNewMessage() — the
1742
+ // intermediate "thinking out loud" assistant messages that fired in
1743
+ // a tool-heavy turn. Without this, every tool-result cycle leaves a
1744
+ // permanent bubble in the chat (see the screenshot from the post-
1745
+ // 0.7.1 deploy where six bubbles appeared for one logical turn).
1746
+ // Matches OpenClaw's archivedAnswerPreviews end-of-turn cleanup.
1747
+ // Call AFTER finalize/discard decisions so we never delete the
1748
+ // bubble that's the final reply.
1749
+ async function cleanupArchivedBubbles() {
1750
+ const archived = streamer.getArchived?.() || [];
1751
+ if (archived.length === 0) return;
1752
+ for (const messageId of archived) {
1753
+ try {
1754
+ await tg(bot, 'deleteMessage', {
1755
+ chat_id: chatId, message_id: messageId,
1756
+ }, { source: 'bot-reply-archived-cleanup', botName: BOT_NAME });
1757
+ } catch (err) {
1758
+ // Non-fatal — message may be >48h old or already gone.
1759
+ // Operator-visible only via the events table.
1760
+ console.error(`[${label}] archived-cleanup ${messageId}: ${err.message}`);
1761
+ }
1762
+ }
1763
+ logEvent('telegram-archived-cleanup', {
1764
+ chat_id: chatId, msg_id: msg.message_id, count: archived.length,
1765
+ bot: BOT_NAME,
1766
+ });
1767
+ }
1768
+
1728
1769
  // Status reactions on the user's message: 👀 queued → 🤔 thinking →
1729
1770
  // 👨‍💻 coding / ⚡ web / 🔥 tool → 👍 done / 🤯 error. Silent (no
1730
1771
  // notifications), updates in place, one emoji per message. Uses
@@ -1748,12 +1789,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1748
1789
  // at which point we flip to THINKING (🤔).
1749
1790
  reactor.setState('QUEUED');
1750
1791
 
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.
1792
+ // Mark the inbound row terminal so boot replay doesn't pick it up
1793
+ // again. Must fire down EVERY non-throwing exit path (early returns
1794
+ // for error / NO_REPLY, streamed-reply preview-becomes-final, the
1795
+ // discard+redeliver branch, regular reply at end). Earlier versions
1796
+ // only marked at the bottom of try, so streamed-reply early returns
1797
+ // left handler_status stuck at 'dispatched' forever and the next
1798
+ // boot replayed every long turn.
1757
1799
  const markReplied = () => dbWrite(() => db.setInboundHandlerStatus({
1758
1800
  chat_id: chatId, msg_id: msg.message_id, status: 'replied',
1759
1801
  }), 'set handler_status=replied');
@@ -1800,6 +1842,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1800
1842
  // those still markReplied silently.
1801
1843
  if (result.text === 'NO_REPLY') { markReplied(); return; }
1802
1844
  if (!result.text) {
1845
+ // 0.7.1: if the fallback send itself fails, throw rather than
1846
+ // silently markReplied — the user gets nothing AND the inbound
1847
+ // is marked replied so boot replay won't redispatch. Same
1848
+ // anti-pattern that caused msg-10794. Promote to a thrown error
1849
+ // so dispatchHandleMessage's catch branches correctly:
1850
+ // shutdown → 'replay-pending' (boot replay retries)
1851
+ // runtime → 'failed' + user-visible apology via errorReplyText
1803
1852
  try {
1804
1853
  await tg(bot, 'sendMessage', {
1805
1854
  chat_id: chatId,
@@ -1808,7 +1857,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1808
1857
  reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
1809
1858
  }, { ...outMetaBase, source: 'empty-response-fallback' });
1810
1859
  } catch (err) {
1811
- console.error(`[${label}] empty-response fallback send failed: ${err.message}`);
1860
+ reactor.setState('ERROR');
1861
+ logEvent('telegram-empty-response-fallback-failed', {
1862
+ chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
1863
+ error: err.message?.slice(0, 200),
1864
+ });
1865
+ throw new Error(`empty-response fallback send failed: ${err.message}`);
1812
1866
  }
1813
1867
  logEvent('telegram-empty-response-fallback', {
1814
1868
  chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
@@ -1820,7 +1874,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1820
1874
  const parsed = parseResponse(result.text);
1821
1875
  const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
1822
1876
 
1823
- // 0.7.0 streamed text path: OpenClaw's preview-becomes-final flow.
1877
+ // OpenClaw's preview-becomes-final flow:
1824
1878
  //
1825
1879
  // 1. flushDraft() — drain any pending throttled edit so the
1826
1880
  // bubble's visible state is up-to-date before deciding.
@@ -1828,12 +1882,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1828
1882
  // final body. Returns rich result describing whether the
1829
1883
  // preview can stand as the final reply.
1830
1884
  // 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.
1885
+ // 3b. overflow OR !finalEditOk → discard preview, redeliver
1886
+ // via deliverReplies(chunkMarkdownText(...)). The bubble
1887
+ // couldn't render the full body (size or parse error), so
1888
+ // we delete it cleanly and send the proper chunks fresh at
1889
+ // chat bottom no content lost, no stranded bubble.
1837
1890
  if (parsed.text) {
1838
1891
  await streamer.flushDraft();
1839
1892
  const fin = await streamer.finalize(parsed.text);
@@ -1841,6 +1894,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1841
1894
  if (fin.finalEditOk) {
1842
1895
  // Preview was successfully edited to the final text.
1843
1896
  // No follow-up messages needed.
1897
+ await cleanupArchivedBubbles();
1844
1898
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1845
1899
  markReplied();
1846
1900
  return;
@@ -1868,7 +1922,25 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1868
1922
  delivered: r.sent.length, failed: r.failed.length,
1869
1923
  bot: BOT_NAME,
1870
1924
  });
1871
- console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed-redeliver(${reason}, ${chunks.length} chunks) | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1925
+ // 0.7.1: surface partial-failure to the user. Without this,
1926
+ // a chunk-3-of-5 failure leaves a coherent-looking reply with
1927
+ // a silent gap (the user reads chunks 1, 2, 4, 5 unaware
1928
+ // that chunk 3 was dropped). Append a warning + flip the
1929
+ // reactor to ERROR so something visible signals "look here".
1930
+ if (r.failed.length > 0) {
1931
+ reactor.setState('ERROR');
1932
+ try {
1933
+ await tg(bot, 'sendMessage', {
1934
+ chat_id: chatId,
1935
+ text: `⚠️ ${r.failed.length} of ${chunks.length} message parts failed to deliver. The reply may be incomplete — please retry.`,
1936
+ ...(threadId && { message_thread_id: threadId }),
1937
+ }, { ...outMetaBase, source: 'partial-delivery-warning' });
1938
+ } catch (warnErr) {
1939
+ console.error(`[${label}] partial-delivery warning failed: ${warnErr.message}`);
1940
+ }
1941
+ }
1942
+ await cleanupArchivedBubbles();
1943
+ 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
1944
  markReplied();
1873
1945
  return;
1874
1946
  }