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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/process/channels-tool-dispatcher.js +3 -0
- package/lib/telegram/display-hint.js +4 -0
- package/lib/telegram/rich-edit.js +172 -12
- package/lib/telegram/rich-media.js +616 -0
- package/lib/telegram/rich.js +409 -11
- package/lib/telegram/streamer.js +156 -19
- package/package.json +1 -1
- package/polygram.js +137 -33
|
@@ -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",
|
|
@@ -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 `` 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
|
-
* @
|
|
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
|
-
|
|
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: {
|
|
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 (
|
|
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 };
|