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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/telegram/display-hint.js +2 -2
- package/lib/telegram/rich-edit.js +132 -26
- package/lib/telegram/rich-media.js +480 -26
- package/lib/telegram/rich.js +33 -25
- package/lib/telegram/streamer.js +156 -19
- package/package.json +1 -1
- package/polygram.js +84 -38
package/lib/telegram/rich.js
CHANGED
|
@@ -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 /
|
|
174
|
+
// ─── Media (photo / video / animation / wrappers) ────────────────────
|
|
175
175
|
//
|
|
176
|
-
// Image markdown maps to Telegram
|
|
177
|
-
// wrapper tags (Telegram's own rich-markdown
|
|
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} (
|
|
526
|
-
return d.caption ?
|
|
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
|
-
|
|
550
|
-
|
|
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
|
|
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(
|
|
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:
|
|
574
|
+
out.push({ type: 'paragraph', text: `📎 ${b.kind} (${b.items.length} media items)` });
|
|
572
575
|
continue;
|
|
573
576
|
}
|
|
574
|
-
const
|
|
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)
|
|
582
|
+
if (r && r.media) mediaBlocks.push(mediaBlockFrom(r, it.caption));
|
|
580
583
|
else unavailable += 1;
|
|
581
584
|
}
|
|
582
|
-
if (
|
|
583
|
-
else if (
|
|
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({
|
|
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
|
|
604
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
685
|
-
* https URL string or a JSON-safe
|
|
686
|
-
* that rich-edit.js
|
|
687
|
-
*
|
|
688
|
-
*
|
|
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
|
package/lib/telegram/streamer.js
CHANGED
|
@@ -45,7 +45,7 @@ const DEFAULT_THROTTLE_MS = 1000;
|
|
|
45
45
|
const THROTTLE_FLOOR_MS = 250;
|
|
46
46
|
|
|
47
47
|
function createStreamer({
|
|
48
|
-
send,
|
|
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 }) ->
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
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
|
-
|
|
342
|
-
|
|
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 {
|
|
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, {
|
|
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 {
|
|
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.
|
|
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": {
|