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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.18.2",
4
+ "version": "0.20.0",
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",
@@ -317,6 +317,9 @@ function createChannelsToolDispatcher({
317
317
  }
318
318
  try {
319
319
  const ext = path.extname(check.resolved).toLowerCase();
320
+ // Deliberately broader than rich-media.js's PHOTO_EXTENSIONS:
321
+ // sendPhoto accepts .gif as a photo message, while a rich
322
+ // photo BLOCK cannot carry an animation.
320
323
  const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
321
324
  const method = isImage ? 'sendPhoto' : 'sendDocument';
322
325
  const fieldName = isImage ? 'photo' : 'document';
@@ -95,6 +95,10 @@ 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 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
+ '',
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
+ '',
98
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.',
99
103
  '',
100
104
  '### Other Telegram quirks',
@@ -2,10 +2,79 @@
2
2
  * Rich-message edit-with-fallback. Capability and content errors preserve
3
3
  * the reply through a plain edit; transient errors remain visible to the
4
4
  * streamer's retry/finalization logic.
5
+ *
6
+ * Media handling: blocks arrive JSON-safe — local files as
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
+ * which a grammy InputFile would break: its toJSON throws by design).
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
+ * Fallback edits go through sanitizeFallbackText so a media-bearing
14
+ * reply degrades without rendering absolute local paths into the chat.
5
15
  */
6
16
 
7
17
  'use strict';
8
18
 
19
+ const { countMediaBlocks } = require('./rich');
20
+
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) {
27
+ return (blocks || []).map((b) => {
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
+ };
54
+ }
55
+ if (b && Array.isArray(b.blocks)) {
56
+ return { ...b, blocks: materializeMediaBlocks(b.blocks, makeInputFile, preflightMedia) };
57
+ }
58
+ if (b && Array.isArray(b.items)) {
59
+ return {
60
+ ...b,
61
+ items: b.items.map((it) => (it && Array.isArray(it.blocks)
62
+ ? { ...it, blocks: materializeMediaBlocks(it.blocks, makeInputFile, preflightMedia) }
63
+ : it)),
64
+ };
65
+ }
66
+ return b;
67
+ });
68
+ }
69
+
70
+ function defaultMakeInputFile(source) {
71
+ // Lazy require keeps unit tests free of the grammy dependency; the
72
+ // InputFile MUST be path-backed (never a stream/supplier) so api.js's
73
+ // 429/pre-connect retries can re-read it.
74
+ const { InputFile } = require('grammy');
75
+ return new InputFile(source);
76
+ }
77
+
9
78
  /**
10
79
  * @param {object} deps
11
80
  * @param {(bot, method, params, meta) => Promise<*>} deps.tg
@@ -19,7 +88,12 @@
19
88
  * @param {() => (string|null)} [deps.getApiRoot] — for the latch-trip log line only
20
89
  * @param {(url: string) => string} [deps.stripUrlCreds] — strips basic-auth
21
90
  * userinfo from apiRoot before logging
22
- * @returns {(args: {bot, chatId: string, threadId: (string|null), messageId: number, blocks: Array, sourceText: string}) => Promise<{result: *, wentRich: boolean}>}
91
+ * @param {(source: string) => *} [deps.makeInputFile] — { source } envelope →
92
+ * uploadable file for grammy (test seam)
93
+ * @param {(text: string) => string} [deps.sanitizeFallbackText] — applied to
94
+ * sourceText on every plain-fallback edit (polygram wires
95
+ * stripMediaMarkdown; default identity preserves legacy callers)
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}>}
23
97
  */
24
98
  function createRichEditor({
25
99
  tg,
@@ -32,31 +106,79 @@ function createRichEditor({
32
106
  setRichKnownUnsupported = () => {},
33
107
  getApiRoot = () => null,
34
108
  stripUrlCreds = (s) => s,
109
+ makeInputFile = defaultMakeInputFile,
110
+ sanitizeFallbackText = (s) => s,
35
111
  } = {}) {
36
- 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
+ }) {
37
143
  if (getRichKnownUnsupported()) {
38
144
  const res = await tg(bot, 'editMessageText', {
39
- chat_id: chatId, message_id: messageId, text: sourceText,
145
+ chat_id: chatId, message_id: messageId, text: sanitizeFallbackText(sourceText),
40
146
  }, { source: 'bot-reply-stream-edit-rich-fallback', botName });
41
147
  return { result: res, wentRich: false };
42
148
  }
43
149
 
150
+ let fallbackKind = null;
44
151
  try {
45
152
  const res = await tg(bot, 'editMessageText', {
46
153
  chat_id: chatId,
47
154
  message_id: messageId,
48
- rich_message: { blocks },
155
+ rich_message: {
156
+ blocks: materializeMediaBlocks(
157
+ blocks,
158
+ makeInputFile,
159
+ mediaContext?.preflightMedia,
160
+ ),
161
+ },
49
162
  }, { source: 'bot-reply-stream-edit-rich', botName, richSourceText: sourceText });
163
+ mediaContext?.learnRichResult?.(blocks, res);
50
164
  // Record successful rich deliveries without storing message content.
51
165
  logEvent('rich-message-sent', {
52
166
  chat_id: chatId, thread_id: threadId, bot: botName,
53
167
  streaming: true, block_count: blocks.length,
168
+ media_count: countMediaBlocks(blocks),
54
169
  char_count: sourceText?.length ?? null,
55
170
  });
56
171
  // The streamer commits rich state only when this marker is true.
57
172
  return { result: res, wentRich: true };
58
173
  } catch (err) {
59
- 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';
60
182
  setRichKnownUnsupported();
61
183
  logEvent('rich-capability-latched', {
62
184
  chat_id: chatId, bot: botName,
@@ -64,6 +186,8 @@ function createRichEditor({
64
186
  error: redactBotToken(err.message)?.slice(0, 200),
65
187
  });
66
188
  } else if (isRichContentError(err)) {
189
+ fallbackKind = 'content-error';
190
+ mediaContext?.evictCachedBlocks?.(blocks);
67
191
  logEvent('rich-content-fallback', {
68
192
  chat_id: chatId, bot: botName,
69
193
  error: redactBotToken(err.message)?.slice(0, 200),
@@ -81,14 +205,50 @@ function createRichEditor({
81
205
  });
82
206
  throw err;
83
207
  }
84
- // Preserve the same authored content and tell the streamer that the
85
- // accepted payload is plain, so its delivery trackers stay accurate.
86
- const fallbackRes = await tg(bot, 'editMessageText', {
87
- chat_id: chatId, message_id: messageId, text: sourceText,
88
- }, { source: 'bot-reply-stream-edit-rich-fallback', botName });
89
- return { result: fallbackRes, wentRich: false };
90
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 };
91
251
  };
92
252
  }
93
253
 
94
- module.exports = { createRichEditor };
254
+ module.exports = { createRichEditor, materializeMediaBlocks };