polygram 0.19.0 → 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.
@@ -171,11 +171,11 @@ const SUMMARY_TAG_RE = /<summary[^>]*>([\s\S]*?)<\/summary>/i;
171
171
  const HTML_TAG_STRIP_RE = /<[^>]+>/g;
172
172
  const DEFAULT_DETAILS_SUMMARY = 'Details';
173
173
 
174
- // ─── Media (photo / collage / slideshow) ─────────────────────────────
174
+ // ─── Media (photo / video / animation / wrappers) ────────────────────
175
175
  //
176
- // Image markdown maps to Telegram photo blocks; <tg-collage>/<tg-slideshow>
177
- // wrapper tags (Telegram's own rich-markdown dialect) group images into
178
- // collage/slideshow blocks. The renderer emits internal DESCRIPTOR
176
+ // Image markdown maps to typed Telegram media blocks after resolution;
177
+ // <tg-collage>/<tg-slideshow> wrapper tags (Telegram's own rich-markdown
178
+ // dialect) group supported media into collage/slideshow blocks. The renderer emits internal DESCRIPTOR
179
179
  // blocks (_media/_mediaGroup) carrying only { src, caption } strings;
180
180
  // toTelegramRichBlocks substitutes them via the injected resolveMedia
181
181
  // (final mode) or with placeholder paragraphs (streaming/partial mode,
