polygram 0.19.0 → 0.20.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.19.0",
4
+ "version": "0.20.1",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -95,9 +95,9 @@ const TABLES_AND_HEADERS_SECTION_RICH = [
95
95
  '',
96
96
  '**Collapsible detail:** wrap verbose logs/diffs/output you want available but not front-and-center in `<details><summary>Label</summary>...</details>`.',
97
97
  '',
98
- '**Inline images:** write `![caption](/abs/path/to/shot.png)` on its own line to place a photo inline at that point in the reply — screenshots, plots, before/after comparisons. Absolute path only, inside your workspace or the attachments staging dir; png/jpg/webp only (no gif). A path you can\'t reach, or any other file type, degrades to a plain "(image unavailable)" line — it will not error. Always accompany an image with at least a sentence of text.',
98
+ '**Inline media:** use the unchanged `![caption](/abs/path/to/file.ext)` syntax on its own line. Local `.jpg`, `.jpeg`, `.png`, and `.webp` files become photos; `.mp4` becomes video; `.gif` becomes animation. Use an absolute path inside your workspace or the attachments staging dir. An unreachable path or unsupported type degrades to a plain "(media unavailable)" line — it will not error. Always accompany media with at least a sentence of substantive text.',
99
99
  '',
100
- '**Grouping images:** wrap consecutive images in `<tg-collage>…</tg-collage>` (grid) or `<tg-slideshow>…</tg-slideshow>` (swipeable) to send them as one block instead of separate photos.',
100
+ '**Grouping media:** wrap consecutive supported media in `<tg-collage>…</tg-collage>` (grid) or `<tg-slideshow>…</tg-slideshow>` (swipeable) to send them as one mixed-media block.',
101
101
  '',
102
102
  'Not every reply needs this — plain prose is still right for a normal conversational answer with no distinct items or sections. Reach for structure when the CONTENT is structured, not by default.',
103
103
  '',
@@ -4,12 +4,12 @@
4
4
  * streamer's retry/finalization logic.
5
5
  *
6
6
  * Media handling: blocks arrive JSON-safe — local files as
7
- * `{ source: '<realpath>' }` envelopes at photo.media (rich.js emits
8
- * them that way because the streamer dedups payloads via JSON.stringify,
7
+ * `{ source: '<realpath>', fingerprint: '…' }` envelopes at the typed
8
+ * media field (rich.js emits them that way because the streamer dedups payloads via JSON.stringify,
9
9
  * which a grammy InputFile would break: its toJSON throws by design).
10
- * The envelope→InputFile materialization happens HERE, on a cloned
11
- * tree, immediately before each send attempt — so the caller's blocks
12
- * stay serializable and every retry gets a fresh path-backed InputFile.
10
+ * Cached file IDs or envelope→InputFile materialization happen HERE, on a
11
+ * cloned tree, immediately before each send attempt — so the caller's
12
+ * blocks stay serializable while local sources are revalidated at delivery.
13
13
  * Fallback edits go through sanitizeFallbackText so a media-bearing
14
14
  * reply degrades without rendering absolute local paths into the chat.
15
15
  */
@@ -18,25 +18,48 @@
18
18
 
19
19
  const { countMediaBlocks } = require('./rich');
20
20
 
21
- // Clone the block tree, replacing { source } envelopes at photo.media
22
- // with uploadable files. Only the photo-block envelope shape is
23
- // materialized — everything else passes through untouched.
24
- function materializeMediaBlocks(blocks, makeInputFile) {
21
+ const MEDIA_SOURCE_CHANGED = 'RICH_MEDIA_SOURCE_CHANGED';
22
+ const MEDIA_BLOCK_KINDS = new Set(['photo', 'video', 'animation']);
23
+
24
+ // Clone the block tree, replacing local source envelopes with uploadable
25
+ // files while preserving each block's concrete media kind.
26
+ function materializeMediaBlocks(blocks, makeInputFile, preflightMedia = null) {
25
27
  return (blocks || []).map((b) => {
26
- if (b && b.type === 'photo'
27
- && b.photo && typeof b.photo === 'object'
28
- && b.photo.media && typeof b.photo.media === 'object'
29
- && typeof b.photo.media.source === 'string') {
30
- return { ...b, photo: { ...b.photo, media: makeInputFile(b.photo.media.source) } };
28
+ const kind = b?.type;
29
+ const inputMedia = MEDIA_BLOCK_KINDS.has(kind) ? b[kind] : null;
30
+ if (inputMedia && typeof inputMedia === 'object'
31
+ && inputMedia.media && typeof inputMedia.media === 'object'
32
+ && typeof inputMedia.media.source === 'string') {
33
+ const checked = typeof preflightMedia === 'function'
34
+ ? preflightMedia(inputMedia.media, kind)
35
+ : {
36
+ ok: true,
37
+ value: typeof inputMedia.media.fileId === 'string' && inputMedia.media.fileId
38
+ ? inputMedia.media.fileId
39
+ : inputMedia.media,
40
+ };
41
+ if (!checked?.ok) {
42
+ const err = new Error('Rich media source changed before upload');
43
+ err.code = MEDIA_SOURCE_CHANGED;
44
+ throw err;
45
+ }
46
+ const media = checked.value || inputMedia.media;
47
+ return {
48
+ ...b,
49
+ [kind]: {
50
+ ...inputMedia,
51
+ media: typeof media === 'string' ? media : makeInputFile(media.source),
52
+ },
53
+ };
31
54
  }
32
55
  if (b && Array.isArray(b.blocks)) {
33
- return { ...b, blocks: materializeMediaBlocks(b.blocks, makeInputFile) };
56
+ return { ...b, blocks: materializeMediaBlocks(b.blocks, makeInputFile, preflightMedia) };
34
57
  }
35
58
  if (b && Array.isArray(b.items)) {
36
59
  return {
37
60
  ...b,
38
61
  items: b.items.map((it) => (it && Array.isArray(it.blocks)
39
- ? { ...it, blocks: materializeMediaBlocks(it.blocks, makeInputFile) }
62
+ ? { ...it, blocks: materializeMediaBlocks(it.blocks, makeInputFile, preflightMedia) }
40
63
  : it)),
41
64
  };
42
65
  }
@@ -70,7 +93,7 @@ function defaultMakeInputFile(source) {
70
93
  * @param {(text: string) => string} [deps.sanitizeFallbackText] — applied to
71
94
  * sourceText on every plain-fallback edit (polygram wires
72
95
  * stripMediaMarkdown; default identity preserves legacy callers)
73
- * @returns {(args: {bot, chatId: string, threadId: (string|null), messageId: number, blocks: Array, sourceText: string}) => Promise<{result: *, wentRich: boolean}>}
96
+ * @returns {(args: {bot, chatId: string, threadId: (string|null), messageId: number, blocks: Array, sourceText: string, phase?: string, mediaContext?: object, hadReplyAnchor?: boolean}) => Promise<{result: *, wentRich: boolean, bubbleRemoved?: boolean}>}
74
97
  */
75
98
  function createRichEditor({
76
99
  tg,
@@ -86,7 +109,37 @@ function createRichEditor({
86
109
  makeInputFile = defaultMakeInputFile,
87
110
  sanitizeFallbackText = (s) => s,
88
111
  } = {}) {
89
- return async function richEditMessageText({ bot, chatId, threadId = null, messageId, blocks, sourceText }) {
112
+ async function rescueBestEffort({
113
+ mediaContext,
114
+ blocks,
115
+ options,
116
+ chatId,
117
+ threadId,
118
+ }) {
119
+ try {
120
+ await mediaContext.rescueBlocks(blocks, options);
121
+ } catch (err) {
122
+ // Text may already have landed, so a rescue failure must not escape
123
+ // into finalization and cause that text to be delivered again.
124
+ mediaContext.recordUnexpectedMediaFailure?.();
125
+ logEvent('rich-media-rescue-failed', {
126
+ chat_id: chatId, thread_id: threadId, bot: botName,
127
+ error_class: err?.name || 'Error',
128
+ });
129
+ }
130
+ }
131
+
132
+ return async function richEditMessageText({
133
+ bot,
134
+ chatId,
135
+ threadId = null,
136
+ messageId,
137
+ blocks,
138
+ sourceText,
139
+ phase = 'preview',
140
+ mediaContext = null,
141
+ hadReplyAnchor = false,
142
+ }) {
90
143
  if (getRichKnownUnsupported()) {
91
144
  const res = await tg(bot, 'editMessageText', {
92
145
  chat_id: chatId, message_id: messageId, text: sanitizeFallbackText(sourceText),
@@ -94,12 +147,20 @@ function createRichEditor({
94
147
  return { result: res, wentRich: false };
95
148
  }
96
149
 
150
+ let fallbackKind = null;
97
151
  try {
98
152
  const res = await tg(bot, 'editMessageText', {
99
153
  chat_id: chatId,
100
154
  message_id: messageId,
101
- rich_message: { blocks: materializeMediaBlocks(blocks, makeInputFile) },
155
+ rich_message: {
156
+ blocks: materializeMediaBlocks(
157
+ blocks,
158
+ makeInputFile,
159
+ mediaContext?.preflightMedia,
160
+ ),
161
+ },
102
162
  }, { source: 'bot-reply-stream-edit-rich', botName, richSourceText: sourceText });
163
+ mediaContext?.learnRichResult?.(blocks, res);
103
164
  // Record successful rich deliveries without storing message content.
104
165
  logEvent('rich-message-sent', {
105
166
  chat_id: chatId, thread_id: threadId, bot: botName,
@@ -110,7 +171,14 @@ function createRichEditor({
110
171
  // The streamer commits rich state only when this marker is true.
111
172
  return { result: res, wentRich: true };
112
173
  } catch (err) {
113
- if (isRichCapabilityError(err)) {
174
+ if (err?.code === MEDIA_SOURCE_CHANGED) {
175
+ fallbackKind = 'content-error';
176
+ logEvent('rich-content-fallback', {
177
+ chat_id: chatId, bot: botName,
178
+ error: 'media-source-changed',
179
+ });
180
+ } else if (isRichCapabilityError(err)) {
181
+ fallbackKind = 'capability';
114
182
  setRichKnownUnsupported();
115
183
  logEvent('rich-capability-latched', {
116
184
  chat_id: chatId, bot: botName,
@@ -118,6 +186,8 @@ function createRichEditor({
118
186
  error: redactBotToken(err.message)?.slice(0, 200),
119
187
  });
120
188
  } else if (isRichContentError(err)) {
189
+ fallbackKind = 'content-error';
190
+ mediaContext?.evictCachedBlocks?.(blocks);
121
191
  logEvent('rich-content-fallback', {
122
192
  chat_id: chatId, bot: botName,
123
193
  error: redactBotToken(err.message)?.slice(0, 200),
@@ -135,13 +205,49 @@ function createRichEditor({
135
205
  });
136
206
  throw err;
137
207
  }
138
- // Preserve the same authored content and tell the streamer that the
139
- // accepted payload is plain, so its delivery trackers stay accurate.
140
- const fallbackRes = await tg(bot, 'editMessageText', {
141
- chat_id: chatId, message_id: messageId, text: sanitizeFallbackText(sourceText),
142
- }, { source: 'bot-reply-stream-edit-rich-fallback', botName });
143
- return { result: fallbackRes, wentRich: false };
144
208
  }
209
+
210
+ const fallbackText = sanitizeFallbackText(sourceText);
211
+ const canRescue = fallbackKind === 'content-error'
212
+ && (phase === 'final' || phase === 'seal')
213
+ && mediaContext
214
+ && typeof mediaContext.rescueBlocks === 'function';
215
+
216
+ if (canRescue && !fallbackText.trim()) {
217
+ const bubbleRemoved = typeof mediaContext.deletePlaceholder === 'function'
218
+ ? await mediaContext.deletePlaceholder(messageId)
219
+ : false;
220
+ await rescueBestEffort({
221
+ mediaContext,
222
+ blocks,
223
+ options: {
224
+ trigger: 'content-error',
225
+ anchorFirst: bubbleRemoved && hadReplyAnchor,
226
+ },
227
+ chatId,
228
+ threadId,
229
+ });
230
+ return { result: null, wentRich: false, bubbleRemoved };
231
+ }
232
+
233
+ // Preserve the same authored content and tell the streamer that the
234
+ // accepted payload is plain, so its delivery trackers stay accurate.
235
+ // If this edit fails, no rescue has started and finalize remains the
236
+ // single owner of discard/redelivery.
237
+ const fallbackRes = await tg(bot, 'editMessageText', {
238
+ chat_id: chatId, message_id: messageId, text: fallbackText,
239
+ }, { source: 'bot-reply-stream-edit-rich-fallback', botName });
240
+
241
+ if (canRescue) {
242
+ await rescueBestEffort({
243
+ mediaContext,
244
+ blocks,
245
+ options: { trigger: 'content-error' },
246
+ chatId,
247
+ threadId,
248
+ });
249
+ }
250
+ return { result: fallbackRes, wentRich: false };
145
251
  };
146
252
  }
147
253