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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
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",
|
|
@@ -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
|
|
98
|
+
'**Inline media:** use the unchanged `` 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
|
|
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
|
|
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
|
-
*
|
|
11
|
-
* tree, immediately before each send attempt — so the caller's
|
|
12
|
-
* stay serializable
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
&& typeof
|
|
30
|
-
|
|
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
|
-
|
|
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: {
|
|
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 (
|
|
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
|
|