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/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 { createRichMediaResolver, PHOTO_UPLOAD_CEILING } = require('./lib/telegram/rich-media');
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
- // Media (photo/collage/slideshow) resolution for rich replies. Same
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: buildAllowedRoots({
1033
- sessionKey,
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
- resolveMaxFileOverride(config, chatId, threadId) ?? PHOTO_UPLOAD_CEILING,
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
- return toTelegramRichBlocks(text, { ...opts, resolveMedia: resolveRichMedia });
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
- return tg(bot, 'sendMessage', params, outMetaBase);
1128
+ const res = await tg(bot, 'sendMessage', params, outMetaBase);
1129
+ return res && typeof res === 'object'
1130
+ ? { ...res, _hadReplyAnchor: hadReplyAnchor }
1131
+ : res;
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
- reactor.clear().catch(() => {});
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
- try { await streamer.discard(); }
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
- // Rich-enabled chats may carry media markdown; the plain
1744
- // redelivery can't upload it, and a raw `![cap](/abs/path)`
1745
- // would render the local path into the chat. Degrade images to
1746
- // their captions (images are not delivered on this path).
1790
+ if (discarded.msgId != null && !discarded.deleted) {
1791
+ mediaContext.recordDeletionFailures(1);
1792
+ }
1793
+ // Rich-enabled chats may carry media markdown; plain chunks cannot
1794
+ // render it, and a raw `![cap](/abs/path)` would leak a local path.
1795
+ // Degrade the chunk text to captions before the validated media
1796
+ // entries are redelivered as ordered sidecars below.
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
- // 0.7.1: surface partial-failure to the user. Without this,
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
- // ERROR state (😨) was set above as the "look here" signal
1794
- // for the partial-delivery failure; clearing it would wipe
1795
- // that signal, so leave the reactor as-is in that case.
1796
- if (r.failed.length === 0) {
1834
+ // The aggregate warning sets ERROR as the "look here" signal
1835
+ // for any text, media, or cleanup failure. Clearing it would
1836
+ // wipe that signal, so leave the reactor as-is in that case.
1837
+ if (!mediaContext.deliveryIncomplete) {
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
- reactor.clear().catch(() => {});
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,