polygram 0.18.2 → 0.20.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.
@@ -45,7 +45,7 @@ const DEFAULT_THROTTLE_MS = 1000;
45
45
  const THROTTLE_FLOOR_MS = 250;
46
46
 
47
47
  function createStreamer({
48
- send, // async (text) -> { message_id }
48
+ send, // async (text) -> { message_id, _hadReplyAnchor? }
49
49
  edit, // async (msg_id, text) -> void
50
50
  deleteMessage = null, // async (msg_id) -> void [optional]
51
51
  minChars = DEFAULT_MIN_CHARS,
@@ -87,13 +87,17 @@ function createStreamer({
87
87
  // Optional progressive rich-message rendering. A null callback keeps
88
88
  // the streamer on its plain-text path.
89
89
  //
90
- // toRichPayload(text, { partial }) -> { blocks, usedRich } | null
90
+ // toRichPayload(text, { partial }) ->
91
+ // { blocks, usedRich, rescueEntries? } | null
91
92
  // Called on every flush/finalize with the CURRENT latestText/finalText
92
93
  // (untruncated — rich content is never char-truncated with a "..."
93
94
  // the way plain live-preview text is; partial:true tells it to hold
94
95
  // back a possibly-incomplete trailing block, see lib/telegram/rich.js).
95
96
  // usedRich:false (or a null return) means "not rich this tick" — the
96
97
  // streamer falls through to the existing plain-text path unchanged.
98
+ // Final media-bearing payloads include rescueEntries. The streamer
99
+ // carries them into redelivery results and uses their presence to
100
+ // decide whether a detached intermediate bubble needs sealing.
97
101
  // The callback may return a different result on each call when live
98
102
  // configuration or capability state changes.
99
103
  toRichPayload = null,
@@ -107,6 +111,7 @@ function createStreamer({
107
111
  throttleMs = Math.max(THROTTLE_FLOOR_MS, throttleMs);
108
112
  let state = 'idle'; // 'idle' | 'live' | 'finalized'
109
113
  let msgId = null;
114
+ let hadReplyAnchor = false;
110
115
  let currentText = ''; // what's on screen right now (truncated to maxLen) — plain-path only
111
116
  let currentRichJson = null; // JSON.stringify of the last-sent rich blocks, or null when not in rich mode
112
117
  let latestText = ''; // latest we've been told about
@@ -114,6 +119,8 @@ function createStreamer({
114
119
  let pendingEdit = null; // timer id
115
120
  let flushPromise = null; // serialized flush chain (for back-pressure)
116
121
  let flushQueued = false; // latest text changed while a flush was active
122
+ let initialSendPromise = null; // active generation's initial send
123
+ let sealChain = Promise.resolve(); // detached bubbles, in boundary order
117
124
  let bubbleGeneration = 0; // invalidates state commits from superseded bubbles
118
125
  // 0.7.2: msg_ids of bubbles that have been superseded by
119
126
  // forceNewMessage(). The caller (polygram.js handleMessage at
@@ -175,21 +182,36 @@ function createStreamer({
175
182
  // delivered via the caller's normal path on finalize().
176
183
  if (state === 'idle') {
177
184
  if (text.length < minChars) return;
185
+ const generation = bubbleGeneration;
178
186
  state = 'live';
179
187
  currentText = truncateForLive(text);
188
+ let sendPromise;
180
189
  try {
181
- const res = await send(currentText);
190
+ sendPromise = Promise.resolve(send(currentText));
191
+ } catch (err) {
192
+ sendPromise = Promise.reject(err);
193
+ }
194
+ initialSendPromise = sendPromise;
195
+ try {
196
+ const res = await sendPromise;
197
+ if (generation !== bubbleGeneration) return;
182
198
  msgId = res?.message_id ?? null;
199
+ hadReplyAnchor = res?._hadReplyAnchor === true;
183
200
  lastEditTs = clock();
184
201
  if (msgId == null) {
185
202
  // Caller failed to get a message_id — revert to idle; finalize
186
203
  // will fall through to normal send path.
187
204
  state = 'idle';
188
205
  msgId = null;
206
+ hadReplyAnchor = false;
189
207
  }
190
208
  } catch (err) {
209
+ if (generation !== bubbleGeneration) return;
191
210
  logger.error(`[stream] initial send failed: ${err.message}`);
192
211
  state = 'idle';
212
+ hadReplyAnchor = false;
213
+ } finally {
214
+ if (initialSendPromise === sendPromise) initialSendPromise = null;
193
215
  }
194
216
  return;
195
217
  }
@@ -210,9 +232,9 @@ function createStreamer({
210
232
  // Decide the rich-vs-plain payload for the CURRENT latestText/finalText.
211
233
  // Shared by flush() and finalize() so the "only attempt rich under the
212
234
  // rich char cap" guard and the upgrade-event firing live in one place.
213
- function resolveRichPayload(text, { partial }) {
235
+ function resolveRichPayload(text, { partial, allowOverflow = false }) {
214
236
  if (!toRichPayload) return null;
215
- if (text.length > richMaxLen) {
237
+ if (text.length > richMaxLen && !allowOverflow) {
216
238
  // Edge case, not fully engineered (documented limitation): a
217
239
  // mid-stream reply that's ALREADY past the rich cap while still
218
240
  // growing. Rather than flap into a truncated/ellipsis rich render
@@ -241,11 +263,15 @@ function createStreamer({
241
263
  return flushPromise;
242
264
  }
243
265
 
266
+ const generation = bubbleGeneration;
244
267
  const run = (async () => {
245
268
  do {
246
269
  flushQueued = false;
247
270
  await flushOnce();
248
- } while (flushQueued && state === 'live' && msgId != null);
271
+ } while (flushQueued
272
+ && generation === bubbleGeneration
273
+ && state === 'live'
274
+ && msgId != null);
249
275
  })();
250
276
  flushPromise = run;
251
277
  try {
@@ -272,6 +298,7 @@ function createStreamer({
272
298
  // can render the same Markdown through the plain-text path.
273
299
  const editResult = await edit(editMsgId, {
274
300
  rich: true, blocks: richPayload.blocks, sourceText,
301
+ phase: 'preview', hadReplyAnchor,
275
302
  });
276
303
  if (generation !== bubbleGeneration || msgId !== editMsgId) return;
277
304
  // A rich editor can resolve successfully after sending the same
@@ -307,6 +334,62 @@ function createStreamer({
307
334
  }
308
335
  }
309
336
 
337
+ function appendSealJob(job, label = 'seal') {
338
+ const run = sealChain.then(job);
339
+ sealChain = run.catch((err) => {
340
+ const errorClass = err?.name || 'Error';
341
+ logger.error?.(`[stream] ${label} failed (${errorClass})`);
342
+ });
343
+ return sealChain;
344
+ }
345
+
346
+ async function drainSeals() {
347
+ while (true) {
348
+ const pending = sealChain;
349
+ await pending;
350
+ if (pending === sealChain) return;
351
+ }
352
+ }
353
+
354
+ async function awaitDetached(promise, label) {
355
+ try {
356
+ await promise;
357
+ } catch (err) {
358
+ logger.error?.(`[stream] ${label} failed (${err?.name || 'Error'})`);
359
+ }
360
+ }
361
+
362
+ async function sealBubble(snapshot) {
363
+ let targetMsgId = snapshot.msgId;
364
+ let targetHadReplyAnchor = snapshot.hadReplyAnchor;
365
+
366
+ if (snapshot.initialSendPromise) {
367
+ const res = await snapshot.initialSendPromise;
368
+ targetMsgId = res?.message_id ?? targetMsgId;
369
+ targetHadReplyAnchor = res?._hadReplyAnchor === true;
370
+ }
371
+ if (snapshot.flushPromise) {
372
+ await awaitDetached(snapshot.flushPromise, 'detached bubble flush');
373
+ }
374
+ if (targetMsgId == null || snapshot.sourceText.length > richMaxLen || !toRichPayload) return;
375
+
376
+ const payload = toRichPayload(snapshot.sourceText, { partial: false });
377
+ if (!payload?.usedRich || !Array.isArray(payload.rescueEntries)
378
+ || payload.rescueEntries.length === 0) {
379
+ return;
380
+ }
381
+ const json = JSON.stringify(payload.blocks);
382
+ if (json === snapshot.currentRichJson) return;
383
+
384
+ await edit(targetMsgId, {
385
+ rich: true,
386
+ blocks: payload.blocks,
387
+ sourceText: snapshot.sourceText,
388
+ phase: 'seal',
389
+ hadReplyAnchor: targetHadReplyAnchor,
390
+ });
391
+ }
392
+
310
393
  // 0.7.0: explicitly drain any pending edit. Useful when the caller
311
394
  // is about to make a finalize/discard decision and wants the bubble's
312
395
  // visual state to be accurate (no stale half-rendered text under a
@@ -314,6 +397,7 @@ function createStreamer({
314
397
  async function flushDraft() {
315
398
  if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; await flush(); }
316
399
  if (flushPromise) { try { await flushPromise; } catch {} }
400
+ await drainSeals();
317
401
  }
318
402
 
319
403
  // Reset bubble state so the next onChunk creates a NEW message.
@@ -330,18 +414,29 @@ function createStreamer({
330
414
  // rc.44 reverts to the 0.7.0 preserve-all default. Opt back into
331
415
  // the 0.7.2 behaviour with `preserveIntermediateBubbles: false`.
332
416
  //
333
- // When preserving, we still cancel the pending throttled edit (it
334
- // wouldn't fire after we transition to a new bubble anyway) but
335
- // there may be a recently-flushed edit in flight whose result we
336
- // don't await — the bubble will display whatever its last
337
- // successful edit landed, which is typically very close to the
338
- // segment's final text (throttle is 250ms; segments take seconds).
417
+ // The old bubble is detached synchronously. Preserved bubbles enqueue
418
+ // one final media-aware edit behind any request already targeting that
419
+ // message; terse bubbles retain late initial-send ids for cleanup.
339
420
  function forceNewMessage() {
340
421
  if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
341
- if (msgId != null && !preserveIntermediateBubbles) {
342
- archived.push(msgId);
343
- }
422
+ const snapshot = {
423
+ sourceText: latestText,
424
+ msgId,
425
+ hadReplyAnchor,
426
+ currentRichJson,
427
+ initialSendPromise,
428
+ flushPromise,
429
+ };
430
+ const hadOutgoingMessage = state === 'live'
431
+ && (snapshot.msgId != null || snapshot.initialSendPromise != null);
432
+
433
+ // Detach in-flight work before resetting. Conditional identity checks
434
+ // in onChunk()/flush() keep old completions from clearing new promises
435
+ // or committing state into the next generation.
436
+ initialSendPromise = null;
437
+ flushPromise = null;
344
438
  msgId = null;
439
+ hadReplyAnchor = false;
345
440
  currentText = '';
346
441
  currentRichJson = null; // new bubble starts plain — see toRichPayload doc
347
442
  latestText = '';
@@ -349,6 +444,27 @@ function createStreamer({
349
444
  lastEditTs = 0;
350
445
  flushQueued = false;
351
446
  bubbleGeneration += 1;
447
+
448
+ if (!hadOutgoingMessage) return;
449
+
450
+ if (preserveIntermediateBubbles) {
451
+ appendSealJob(() => sealBubble(snapshot));
452
+ return;
453
+ }
454
+
455
+ if (snapshot.msgId != null) archived.push(snapshot.msgId);
456
+ if (snapshot.initialSendPromise || snapshot.flushPromise) {
457
+ appendSealJob(async () => {
458
+ if (snapshot.initialSendPromise) {
459
+ const res = await snapshot.initialSendPromise;
460
+ const lateMsgId = res?.message_id ?? null;
461
+ if (lateMsgId != null) archived.push(lateMsgId);
462
+ }
463
+ if (snapshot.flushPromise) {
464
+ await awaitDetached(snapshot.flushPromise, 'detached bubble cleanup flush');
465
+ }
466
+ }, 'detached bubble cleanup');
467
+ }
352
468
  }
353
469
 
354
470
  // 0.7.0: delete the current bubble via the injected deleteMessage
@@ -365,6 +481,7 @@ function createStreamer({
365
481
  async function discard() {
366
482
  if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
367
483
  if (flushPromise) { try { await flushPromise; } catch {} }
484
+ await drainSeals();
368
485
  const idToDelete = msgId;
369
486
  state = 'finalized';
370
487
  msgId = null;
@@ -399,6 +516,7 @@ function createStreamer({
399
516
  if (state === 'finalized') return { streamed: false, msgId, finalEditOk: false, overflow: false };
400
517
  if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
401
518
  if (flushPromise) { try { await flushPromise; } catch {} }
519
+ await drainSeals();
402
520
 
403
521
  if (state === 'idle') {
404
522
  state = 'finalized';
@@ -414,15 +532,20 @@ function createStreamer({
414
532
  // apples-to-apples (currentText was already transformed in onChunk).
415
533
  let body = applyTransform(finalText ?? latestText);
416
534
  if (errorSuffix) body = `${body}\n\n⚠️ ${errorSuffix}`;
535
+ const finalHadReplyAnchor = hadReplyAnchor;
417
536
 
418
537
  // Finalization evaluates the complete body, so no trailing block is
419
538
  // held back as potentially incomplete.
420
- const richPayload = resolveRichPayload(body, { partial: false });
539
+ const richPayload = resolveRichPayload(body, { partial: false, allowOverflow: true });
421
540
  if (richPayload) {
422
541
  // Rich finalization uses the rich-message cap; applying the smaller
423
542
  // plain-message cap would unnecessarily redeliver valid rich bodies.
424
543
  if (body.length > richMaxLen) {
425
- return { streamed: true, msgId, finalText: body, finalEditOk: false, overflow: true };
544
+ return {
545
+ streamed: true, msgId, finalText: body, finalEditOk: false, overflow: true,
546
+ rescueEntries: richPayload.rescueEntries || [],
547
+ hadReplyAnchor: finalHadReplyAnchor,
548
+ };
426
549
  }
427
550
  const json = JSON.stringify(richPayload.blocks);
428
551
  if (json === currentRichJson) {
@@ -430,7 +553,10 @@ function createStreamer({
430
553
  }
431
554
  const wasRich = currentRichJson !== null;
432
555
  try {
433
- const editResult = await edit(msgId, { rich: true, blocks: richPayload.blocks, sourceText: body });
556
+ const editResult = await edit(msgId, {
557
+ rich: true, blocks: richPayload.blocks, sourceText: body,
558
+ phase: 'final', hadReplyAnchor: finalHadReplyAnchor,
559
+ });
434
560
  // A fallback still delivered the final content, but as plain text.
435
561
  const wentRich = editResult == null || editResult.wentRich !== false;
436
562
  if (wentRich) {
@@ -440,10 +566,20 @@ function createStreamer({
440
566
  currentText = body;
441
567
  currentRichJson = null;
442
568
  }
569
+ if (editResult?.bubbleRemoved) {
570
+ msgId = null;
571
+ currentText = '';
572
+ currentRichJson = null;
573
+ hadReplyAnchor = false;
574
+ }
443
575
  return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
444
576
  } catch (err) {
445
577
  logger.error(`[stream] final rich edit failed: ${err.message}`);
446
- return { streamed: true, msgId, finalText: body, finalEditOk: false, overflow: false };
578
+ return {
579
+ streamed: true, msgId, finalText: body, finalEditOk: false, overflow: false,
580
+ rescueEntries: richPayload.rescueEntries || [],
581
+ hadReplyAnchor: finalHadReplyAnchor,
582
+ };
447
583
  }
448
584
  }
449
585
 
@@ -485,6 +621,7 @@ function createStreamer({
485
621
  finalize,
486
622
  flushDraft,
487
623
  forceNewMessage,
624
+ drainSeals,
488
625
  discard,
489
626
  archive,
490
627
  getArchived,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.18.2",
3
+ "version": "0.20.0",
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
@@ -49,7 +49,7 @@ const { extractAssistantText } = require('@shumkov/orchestra');
49
49
  // and reused by createChannelsToolDispatcher inside main() — Claude replies
50
50
  // containing code blocks or HTML-style tags aren't split mid-element by the
51
51
  // size cap.
52
- const { createChannelsToolDispatcher } = require('./lib/process/channels-tool-dispatcher');
52
+ const { createChannelsToolDispatcher, buildAllowedRoots } = require('./lib/process/channels-tool-dispatcher');
53
53
  const { createTmuxRunner } = require('@shumkov/orchestra');
54
54
  const { sweepTmuxOrphans } = require('@shumkov/orchestra').orphanSweep;
55
55
  // rc.42: autosteer-buffer module deleted. Native SDK priority push
@@ -98,7 +98,16 @@ const { createStreamer } = require('./lib/telegram/streamer');
98
98
  const { chunkMarkdownText } = require('./lib/telegram/chunk');
99
99
  const {
100
100
  toTelegramRichBlocks, resolveRichTextEnabled, isRichCapabilityError, isRichContentError,
101
+ stripMediaMarkdown,
101
102
  } = require('./lib/telegram/rich');
103
+ const {
104
+ createRichMediaResolver,
105
+ createMediaDeliveryContext,
106
+ createMediaFileIdCache,
107
+ collectMediaRescueEntries,
108
+ PHOTO_UPLOAD_CEILING,
109
+ OTHER_MEDIA_UPLOAD_CEILING,
110
+ } = require('./lib/telegram/rich-media');
102
111
  const { createRichEditor } = require('./lib/telegram/rich-edit');
103
112
  const { buildPolygramDisplayHint } = require('./lib/telegram/display-hint');
104
113
  const { redactBotToken, stripUrlCredentials } = require('./lib/error/net');
@@ -321,6 +330,7 @@ let richKnownUnsupported = false;
321
330
  // safe to reference early because it's a stable function that does its
322
331
  // own live lookups internally when called.
323
332
  let richEditMessageText;
333
+ let richMediaFileIdCache;
324
334
 
325
335
  // 0.15 secret redaction (agent-flagged path): the agent marks a secret it saw
326
336
  // in the user's message with `[redact:<secret>]`. parseResponse / stripInlineTags
@@ -992,7 +1002,6 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
992
1002
  effort: chatConfig.effort,
993
1003
  ...(linkPreview === false ? { linkPreview: false } : {}),
994
1004
  };
995
-
996
1005
  // 0.7.2: only the FIRST bubble in a turn quotes the user's message
997
1006
  // via reply_parameters. When a tool-heavy turn produces multiple
998
1007
  // assistant messages (each spawning its own bubble via
@@ -1018,9 +1027,77 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1018
1027
  // content qualifies. Those delivery paths intentionally remain plain.
1019
1028
  // Re-read both controls for every flush so a config change or a
1020
1029
  // capability result applies to the turn already in progress.
1030
+ // Typed media resolution for rich replies. Same
1031
+ // trust boundary as reply(files): roots are the per-session staging
1032
+ // dir plus the session's spawn cwd (topic override wins, mirroring
1033
+ // getSessionSpawnIdentity) — path-set parity with the channels
1034
+ // dispatcher, which builds its roots from the same inputs. URL media
1035
+ // is disabled when a self-hosted Bot API server is configured (its
1036
+ // URL-fetch origin is unverified — see lib/telegram/rich-media.js).
1037
+ const _mediaTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
1038
+ const mediaAllowedRoots = buildAllowedRoots({
1039
+ sessionKey,
1040
+ sessionCwd: _mediaTopicCfg.cwd || chatConfig.cwd || null,
1041
+ });
1042
+ const maxMediaOverride = resolveMaxFileOverride(config, chatId, threadId);
1043
+ const resolveRichMedia = createRichMediaResolver({
1044
+ allowedRoots: mediaAllowedRoots,
1045
+ fileIdCache: richMediaFileIdCache,
1046
+ maxPhotoBytes: Math.min(
1047
+ PHOTO_UPLOAD_CEILING,
1048
+ maxMediaOverride ?? PHOTO_UPLOAD_CEILING,
1049
+ ),
1050
+ maxOtherMediaBytes: Math.min(
1051
+ OTHER_MEDIA_UPLOAD_CEILING,
1052
+ maxMediaOverride ?? OTHER_MEDIA_UPLOAD_CEILING,
1053
+ ),
1054
+ allowUrlMedia: !config.bot?.apiRoot,
1055
+ logEvent: (kind, detail) => logEvent(kind, {
1056
+ chat_id: chatId, thread_id: threadId, bot: BOT_NAME, ...detail,
1057
+ }),
1058
+ });
1021
1059
  const toRichPayload = (text, opts) => {
1022
1060
  if (richKnownUnsupported || !resolveRichTextEnabled(config, chatId, threadId)) return null;
1023
- return toTelegramRichBlocks(text, opts);
1061
+ const payload = toTelegramRichBlocks(text, { ...opts, resolveMedia: resolveRichMedia });
1062
+ if (!opts.partial && payload?.usedRich) {
1063
+ return {
1064
+ ...payload,
1065
+ rescueEntries: collectMediaRescueEntries(payload.blocks),
1066
+ };
1067
+ }
1068
+ return payload;
1069
+ };
1070
+ const mediaContext = createMediaDeliveryContext({
1071
+ allowedRoots: mediaAllowedRoots,
1072
+ fileIdCache: richMediaFileIdCache,
1073
+ tg,
1074
+ bot,
1075
+ chatId,
1076
+ threadId,
1077
+ replyToMessageId: msg.message_id,
1078
+ botName: BOT_NAME,
1079
+ logEvent: (kind, detail) => logEvent(kind, {
1080
+ chat_id: chatId, thread_id: threadId, bot: BOT_NAME, ...detail,
1081
+ }),
1082
+ setDeliveryError: () => reactor?.setState('ERROR'),
1083
+ logger: { warn: (m) => console.error(`[${label}] ${m}`) },
1084
+ });
1085
+ // Plain-path text hygiene for rich-enabled chats: the live preview
1086
+ // streams PLAIN until the rich gate trips (and stays plain if the
1087
+ // capability latch fires), so a reply's image markdown — including a
1088
+ // still-incomplete trailing "![shot](/abs/pa…" fragment — would
1089
+ // otherwise render its raw local path into the visible bubble.
1090
+ // Degrade media markdown to caption text on every plain send/edit.
1091
+ // The streamer's internal dedup state keeps the UNsanitized text on
1092
+ // both sides of its comparisons, so this stays a display-only
1093
+ // transform (same class as tg()'s markdown→HTML conversion).
1094
+ const sanitizeLiveText = (text) => {
1095
+ if (!resolveRichTextEnabled(config, chatId, threadId)) return text;
1096
+ const clean = stripMediaMarkdown(text);
1097
+ // An image-only chunk can sanitize to nothing; keep the bubble
1098
+ // alive with a minimal placeholder rather than tripping tg()'s
1099
+ // empty-text guard.
1100
+ return clean.trim() ? clean : '…';
1024
1101
  };
1025
1102
 
1026
1103
  const streamer = createStreamer({
@@ -1035,8 +1112,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1035
1112
  logEvent('rich-streaming-upgrade', { chat_id: chatId, thread_id: threadId, bot: BOT_NAME });
1036
1113
  },
1037
1114
  send: async (text) => {
1115
+ const hadReplyAnchor = !firstBubbleSent;
1038
1116
  const params = {
1039
- chat_id: chatId, text,
1117
+ chat_id: chatId, text: sanitizeLiveText(text),
1040
1118
  ...(threadId && { message_thread_id: threadId }),
1041
1119
  };
1042
1120
  if (!firstBubbleSent) {
@@ -1047,7 +1125,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1047
1125
  params.reply_parameters = { message_id: msg.message_id, allow_sending_without_reply: true };
1048
1126
  firstBubbleSent = true;
1049
1127
  }
1050
- return tg(bot, 'sendMessage', params, outMetaBase);
1128
+ const res = await tg(bot, 'sendMessage', params, outMetaBase);
1129
+ return res && typeof res === 'object'
1130
+ ? { ...res, _hadReplyAnchor: hadReplyAnchor }
1131
+ : res;
1051
1132
  },
1052
1133
  edit: async (messageId, payload) => {
1053
1134
  // The streamer emits either a plain string or a rich payload with
@@ -1057,6 +1138,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1057
1138
  return richEditMessageText({
1058
1139
  bot, chatId, threadId, messageId,
1059
1140
  blocks: payload.blocks, sourceText: payload.sourceText,
1141
+ phase: payload.phase,
1142
+ mediaContext,
1143
+ hadReplyAnchor: payload.hadReplyAnchor,
1060
1144
  });
1061
1145
  }
1062
1146
  const text = payload;
@@ -1070,7 +1154,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1070
1154
  return await tg(bot, 'editMessageText', {
1071
1155
  chat_id: chatId,
1072
1156
  message_id: messageId,
1073
- text,
1157
+ text: sanitizeLiveText(text),
1074
1158
  }, { source: 'bot-reply-stream-edit', botName: BOT_NAME });
1075
1159
  } catch (err) {
1076
1160
  // Stream-edit failures would otherwise be invisible — edits
@@ -1078,7 +1162,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1078
1162
  // want the failure path specifically surfaced). Log to events.
1079
1163
  logEvent('telegram-edit-failed', {
1080
1164
  chat_id: chatId, msg_id: messageId,
1081
- api_error: err.message?.slice(0, 200),
1165
+ // Network errors can embed the request URL, which carries the
1166
+ // bot token — redact before persisting, matching rich-edit.js
1167
+ // and api.js's handling of the same error class.
1168
+ api_error: redactBotToken(err.message)?.slice(0, 200),
1082
1169
  bot: BOT_NAME,
1083
1170
  });
1084
1171
  throw err;
@@ -1674,6 +1761,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1674
1761
  if (fin.finalEditOk) {
1675
1762
  // Preview was successfully edited to the final text.
1676
1763
  // No follow-up messages needed.
1764
+ await mediaContext.flushPartialDeliveryWarning(outMetaBase);
1677
1765
  await sendInlineStickers();
1678
1766
  await sendInlineReactions();
1679
1767
  await cleanupArchivedBubbles();
@@ -1685,7 +1773,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1685
1773
  // stuck. reactor.stop() in the finally only kills timers,
1686
1774
  // not the visible reaction. Clear here, mirroring the
1687
1775
  // rc.10 block — AFTER delivery so there's no visual gap.
1688
- reactor.clear().catch(() => {});
1776
+ if (!mediaContext.deliveryIncomplete) {
1777
+ reactor.clear().catch(() => {});
1778
+ }
1689
1779
  clearAutosteeredReactions(sessionKey).catch(() => {});
1690
1780
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1691
1781
  markReplied();
@@ -1694,9 +1784,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1694
1784
  // Preview can't hold the final body (overflow OR last edit
1695
1785
  // failed even after our HTML→plain fallback). Delete it and
1696
1786
  // send the body as proper chunks.
1697
- try { await streamer.discard(); }
1787
+ let discarded = { msgId: fin.msgId, deleted: false };
1788
+ try { discarded = await streamer.discard(); }
1698
1789
  catch (err) { console.error(`[${label}] discard failed: ${err.message}`); }
1699
- const chunks = chunkMarkdownText(parsed.text, TG_CHUNK_BUDGET);
1790
+ if (discarded.msgId != null && !discarded.deleted) {
1791
+ mediaContext.recordDeletionFailures(1);
1792
+ }
1793
+ // Rich-enabled chats may carry media markdown; plain chunks cannot
1794
+ // render it, and a raw `![cap](/abs/path)` would leak a local path.
1795
+ // Degrade the chunk text to captions before the validated media
1796
+ // entries are redelivered as ordered sidecars below.
1797
+ const redeliverText = resolveRichTextEnabled(config, chatId, threadId)
1798
+ ? stripMediaMarkdown(parsed.text)
1799
+ : parsed.text;
1800
+ const chunks = chunkMarkdownText(redeliverText, TG_CHUNK_BUDGET);
1700
1801
  const r = await deliverReplies({
1701
1802
  bot,
1702
1803
  send: (b, method, params, m) => tg(b, method, params, m),
@@ -1708,29 +1809,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1708
1809
  logger: { error: (m) => console.error(`[${label}] ${m}`) },
1709
1810
  });
1710
1811
  const reason = fin.overflow ? 'overflow' : 'edit-failed';
1812
+ mediaContext.recordTextFailures(r.failed.length);
1813
+ const mediaResult = await mediaContext.rescueEntries(fin.rescueEntries, {
1814
+ trigger: reason,
1815
+ anchorFirst: chunks.length === 0 && discarded.deleted && fin.hadReplyAnchor,
1816
+ meta: outMeta,
1817
+ });
1711
1818
  logEvent('telegram-stream-redeliver', {
1712
1819
  chat_id: chatId, msg_id: msg.message_id,
1713
1820
  reason, chunks: chunks.length,
1714
1821
  delivered: r.sent.length, failed: r.failed.length,
1822
+ media_delivered: mediaResult.sent, media_failed: mediaResult.failed,
1715
1823
  bot: BOT_NAME,
1716
1824
  });
1717
- // 0.7.1: surface partial-failure to the user. Without this,
1718
- // a chunk-3-of-5 failure leaves a coherent-looking reply with
1719
- // a silent gap (the user reads chunks 1, 2, 4, 5 unaware
1720
- // that chunk 3 was dropped). Append a warning + flip the
1721
- // reactor to ERROR so something visible signals "look here".
1722
- if (r.failed.length > 0) {
1723
- reactor.setState('ERROR');
1724
- try {
1725
- await tg(bot, 'sendMessage', {
1726
- chat_id: chatId,
1727
- text: `⚠️ ${r.failed.length} of ${chunks.length} message parts failed to deliver. The reply may be incomplete — please retry.`,
1728
- ...(threadId && { message_thread_id: threadId }),
1729
- }, { ...outMetaBase, source: 'partial-delivery-warning' });
1730
- } catch (warnErr) {
1731
- console.error(`[${label}] partial-delivery warning failed: ${warnErr.message}`);
1732
- }
1733
- }
1825
+ await mediaContext.flushPartialDeliveryWarning(outMetaBase);
1734
1826
  await sendInlineStickers();
1735
1827
  await sendInlineReactions();
1736
1828
  await cleanupArchivedBubbles();
@@ -1739,10 +1831,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1739
1831
  // the rc.10 deferred-clear block, so the reactor would stay
1740
1832
  // stuck. Clear it (and autosteered ✍) here, after delivery —
1741
1833
  // but ONLY on a clean delivery. When r.failed.length>0 the
1742
- // ERROR state (😨) was set above as the "look here" signal
1743
- // for the partial-delivery failure; clearing it would wipe
1744
- // that signal, so leave the reactor as-is in that case.
1745
- if (r.failed.length === 0) {
1834
+ // The aggregate warning sets ERROR as the "look here" signal
1835
+ // for any text, media, or cleanup failure. Clearing it would
1836
+ // wipe that signal, so leave the reactor as-is in that case.
1837
+ if (!mediaContext.deliveryIncomplete) {
1746
1838
  reactor.clear().catch(() => {});
1747
1839
  }
1748
1840
  clearAutosteeredReactions(sessionKey).catch(() => {});
@@ -1774,7 +1866,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1774
1866
  // 0.7.0: use markdown-aware chunker + deliverReplies primitive.
1775
1867
  // The old chunkText was newline/byte-only; chunkMarkdownText also
1776
1868
  // respects code-fence boundaries (closes + reopens across chunks).
1777
- const chunks = chunkMarkdownText(parsed.text, TG_CHUNK_BUDGET);
1869
+ // Short replies never went live-rich, so image markdown from a
1870
+ // rich-enabled chat degrades to caption text here too — never a
1871
+ // raw local path in the bubble.
1872
+ const shortText = resolveRichTextEnabled(config, chatId, threadId)
1873
+ ? stripMediaMarkdown(parsed.text)
1874
+ : parsed.text;
1875
+ const chunks = chunkMarkdownText(shortText, TG_CHUNK_BUDGET);
1778
1876
  await deliverReplies({
1779
1877
  bot,
1780
1878
  send: (b, method, params, m) => tg(b, method, params, m),
@@ -1787,8 +1885,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1787
1885
  });
1788
1886
  }
1789
1887
 
1888
+ await mediaContext.flushPartialDeliveryWarning(outMetaBase);
1790
1889
  await sendInlineStickers();
1791
1890
  await sendInlineReactions();
1891
+ await cleanupArchivedBubbles();
1792
1892
  // rc.10: clear progress reactions AFTER the reply has been
1793
1893
  // delivered so the user doesn't see a "reactions cleared, then
1794
1894
  // ~1-3s of nothing, then reply bubble" gap. The reply bubble
@@ -1796,7 +1896,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1796
1896
  // with the delivery completion is the smooth UX path. Both
1797
1897
  // fire-and-forget — these are best-effort cleanups, not part of
1798
1898
  // the reply contract.
1799
- reactor.clear().catch(() => {});
1899
+ if (!mediaContext.deliveryIncomplete) {
1900
+ reactor.clear().catch(() => {});
1901
+ }
1800
1902
  // 0.8.0-rc.14: also clear ✍ reactions on every follow-up
1801
1903
  // message that was autosteered into THIS turn — they live in
1802
1904
  // separate handleMessage scopes whose reactors are already GC'd.
@@ -2337,6 +2439,7 @@ async function main() {
2337
2439
  db = dbClient.open(DB_PATH);
2338
2440
  console.log(`[db] opened ${DB_PATH}`);
2339
2441
  tg = createSender(db, console, config);
2442
+ richMediaFileIdCache = createMediaFileIdCache();
2340
2443
  richEditMessageText = createRichEditor({
2341
2444
  tg,
2342
2445
  botName: BOT_NAME,
@@ -2348,6 +2451,7 @@ async function main() {
2348
2451
  setRichKnownUnsupported: () => { richKnownUnsupported = true; },
2349
2452
  getApiRoot: () => config.bot?.apiRoot || null,
2350
2453
  stripUrlCreds: stripUrlCredentials,
2454
+ sanitizeFallbackText: stripMediaMarkdown,
2351
2455
  });
2352
2456
  pairings = createPairingsStore(db.raw);
2353
2457
  approvals = createApprovalsStore(db.raw);