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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/process/channels-tool-dispatcher.js +3 -0
- package/lib/telegram/display-hint.js +4 -0
- package/lib/telegram/rich-edit.js +172 -12
- package/lib/telegram/rich-media.js +616 -0
- package/lib/telegram/rich.js +409 -11
- package/lib/telegram/streamer.js +156 -19
- package/package.json +1 -1
- package/polygram.js +137 -33
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": {
|
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
|
-
|
|
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 "'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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `` 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
|
-
|
|
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
|
-
//
|
|
1743
|
-
// for
|
|
1744
|
-
// that signal, so leave the reactor as-is in that case.
|
|
1745
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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);
|