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/polygram.js
CHANGED
|
@@ -100,7 +100,14 @@ const {
|
|
|
100
100
|
toTelegramRichBlocks, resolveRichTextEnabled, isRichCapabilityError, isRichContentError,
|
|
101
101
|
stripMediaMarkdown,
|
|
102
102
|
} = require('./lib/telegram/rich');
|
|
103
|
-
const {
|
|
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');
|
|
104
111
|
const { createRichEditor } = require('./lib/telegram/rich-edit');
|
|
105
112
|
const { buildPolygramDisplayHint } = require('./lib/telegram/display-hint');
|
|
106
113
|
const { redactBotToken, stripUrlCredentials } = require('./lib/error/net');
|
|
@@ -323,6 +330,7 @@ let richKnownUnsupported = false;
|
|
|
323
330
|
// safe to reference early because it's a stable function that does its
|
|
324
331
|
// own live lookups internally when called.
|
|
325
332
|
let richEditMessageText;
|
|
333
|
+
let richMediaFileIdCache;
|
|
326
334
|
|
|
327
335
|
// 0.15 secret redaction (agent-flagged path): the agent marks a secret it saw
|
|
328
336
|
// in the user's message with `[redact:<secret>]`. parseResponse / stripInlineTags
|
|
@@ -994,7 +1002,6 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
994
1002
|
effort: chatConfig.effort,
|
|
995
1003
|
...(linkPreview === false ? { linkPreview: false } : {}),
|
|
996
1004
|
};
|
|
997
|
-
|
|
998
1005
|
// 0.7.2: only the FIRST bubble in a turn quotes the user's message
|
|
999
1006
|
// via reply_parameters. When a tool-heavy turn produces multiple
|
|
1000
1007
|
// assistant messages (each spawning its own bubble via
|
|
@@ -1020,7 +1027,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1020
1027
|
// content qualifies. Those delivery paths intentionally remain plain.
|
|
1021
1028
|
// Re-read both controls for every flush so a config change or a
|
|
1022
1029
|
// capability result applies to the turn already in progress.
|
|
1023
|
-
//
|
|
1030
|
+
// Typed media resolution for rich replies. Same
|
|
1024
1031
|
// trust boundary as reply(files): roots are the per-session staging
|
|
1025
1032
|
// dir plus the session's spawn cwd (topic override wins, mirroring
|
|
1026
1033
|
// getSessionSpawnIdentity) — path-set parity with the channels
|
|
@@ -1028,14 +1035,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1028
1035
|
// is disabled when a self-hosted Bot API server is configured (its
|
|
1029
1036
|
// URL-fetch origin is unverified — see lib/telegram/rich-media.js).
|
|
1030
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);
|
|
1031
1043
|
const resolveRichMedia = createRichMediaResolver({
|
|
1032
|
-
allowedRoots:
|
|
1033
|
-
|
|
1034
|
-
sessionCwd: _mediaTopicCfg.cwd || chatConfig.cwd || null,
|
|
1035
|
-
}),
|
|
1044
|
+
allowedRoots: mediaAllowedRoots,
|
|
1045
|
+
fileIdCache: richMediaFileIdCache,
|
|
1036
1046
|
maxPhotoBytes: Math.min(
|
|
1037
1047
|
PHOTO_UPLOAD_CEILING,
|
|
1038
|
-
|
|
1048
|
+
maxMediaOverride ?? PHOTO_UPLOAD_CEILING,
|
|
1049
|
+
),
|
|
1050
|
+
maxOtherMediaBytes: Math.min(
|
|
1051
|
+
OTHER_MEDIA_UPLOAD_CEILING,
|
|
1052
|
+
maxMediaOverride ?? OTHER_MEDIA_UPLOAD_CEILING,
|
|
1039
1053
|
),
|
|
1040
1054
|
allowUrlMedia: !config.bot?.apiRoot,
|
|
1041
1055
|
logEvent: (kind, detail) => logEvent(kind, {
|
|
@@ -1044,8 +1058,30 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1044
1058
|
});
|
|
1045
1059
|
const toRichPayload = (text, opts) => {
|
|
1046
1060
|
if (richKnownUnsupported || !resolveRichTextEnabled(config, chatId, threadId)) return null;
|
|
1047
|
-
|
|
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;
|
|
1048
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
|
+
});
|
|
1049
1085
|
// Plain-path text hygiene for rich-enabled chats: the live preview
|
|
1050
1086
|
// streams PLAIN until the rich gate trips (and stays plain if the
|
|
1051
1087
|
// capability latch fires), so a reply's image markdown — including a
|
|
@@ -1076,6 +1112,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1076
1112
|
logEvent('rich-streaming-upgrade', { chat_id: chatId, thread_id: threadId, bot: BOT_NAME });
|
|
1077
1113
|
},
|
|
1078
1114
|
send: async (text) => {
|
|
1115
|
+
const hadReplyAnchor = !firstBubbleSent;
|
|
1079
1116
|
const params = {
|
|
1080
1117
|
chat_id: chatId, text: sanitizeLiveText(text),
|
|
1081
1118
|
...(threadId && { message_thread_id: threadId }),
|
|
@@ -1088,7 +1125,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1088
1125
|
params.reply_parameters = { message_id: msg.message_id, allow_sending_without_reply: true };
|
|
1089
1126
|
firstBubbleSent = true;
|
|
1090
1127
|
}
|
|
1091
|
-
|
|
1128
|
+
const res = await tg(bot, 'sendMessage', params, outMetaBase);
|
|
1129
|
+
return res && typeof res === 'object'
|
|
1130
|
+
? { ...res, _hadReplyAnchor: hadReplyAnchor }
|
|
1131
|
+
: res;
|
|
1092
1132
|
},
|
|
1093
1133
|
edit: async (messageId, payload) => {
|
|
1094
1134
|
// The streamer emits either a plain string or a rich payload with
|
|
@@ -1098,6 +1138,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1098
1138
|
return richEditMessageText({
|
|
1099
1139
|
bot, chatId, threadId, messageId,
|
|
1100
1140
|
blocks: payload.blocks, sourceText: payload.sourceText,
|
|
1141
|
+
phase: payload.phase,
|
|
1142
|
+
mediaContext,
|
|
1143
|
+
hadReplyAnchor: payload.hadReplyAnchor,
|
|
1101
1144
|
});
|
|
1102
1145
|
}
|
|
1103
1146
|
const text = payload;
|
|
@@ -1718,6 +1761,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1718
1761
|
if (fin.finalEditOk) {
|
|
1719
1762
|
// Preview was successfully edited to the final text.
|
|
1720
1763
|
// No follow-up messages needed.
|
|
1764
|
+
await mediaContext.flushPartialDeliveryWarning(outMetaBase);
|
|
1721
1765
|
await sendInlineStickers();
|
|
1722
1766
|
await sendInlineReactions();
|
|
1723
1767
|
await cleanupArchivedBubbles();
|
|
@@ -1729,7 +1773,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1729
1773
|
// stuck. reactor.stop() in the finally only kills timers,
|
|
1730
1774
|
// not the visible reaction. Clear here, mirroring the
|
|
1731
1775
|
// rc.10 block — AFTER delivery so there's no visual gap.
|
|
1732
|
-
|
|
1776
|
+
if (!mediaContext.deliveryIncomplete) {
|
|
1777
|
+
reactor.clear().catch(() => {});
|
|
1778
|
+
}
|
|
1733
1779
|
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
1734
1780
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1735
1781
|
markReplied();
|
|
@@ -1738,12 +1784,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1738
1784
|
// Preview can't hold the final body (overflow OR last edit
|
|
1739
1785
|
// failed even after our HTML→plain fallback). Delete it and
|
|
1740
1786
|
// send the body as proper chunks.
|
|
1741
|
-
|
|
1787
|
+
let discarded = { msgId: fin.msgId, deleted: false };
|
|
1788
|
+
try { discarded = await streamer.discard(); }
|
|
1742
1789
|
catch (err) { console.error(`[${label}] discard failed: ${err.message}`); }
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
//
|
|
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.
|
|
1747
1797
|
const redeliverText = resolveRichTextEnabled(config, chatId, threadId)
|
|
1748
1798
|
? stripMediaMarkdown(parsed.text)
|
|
1749
1799
|
: parsed.text;
|
|
@@ -1759,29 +1809,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1759
1809
|
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
1760
1810
|
});
|
|
1761
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
|
+
});
|
|
1762
1818
|
logEvent('telegram-stream-redeliver', {
|
|
1763
1819
|
chat_id: chatId, msg_id: msg.message_id,
|
|
1764
1820
|
reason, chunks: chunks.length,
|
|
1765
1821
|
delivered: r.sent.length, failed: r.failed.length,
|
|
1822
|
+
media_delivered: mediaResult.sent, media_failed: mediaResult.failed,
|
|
1766
1823
|
bot: BOT_NAME,
|
|
1767
1824
|
});
|
|
1768
|
-
|
|
1769
|
-
// a chunk-3-of-5 failure leaves a coherent-looking reply with
|
|
1770
|
-
// a silent gap (the user reads chunks 1, 2, 4, 5 unaware
|
|
1771
|
-
// that chunk 3 was dropped). Append a warning + flip the
|
|
1772
|
-
// reactor to ERROR so something visible signals "look here".
|
|
1773
|
-
if (r.failed.length > 0) {
|
|
1774
|
-
reactor.setState('ERROR');
|
|
1775
|
-
try {
|
|
1776
|
-
await tg(bot, 'sendMessage', {
|
|
1777
|
-
chat_id: chatId,
|
|
1778
|
-
text: `⚠️ ${r.failed.length} of ${chunks.length} message parts failed to deliver. The reply may be incomplete — please retry.`,
|
|
1779
|
-
...(threadId && { message_thread_id: threadId }),
|
|
1780
|
-
}, { ...outMetaBase, source: 'partial-delivery-warning' });
|
|
1781
|
-
} catch (warnErr) {
|
|
1782
|
-
console.error(`[${label}] partial-delivery warning failed: ${warnErr.message}`);
|
|
1783
|
-
}
|
|
1784
|
-
}
|
|
1825
|
+
await mediaContext.flushPartialDeliveryWarning(outMetaBase);
|
|
1785
1826
|
await sendInlineStickers();
|
|
1786
1827
|
await sendInlineReactions();
|
|
1787
1828
|
await cleanupArchivedBubbles();
|
|
@@ -1790,10 +1831,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1790
1831
|
// the rc.10 deferred-clear block, so the reactor would stay
|
|
1791
1832
|
// stuck. Clear it (and autosteered ✍) here, after delivery —
|
|
1792
1833
|
// but ONLY on a clean delivery. When r.failed.length>0 the
|
|
1793
|
-
//
|
|
1794
|
-
// for
|
|
1795
|
-
// that signal, so leave the reactor as-is in that case.
|
|
1796
|
-
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) {
|
|
1797
1838
|
reactor.clear().catch(() => {});
|
|
1798
1839
|
}
|
|
1799
1840
|
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
@@ -1844,8 +1885,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1844
1885
|
});
|
|
1845
1886
|
}
|
|
1846
1887
|
|
|
1888
|
+
await mediaContext.flushPartialDeliveryWarning(outMetaBase);
|
|
1847
1889
|
await sendInlineStickers();
|
|
1848
1890
|
await sendInlineReactions();
|
|
1891
|
+
await cleanupArchivedBubbles();
|
|
1849
1892
|
// rc.10: clear progress reactions AFTER the reply has been
|
|
1850
1893
|
// delivered so the user doesn't see a "reactions cleared, then
|
|
1851
1894
|
// ~1-3s of nothing, then reply bubble" gap. The reply bubble
|
|
@@ -1853,7 +1896,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1853
1896
|
// with the delivery completion is the smooth UX path. Both
|
|
1854
1897
|
// fire-and-forget — these are best-effort cleanups, not part of
|
|
1855
1898
|
// the reply contract.
|
|
1856
|
-
|
|
1899
|
+
if (!mediaContext.deliveryIncomplete) {
|
|
1900
|
+
reactor.clear().catch(() => {});
|
|
1901
|
+
}
|
|
1857
1902
|
// 0.8.0-rc.14: also clear ✍ reactions on every follow-up
|
|
1858
1903
|
// message that was autosteered into THIS turn — they live in
|
|
1859
1904
|
// separate handleMessage scopes whose reactors are already GC'd.
|
|
@@ -2394,6 +2439,7 @@ async function main() {
|
|
|
2394
2439
|
db = dbClient.open(DB_PATH);
|
|
2395
2440
|
console.log(`[db] opened ${DB_PATH}`);
|
|
2396
2441
|
tg = createSender(db, console, config);
|
|
2442
|
+
richMediaFileIdCache = createMediaFileIdCache();
|
|
2397
2443
|
richEditMessageText = createRichEditor({
|
|
2398
2444
|
tg,
|
|
2399
2445
|
botName: BOT_NAME,
|