@@ -522,8 +522,8 @@ function blocksFromTokens(tokens) {
522
522
  // never a filesystem-derived basename, which for a rejected path would
523
523
  // echo the target filename into the visible chat.
524
524
  function mediaPlaceholderText(d, { unavailable = false } = {}) {
525
- if (unavailable) return d.caption ? `${d.caption} (image unavailable)` : '(image unavailable)';
526
- return d.caption ? `🖼 ${d.caption}` : '🖼 image';
525
+ if (unavailable) return d.caption ? `${d.caption} (media unavailable)` : '(media unavailable)';
526
+ return d.caption ? `📎 ${d.caption}` : '📎 media';
527
527
  }
528
528
 
529
529
  // Depth-first, document-order collection of media descriptors across
@@ -546,13 +546,16 @@ function collectMediaDescriptors(blocks, list = []) {
546
546
  return list;
547
547
  }
548
548
 
549
- function photoBlockFrom(result, caption) {
550
- const block = { type: 'photo', photo: { type: 'photo', media: result.media } };
549
+ const MEDIA_BLOCK_KINDS = new Set(['photo', 'video', 'animation']);
550
+
551
+ function mediaBlockFrom(result, caption) {
552
+ const kind = MEDIA_BLOCK_KINDS.has(result?.kind) ? result.kind : 'photo';
553
+ const block = { type: kind, [kind]: { type: kind, media: result.media } };
551
554
  if (caption) block.caption = { text: caption };
552
555
  return block;
553
556
  }
554
557
 
555
- // Replace descriptor blocks with real photo/collage/slideshow blocks
558
+ // Replace descriptor blocks with real media/wrapper blocks
556
559
  // (when `results` — index-aligned with collectMediaDescriptors order —
557
560
  // is provided) or with placeholder paragraphs (streaming / no resolver).
558
561
  // `cursor` threads the shared descriptor index through the recursion.
@@ -562,27 +565,30 @@ function substituteMediaBlocks(blocks, results, cursor = { i: 0 }) {
562
565
  if (b.type === '_media') {
563
566
  const r = results ? results[cursor.i] : null;
564
567
  cursor.i += 1;
565
- if (r && r.media) out.push(photoBlockFrom(r, b.caption));
568
+ if (r && r.media) out.push(mediaBlockFrom(r, b.caption));
566
569
  else if (results) out.push({ type: 'paragraph', text: mediaPlaceholderText(b, { unavailable: true }) });
567
570
  else out.push({ type: 'paragraph', text: mediaPlaceholderText(b) });
568
571
  } else if (b.type === '_mediaGroup') {
569
572
  if (!results) {
570
573
  cursor.i += b.items.length;
571
- out.push({ type: 'paragraph', text: `🖼 ${b.kind} (${b.items.length} images)` });
574
+ out.push({ type: 'paragraph', text: `📎 ${b.kind} (${b.items.length} media items)` });
572
575
  continue;
573
576
  }
574
- const photos = [];
577
+ const mediaBlocks = [];
575
578
  let unavailable = 0;
576
579
  for (const it of b.items) {
577
580
  const r = results[cursor.i];
578
581
  cursor.i += 1;
579
- if (r && r.media) photos.push(photoBlockFrom(r, it.caption));
582
+ if (r && r.media) mediaBlocks.push(mediaBlockFrom(r, it.caption));
580
583
  else unavailable += 1;
581
584
  }
582
- if (photos.length === 1) out.push(photos[0]);
583
- else if (photos.length > 1) out.push({ type: b.kind, blocks: photos });
585
+ if (mediaBlocks.length === 1) out.push(mediaBlocks[0]);
586
+ else if (mediaBlocks.length > 1) out.push({ type: b.kind, blocks: mediaBlocks });
584
587
  if (unavailable > 0) {
585
- out.push({ type: 'paragraph', text: `(${unavailable} image${unavailable === 1 ? '' : 's'} unavailable)` });
588
+ out.push({
589
+ type: 'paragraph',
590
+ text: `(${unavailable} media item${unavailable === 1 ? '' : 's'} unavailable)`,
591
+ });
586
592
  }
587
593
  } else if (Array.isArray(b.blocks) || Array.isArray(b.items)) {
588
594
  const clone = { ...b };
@@ -600,12 +606,12 @@ function substituteMediaBlocks(blocks, results, cursor = { i: 0 }) {
600
606
  return out;
601
607
  }
602
608
 
603
- // Count media attachments in an emitted block tree (photo blocks plus
604
- // collage/slideshow children) — the media_count instrumentation field.
609
+ // Count media attachments in an emitted block tree, including wrapper
610
+ // and container children — the media_count instrumentation field.
605
611
  function countMediaBlocks(blocks) {
606
612
  let n = 0;
607
613
  for (const b of blocks || []) {
608
- if (b.type === 'photo') n += 1;
614
+ if (MEDIA_BLOCK_KINDS.has(b.type)) n += 1;
609
615
  else if (b.type === 'collage' || b.type === 'slideshow') n += countMediaBlocks(b.blocks);
610
616
  else {
611
617
  if (Array.isArray(b.blocks)) n += countMediaBlocks(b.blocks);
@@ -664,7 +670,8 @@ function stripMediaMarkdown(markdown) {
664
670
  clean = clean.replace(TRAILING_IMAGE_FRAGMENT_RE, '');
665
671
  out.push(clean);
666
672
  }
667
- return out.join('\n');
673
+ const projected = out.join('\n');
674
+ return projected.trim() ? projected : '';
668
675
  }
669
676
 
670
677
  /**
@@ -681,11 +688,12 @@ function stripMediaMarkdown(markdown) {
681
688
  * than nothing).
682
689
  *
683
690
  * opts.resolveMedia (descriptors: [{src, caption}]) -> results[] —
684
- * index-aligned with the descriptors; each result is `{ media }` (an
685
- * https URL string or a JSON-safe `{ source: '<realpath>' }` envelope
686
- * that rich-edit.js later materializes into a grammy InputFile) or a
687
- * falsy/rejected marker. NEVER called in partial mode — streaming ticks
688
- * must not touch the filesystem; images render as placeholder
691
+ * index-aligned with the descriptors; each accepted result is
692
+ * `{ kind, media }`, where media is an https URL string or a JSON-safe
693
+ * `{ source, fingerprint, fileId? }` envelope that rich-edit.js validates
694
+ * before using a cached ID or materializing a grammy InputFile. Rejections
695
+ * carry `{ rejected }`. NEVER called in partial mode — streaming ticks
696
+ * must not touch the filesystem; media renders as placeholder
689
697
  * paragraphs until finalize. Absent resolver → placeholders too.
690
698
  * When media was the ONLY gate trigger and none of it resolved, the
691
699
  * whole render demotes to `usedRich: false` so prose doesn't ship as
@@ -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.19.0",
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": {