polygram 0.6.15 β†’ 0.7.0

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.6.15",
4
+ "version": "0.7.0",
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 # 500 tests, 115 suites, node:test, no external services
367
+ npm test # 619 tests, 153 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
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Subagent / informational announces β€” a thin OpenClaw-style helper for
3
+ * sending small "I'm doing X" messages to a chat without mixing into
4
+ * the main reply flow.
5
+ *
6
+ * Polygram's user-facing surface for "shumabit is working" is the
7
+ * status reaction on the user's message (πŸ‘€ β†’ πŸ€” β†’ tool icons β†’
8
+ * πŸ‘/πŸ’₯). For tool-heavy turns where the user wants more visibility
9
+ * β€” e.g. shumabit delegating to a subagent via Claude Code's Task
10
+ * tool β€” an opt-in announce can post a brief informational message
11
+ * to the chat. Off by default (`config.bots.<bot>.announceSubagents`
12
+ * or `config.chats.<id>.announceSubagents`), so existing chats see
13
+ * no behavior change.
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.
19
+ */
20
+
21
+ const SUBAGENT_DEBOUNCE_MS = 30_000;
22
+
23
+ /**
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.
27
+ */
28
+ const lastAnnounceByChat = new Map();
29
+
30
+ function shouldAnnounce(chatId, now = Date.now(), debounceMs = SUBAGENT_DEBOUNCE_MS) {
31
+ const prev = lastAnnounceByChat.get(String(chatId));
32
+ if (prev != null && now - prev < debounceMs) return false;
33
+ lastAnnounceByChat.set(String(chatId), now);
34
+ return true;
35
+ }
36
+
37
+ /**
38
+ * Send a plain-text announce (no markdown processing, no reply linkage).
39
+ * Caller passes `tg(bot, method, params, meta)` as `send` so we don't
40
+ * have to import the full lib/telegram.js dependency tree here.
41
+ */
42
+ async function announce({
43
+ send,
44
+ bot,
45
+ chatId,
46
+ threadId = null,
47
+ text,
48
+ meta = {},
49
+ logger = console,
50
+ }) {
51
+ if (!text) return null;
52
+ const params = {
53
+ chat_id: chatId,
54
+ text,
55
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
56
+ };
57
+ try {
58
+ return await send(bot, 'sendMessage', params, {
59
+ ...meta,
60
+ source: meta.source || 'announce',
61
+ plainText: true, // skip markdown→HTML
62
+ linkPreview: false, // never preview-card for announces
63
+ });
64
+ } catch (err) {
65
+ logger.error?.(`[announce] failed: ${err.message}`);
66
+ return null;
67
+ }
68
+ }
69
+
70
+ module.exports = { announce, shouldAnnounce, SUBAGENT_DEBOUNCE_MS };
package/lib/deliver.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Chunked reply delivery primitive.
3
+ *
4
+ * `deliverReplies` is the polygram analog of OpenClaw's `deliverReplies` β€”
5
+ * a small loop over already-chunked text that sends each chunk as its own
6
+ * Telegram message via the `send()` wrapper from lib/telegram.js (which
7
+ * does write-before-send, HTML→plain fallback, and MESSAGE_NOT_MODIFIED
8
+ * swallowing).
9
+ *
10
+ * Polygram's old code path inlined a `for (chunk of chunkText(rest))` loop
11
+ * inside polygram.js with an ad-hoc `tg()` call. That worked, but mixed
12
+ * delivery concerns into the streaming-finalize logic, and made testing
13
+ * the multi-message path painful. With this primitive, the new
14
+ * "preview-becomes-final" flow in handleMessage (Phase 5) just calls:
15
+ *
16
+ * await deliverReplies({ bot, chatId, threadId, chunks, replyToMessageId, ... })
17
+ *
18
+ * β€” and gets back `{ sent, failed }` arrays of message_ids per chunk.
19
+ *
20
+ * Behavior:
21
+ * - Sends `chunks[0]` first with `reply_parameters` (so the answer
22
+ * visually anchors to the user's question). Subsequent chunks omit
23
+ * `reply_parameters` β€” chaining replies would clutter the chat.
24
+ * - On chunk failure, logs and continues to the next chunk. We'd
25
+ * rather deliver partial content than abort the whole reply.
26
+ * - Empty input returns `{ sent: [], failed: [] }` immediately.
27
+ */
28
+
29
+ async function deliverReplies({
30
+ bot,
31
+ send, // (bot, method, params, meta) β†’ res β€” usually createSender(db, logger)(...) or tg
32
+ chatId,
33
+ threadId = null,
34
+ chunks,
35
+ replyToMessageId = null,
36
+ meta = {},
37
+ logger = console,
38
+ }) {
39
+ if (!Array.isArray(chunks) || chunks.length === 0) {
40
+ return { sent: [], failed: [] };
41
+ }
42
+ const sent = [];
43
+ const failed = [];
44
+ for (let i = 0; i < chunks.length; i++) {
45
+ const params = {
46
+ chat_id: chatId,
47
+ text: chunks[i],
48
+ };
49
+ if (threadId != null) params.message_thread_id = threadId;
50
+ if (i === 0 && replyToMessageId != null) {
51
+ // allow_sending_without_reply: long turns give the user time to
52
+ // delete their original message; without this flag Telegram
53
+ // rejects with MESSAGE_NOT_FOUND and the whole reply is lost.
54
+ params.reply_parameters = { message_id: replyToMessageId, allow_sending_without_reply: true };
55
+ }
56
+ try {
57
+ const res = await send(bot, 'sendMessage', params, meta);
58
+ const msgId = res?.message_id ?? null;
59
+ sent.push(msgId);
60
+ } catch (err) {
61
+ logger.error?.(`[deliver] chunk ${i + 1}/${chunks.length} failed: ${err.message}`);
62
+ failed.push({ index: i, error: err.message });
63
+ // Keep going β€” partial delivery is better than total loss.
64
+ }
65
+ }
66
+ return { sent, failed };
67
+ }
68
+
69
+ module.exports = { deliverReplies };
package/lib/net-errors.js CHANGED
@@ -30,20 +30,55 @@ const PRE_CONNECT_ERROR_CODES = new Set([
30
30
  // Transient errors that are recoverable but may have made it partway. DO
31
31
  // NOT auto-retry these β€” the risk of double-delivery outweighs the gain.
32
32
  // Surface them to the caller and let humans decide.
33
+ //
34
+ // 0.7.0 added the UND_ERR_* family + ECONNABORTED / ERR_NETWORK to match
35
+ // OpenClaw's set (extensions/telegram/src/network-errors.ts). Node 22+
36
+ // uses undici as its default fetch impl, so these surface in real
37
+ // production traffic β€” pre-0.7.0 we'd silently misclassify them as
38
+ // non-network errors.
33
39
  const RECOVERABLE_ERROR_CODES = new Set([
34
- 'ETIMEDOUT', // TCP timeout after connect (message may have landed)
35
- 'EPIPE', // write after close β€” outcome indeterminate
36
- 'EAGAIN', // socket would block β€” reader should retry
40
+ 'ETIMEDOUT', // TCP timeout after connect (message may have landed)
41
+ 'EPIPE', // write after close β€” outcome indeterminate
42
+ 'EAGAIN', // socket would block β€” reader should retry
43
+ 'ESOCKETTIMEDOUT', // socket-level timeout (axios/legacy node)
44
+ 'ECONNABORTED', // connection aborted by client (timeout-induced)
45
+ 'ERR_NETWORK', // generic network error code
46
+ 'UND_ERR_CONNECT_TIMEOUT', // undici: connection timeout
47
+ 'UND_ERR_HEADERS_TIMEOUT', // undici: response headers timeout
48
+ 'UND_ERR_BODY_TIMEOUT', // undici: response body timeout
49
+ 'UND_ERR_SOCKET', // undici: socket error
50
+ 'UND_ERR_ABORTED', // undici: request aborted
37
51
  ]);
38
52
 
39
53
  // Error.name values emitted by undici/node for transient conditions.
54
+ // 0.7.0 added the undici-specific timeout error names; the new fetch
55
+ // impl in Node 22+ surfaces these as `err.name` rather than `err.code`
56
+ // in some shapes.
40
57
  const RECOVERABLE_ERROR_NAMES = new Set([
41
58
  'AbortError',
42
59
  'TimeoutError',
43
60
  'FetchError',
44
61
  'SocketError',
62
+ 'ConnectTimeoutError',
63
+ 'HeadersTimeoutError',
64
+ 'BodyTimeoutError',
45
65
  ]);
46
66
 
67
+ // Message-substring matchers for transient errors. undici sometimes
68
+ // wraps a network failure in a generic "fetch failed" without setting
69
+ // .code or .name β€” only the message tells us it's a network error.
70
+ //
71
+ // These are matched ONLY when the error doesn't already have a code or
72
+ // name we recognise, to avoid double-counting and to keep the broad
73
+ // matcher from catching unrelated errors that happen to include the
74
+ // substring.
75
+ const RECOVERABLE_MESSAGE_SNIPPETS = [
76
+ 'fetch failed',
77
+ 'undici',
78
+ 'network error',
79
+ 'network request',
80
+ ];
81
+
47
82
  function extractCode(err) {
48
83
  if (!err) return null;
49
84
  return err.code
@@ -67,6 +102,11 @@ function isSafeToRetry(err) {
67
102
  return code != null && PRE_CONNECT_ERROR_CODES.has(code);
68
103
  }
69
104
 
105
+ function extractMessage(err) {
106
+ if (!err) return '';
107
+ return String(err.message || err.cause?.message || err.description || '').toLowerCase();
108
+ }
109
+
70
110
  /**
71
111
  * Is this a transient network error β€” recoverable in the sense that the
72
112
  * connection may work next time, but NOT safe to auto-retry because the
@@ -80,6 +120,13 @@ function isTransientNetworkError(err) {
80
120
  }
81
121
  const name = extractName(err);
82
122
  if (name && RECOVERABLE_ERROR_NAMES.has(name)) return true;
123
+ // 0.7.0: only fall through to message-snippet matching when the
124
+ // error has no recognised code/name β€” avoids false-positive matches
125
+ // on unrelated errors that happen to mention "network".
126
+ const message = extractMessage(err);
127
+ if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((s) => message.includes(s))) {
128
+ return true;
129
+ }
83
130
  return false;
84
131
  }
85
132
 
@@ -113,9 +160,11 @@ module.exports = {
113
160
  PRE_CONNECT_ERROR_CODES,
114
161
  RECOVERABLE_ERROR_CODES,
115
162
  RECOVERABLE_ERROR_NAMES,
163
+ RECOVERABLE_MESSAGE_SNIPPETS,
116
164
  isSafeToRetry,
117
165
  isTransientNetworkError,
118
166
  extractCode,
119
167
  extractName,
168
+ extractMessage,
120
169
  redactBotToken,
121
170
  };
@@ -59,6 +59,7 @@ class ProcessManager {
59
59
  onClose = null, // (sessionKey, code, entry) β†’ void
60
60
  onStreamChunk = null, // (sessionKey, partialText, entry) β†’ void β€” routes to pendingQueue[0]
61
61
  onToolUse = null, // (sessionKey, toolName, entry) β†’ void β€” routes to pendingQueue[0]
62
+ onAssistantMessageStart = null, // (sessionKey, entry) β†’ void β€” fires when a NEW top-level assistant message begins (after a previous one ended). Used by polygram.js to call streamer.forceNewMessage() so each assistant message gets its own bubble.
62
63
  onRespawn = null, // (sessionKey, reason, entry) β†’ void β€” fires after graceful drain-and-kill
63
64
  } = {}) {
64
65
  if (!spawnFn) throw new Error('spawnFn required');
@@ -72,6 +73,7 @@ class ProcessManager {
72
73
  this.onClose = onClose;
73
74
  this.onStreamChunk = onStreamChunk;
74
75
  this.onToolUse = onToolUse;
76
+ this.onAssistantMessageStart = onAssistantMessageStart;
75
77
  this.onRespawn = onRespawn;
76
78
  this.procs = new Map();
77
79
  }
@@ -275,12 +277,34 @@ class ProcessManager {
275
277
  }
276
278
 
277
279
  if (event.type === 'assistant' && head) {
278
- if (this.onStreamChunk) {
279
- const added = extractAssistantText(event);
280
- if (added) {
281
- head.streamText = head.streamText
282
- ? `${head.streamText}\n\n${added}`
283
- : added;
280
+ // 0.7.0 (Phase F): detect message_id transitions to split bubbles
281
+ // per top-level assistant message. Each Anthropic stream-json
282
+ // 'assistant' event carries event.message.id; the same id across
283
+ // events means cumulative updates to the same message, a new
284
+ // id means a new message (typically after a tool-result cycle).
285
+ const messageId = event.message?.id;
286
+ const added = extractAssistantText(event);
287
+ if (added) {
288
+ // Pre-0.7.0 we did `streamText = streamText + '\n\n' + added`,
289
+ // which DUPLICATED text on every update because `added` is
290
+ // the cumulative full text-so-far of the current assistant
291
+ // message (not a delta). 0.7.0 REPLACES instead β€” the new
292
+ // text is already cumulative β€” and uses messageId boundaries
293
+ // to fire onAssistantMessageStart for each new top-level
294
+ // assistant message. The streamer responds by force-creating
295
+ // a fresh bubble, so each assistant message gets its own.
296
+ const isNewMessage = head.lastAssistantMessageId != null
297
+ && messageId != null
298
+ && head.lastAssistantMessageId !== messageId
299
+ && head.streamText
300
+ && head.streamText.length > 0;
301
+ if (isNewMessage && this.onAssistantMessageStart) {
302
+ try { this.onAssistantMessageStart(sessionKey, entry); }
303
+ catch (err) { this.logger.error(`[${entry.label}] onAssistantMessageStart: ${err.message}`); }
304
+ }
305
+ if (messageId != null) head.lastAssistantMessageId = messageId;
306
+ head.streamText = added;
307
+ if (this.onStreamChunk) {
284
308
  try { this.onStreamChunk(sessionKey, head.streamText, entry); }
285
309
  catch (err) { this.logger.error(`[${entry.label}] onStreamChunk: ${err.message}`); }
286
310
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * In-memory cache of message IDs the bot has sent, per chat.
3
+ *
4
+ * Port of OpenClaw's `sent-message-cache` (send-DVX_zY9w.js:1014-1041).
5
+ * Use case: filter the bot's own messages out of activation logic in
6
+ * group chats β€” a bot reply with a URL would otherwise auto-trigger
7
+ * a self-reply if the chat's activation rule includes "any message
8
+ * with a URL". Polygram's existing `messages` table can answer the
9
+ * same question via SQL (`direction = 'out' AND chat_id AND msg_id`),
10
+ * but the in-memory cache is O(1) for the high-frequency callers
11
+ * (every inbound message reaction handler).
12
+ *
13
+ * 24-hour TTL: Telegram messages older than 48h can't be reacted to
14
+ * anyway, so 24h is a comfortable working set. Per-chat cleanup runs
15
+ * lazily when the chat exceeds 100 entries.
16
+ */
17
+
18
+ const TTL_MS = 24 * 60 * 60 * 1000;
19
+ const CLEANUP_THRESHOLD = 100;
20
+
21
+ function createSentCache() {
22
+ // chatKey β†’ Map<msgId, ts>
23
+ const sentMessages = new Map();
24
+
25
+ function chatKey(chatId) { return String(chatId); }
26
+
27
+ function record(chatId, messageId) {
28
+ if (chatId == null || messageId == null) return;
29
+ const key = chatKey(chatId);
30
+ let entry = sentMessages.get(key);
31
+ if (!entry) {
32
+ entry = new Map();
33
+ sentMessages.set(key, entry);
34
+ }
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
+ }
42
+ }
43
+
44
+ function wasSent(chatId, messageId) {
45
+ if (chatId == null || messageId == null) return false;
46
+ const key = chatKey(chatId);
47
+ const entry = sentMessages.get(key);
48
+ if (!entry) return false;
49
+ const ts = entry.get(messageId);
50
+ if (ts == null) return false;
51
+ if (Date.now() - ts > TTL_MS) {
52
+ entry.delete(messageId);
53
+ return false;
54
+ }
55
+ return true;
56
+ }
57
+
58
+ function size() {
59
+ let total = 0;
60
+ for (const entry of sentMessages.values()) total += entry.size;
61
+ return total;
62
+ }
63
+
64
+ function clear() {
65
+ sentMessages.clear();
66
+ }
67
+
68
+ return { record, wasSent, size, clear };
69
+ }
70
+
71
+ module.exports = { createSentCache, TTL_MS, CLEANUP_THRESHOLD };
@@ -1,15 +1,24 @@
1
1
  /**
2
2
  * Live streaming-reply state machine for a single turn.
3
3
  *
4
- * Lifecycle per turn:
4
+ * Lifecycle (0.7.0):
5
5
  * idle -> (text >= minChars) -> live
6
6
  * live -> (subsequent chunks) -> live (throttled edits)
7
- * idle|live -> finalize(finalText) -> done
7
+ * live -> forceNewMessage() -> idle (next chunk = new bubble)
8
+ * live -> discard() -> finalized (bubble deleted)
9
+ * any -> finalize(finalText) -> finalized
8
10
  *
9
- * The streamer never talks to Telegram directly β€” callers inject
10
- * `send(text)` (returns {message_id}) and `edit(msg_id, text)`. That keeps
11
- * polygram.js in charge of transcript writes, sticker/reaction routing, and
12
- * error handling; this module is just a cadence machine.
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)`.
13
+ * That keeps polygram.js in charge of transcript writes, sticker/reaction
14
+ * routing, and error handling; this module is just a cadence machine.
15
+ *
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).
13
22
  *
14
23
  * Test-friendly: inject `clock` (now() fn) and `schedule` (setTimeout-like)
15
24
  * so a fake clock can drive throttle timing deterministically.
@@ -25,6 +34,7 @@ const DEFAULT_THROTTLE_MS = 1000;
25
34
  function createStreamer({
26
35
  send, // async (text) -> { message_id }
27
36
  edit, // async (msg_id, text) -> void
37
+ deleteMessage = null, // async (msg_id) -> void [optional]
28
38
  minChars = DEFAULT_MIN_CHARS,
29
39
  throttleMs = DEFAULT_THROTTLE_MS,
30
40
  maxLen = 4096,
@@ -41,7 +51,12 @@ function createStreamer({
41
51
  let pendingEdit = null; // timer id
42
52
  let flushPromise = null; // ongoing edit promise (for back-pressure)
43
53
 
44
- function truncate(s) {
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.
59
+ function truncateForLive(s) {
45
60
  if (s.length <= maxLen) return s;
46
61
  return s.slice(0, maxLen - 3) + '...';
47
62
  }
@@ -56,7 +71,7 @@ function createStreamer({
56
71
  if (state === 'idle') {
57
72
  if (text.length < minChars) return;
58
73
  state = 'live';
59
- currentText = truncate(text);
74
+ currentText = truncateForLive(text);
60
75
  try {
61
76
  const res = await send(currentText);
62
77
  msgId = res?.message_id ?? null;
@@ -90,7 +105,7 @@ function createStreamer({
90
105
  async function flush() {
91
106
  pendingEdit = null;
92
107
  if (state !== 'live' || msgId == null) return;
93
- const next = truncate(latestText);
108
+ const next = truncateForLive(latestText);
94
109
  if (next === currentText) return;
95
110
  lastEditTs = clock();
96
111
  currentText = next;
@@ -98,38 +113,132 @@ function createStreamer({
98
113
  flushPromise = edit(msgId, currentText);
99
114
  await flushPromise;
100
115
  } catch (err) {
101
- // Non-fatal β€” maybe 429. Log and keep going; next chunk will retry.
116
+ // Non-fatal β€” maybe 429 or transient. Log and keep going; next
117
+ // chunk will retry. The HTML→plain fallback in lib/telegram.js
118
+ // already handles the most common cause (parse error from
119
+ // truncate cutting mid-tag).
102
120
  logger.error(`[stream] edit failed: ${err.message}`);
103
121
  } finally {
104
122
  flushPromise = null;
105
123
  }
106
124
  }
107
125
 
126
+ // 0.7.0: explicitly drain any pending edit. Useful when the caller
127
+ // is about to make a finalize/discard decision and wants the bubble's
128
+ // visual state to be accurate (no stale half-rendered text under a
129
+ // pending timer).
130
+ async function flushDraft() {
131
+ if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; await flush(); }
132
+ if (flushPromise) { try { await flushPromise; } catch {} }
133
+ }
134
+
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.
140
+ function forceNewMessage() {
141
+ if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
142
+ // Don't await flushPromise β€” the caller has decided to start a new
143
+ // message; whatever the old bubble shows is "done".
144
+ msgId = null;
145
+ currentText = '';
146
+ latestText = '';
147
+ state = 'idle';
148
+ lastEditTs = 0;
149
+ }
150
+
151
+ // 0.7.0: delete the current bubble via the injected deleteMessage
152
+ // callback. Used when the final reply overflows the preview's single-
153
+ // message capacity, so handleMessage will discard the preview and
154
+ // redeliver via deliverReplies (chunks land at chat bottom).
155
+ //
156
+ // Works whether state is 'live' OR 'finalized' β€” handleMessage's
157
+ // typical flow is finalize() β†’ finalEditOk false β†’ discard. The
158
+ // bubble's msgId is preserved through finalize so we can still
159
+ // delete it. If deleteMessage isn't provided, we just transition
160
+ // state without touching Telegram β€” the bubble stays at its last
161
+ // edited content, becoming a vestigial "head" of the conversation.
162
+ async function discard() {
163
+ if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
164
+ if (flushPromise) { try { await flushPromise; } catch {} }
165
+ const idToDelete = msgId;
166
+ state = 'finalized';
167
+ msgId = null;
168
+ let deleted = false;
169
+ if (idToDelete && typeof deleteMessage === 'function') {
170
+ try {
171
+ await deleteMessage(idToDelete);
172
+ deleted = true;
173
+ } catch (err) {
174
+ // Telegram rejects deletions of messages older than 48h or
175
+ // already-deleted ones. Non-fatal β€” the redelivery happens
176
+ // either way.
177
+ logger.warn?.(`[stream] discard deleteMessage failed: ${err.message}`);
178
+ }
179
+ }
180
+ return { msgId: idToDelete, deleted };
181
+ }
182
+
183
+ // 0.7.0: snapshot for callers that want to track the bubble's id
184
+ // for later cleanup (e.g. archive a superseded preview when
185
+ // forceNewMessage was called and the previous bubble should be
186
+ // deleted at end-of-turn).
187
+ function archive() {
188
+ return { msgId, currentText };
189
+ }
190
+
191
+ // 0.7.0: rich result. `finalEditOk` tells caller whether the preview
192
+ // can stand as the final reply (true) or needs to be replaced via
193
+ // discard + deliverReplies (false). `overflow` is the one specific
194
+ // reason: body wouldn't fit in a single Telegram message.
108
195
  async function finalize(finalText, { errorSuffix = null } = {}) {
109
- if (state === 'finalized') return { streamed: false, msgId };
196
+ if (state === 'finalized') return { streamed: false, msgId, finalEditOk: false, overflow: false };
110
197
  if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
111
198
  if (flushPromise) { try { await flushPromise; } catch {} }
112
199
 
113
200
  if (state === 'idle') {
114
201
  state = 'finalized';
115
- return { streamed: false, msgId: null };
202
+ return { streamed: false, msgId: null, finalEditOk: false, overflow: false };
116
203
  }
117
204
 
118
- // live β†’ finalize: one last edit with the full answer.
205
+ // live β†’ finalize.
119
206
  state = 'finalized';
120
207
  let body = finalText ?? latestText;
121
208
  if (errorSuffix) body = `${body}\n\n⚠️ ${errorSuffix}`;
122
- const next = truncate(body);
123
- if (next !== currentText) {
124
- try { await edit(msgId, next); currentText = next; }
125
- catch (err) { logger.error(`[stream] final edit failed: ${err.message}`); }
209
+
210
+ // If body overflows the single-message cap, the caller needs to
211
+ // discard this bubble and redeliver via chunks. Don't try to edit.
212
+ if (body.length > maxLen) {
213
+ return { streamed: true, msgId, finalText: body, finalEditOk: false, overflow: true };
214
+ }
215
+
216
+ // Body fits. Try one last edit to bring the bubble to the final
217
+ // text. If that succeeds, preview-IS-final and caller can return
218
+ // without redelivering. If it fails (e.g. parse error after our
219
+ // wrapper exhausts its retry, or a 5xx), caller should discard
220
+ // and redeliver β€” the bubble's content is unreliable.
221
+ if (body === currentText) {
222
+ // Already correct β€” no edit needed.
223
+ return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
224
+ }
225
+ try {
226
+ await edit(msgId, body);
227
+ currentText = body;
228
+ return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
229
+ } catch (err) {
230
+ logger.error(`[stream] final edit failed: ${err.message}`);
231
+ return { streamed: true, msgId, finalText: body, finalEditOk: false, overflow: false };
126
232
  }
127
- return { streamed: true, msgId, finalText: next };
128
233
  }
129
234
 
130
235
  return {
131
236
  onChunk,
132
237
  finalize,
238
+ flushDraft,
239
+ forceNewMessage,
240
+ discard,
241
+ archive,
133
242
  // Introspection for tests:
134
243
  get state() { return state; },
135
244
  get msgId() { return msgId; },