polygram 0.6.16 → 0.7.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/README.md +1 -1
- package/lib/announces.js +70 -0
- package/lib/deliver.js +69 -0
- package/lib/net-errors.js +52 -3
- package/lib/process-manager.js +30 -6
- package/lib/sent-cache.js +71 -0
- package/lib/stream-reply.js +127 -18
- package/lib/telegram-chunk.js +278 -0
- package/lib/telegram-format.js +107 -1
- package/lib/telegram.js +134 -39
- package/package.json +1 -1
- package/polygram.js +143 -46
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown-aware chunking for Telegram-bound replies.
|
|
3
|
+
*
|
|
4
|
+
* Direct port of OpenClaw's chunkMarkdownText (`extensions/telegram` uses
|
|
5
|
+
* `chunkerMode: 'markdown'`). The naive byte-cut chunker we shipped pre-0.7.0
|
|
6
|
+
* landed boundaries mid-word and mid-HTML-tag, which Telegram's parse_mode=HTML
|
|
7
|
+
* rejected with `400 can't parse entities` — bubbles froze and content got
|
|
8
|
+
* dropped (see msg 10794 incident). This algorithm guarantees:
|
|
9
|
+
*
|
|
10
|
+
* 1. No chunk exceeds `limit`.
|
|
11
|
+
* 2. Breaks prefer newlines over whitespace over hard-cut.
|
|
12
|
+
* 3. Code fences (```...```) are never broken silently — if a chunk would
|
|
13
|
+
* land inside a fence, we close it on chunk N and re-open with the same
|
|
14
|
+
* marker + language on chunk N+1, so each chunk is independently
|
|
15
|
+
* parseable.
|
|
16
|
+
* 4. Parenthesised expressions `(...)` aren't broken at whitespace inside
|
|
17
|
+
* the parens (avoids splitting `[markdown link](http://example.com/...)`).
|
|
18
|
+
*
|
|
19
|
+
* Plain `chunkText` (no fence handling) is exported for callers that already
|
|
20
|
+
* know the input has no markdown — primarily code paths handling raw user
|
|
21
|
+
* input echoes or non-text payloads.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ─── Code-fence span detection ──────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
// Scan `buffer` for ```...``` and ~~~...~~~ fences. Returns the span list
|
|
27
|
+
// of matched (open, close) pairs. An unclosed open fence at end-of-input
|
|
28
|
+
// is treated as if it closes at end (so the chunker can still split inside
|
|
29
|
+
// safely).
|
|
30
|
+
function parseFenceSpans(buffer) {
|
|
31
|
+
const spans = [];
|
|
32
|
+
let open;
|
|
33
|
+
let offset = 0;
|
|
34
|
+
while (offset <= buffer.length) {
|
|
35
|
+
const nextNewline = buffer.indexOf('\n', offset);
|
|
36
|
+
const lineEnd = nextNewline === -1 ? buffer.length : nextNewline;
|
|
37
|
+
const line = buffer.slice(offset, lineEnd);
|
|
38
|
+
// Fence opens/closes start with up to 3 spaces of indent then 3+ of
|
|
39
|
+
// ` or ~. The "info string" after the marker (language hint) doesn't
|
|
40
|
+
// affect span boundaries.
|
|
41
|
+
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
|
|
42
|
+
if (match) {
|
|
43
|
+
const indent = match[1];
|
|
44
|
+
const marker = match[2];
|
|
45
|
+
const markerChar = marker[0];
|
|
46
|
+
const markerLen = marker.length;
|
|
47
|
+
if (!open) {
|
|
48
|
+
open = { start: offset, markerChar, markerLen, openLine: line, marker, indent };
|
|
49
|
+
} else if (open.markerChar === markerChar && markerLen >= open.markerLen) {
|
|
50
|
+
// Closing fence must use the SAME char and at least as many of them.
|
|
51
|
+
// Different-char or shorter sequences are part of the body.
|
|
52
|
+
spans.push({
|
|
53
|
+
start: open.start, end: lineEnd,
|
|
54
|
+
openLine: open.openLine, marker: open.marker, indent: open.indent,
|
|
55
|
+
});
|
|
56
|
+
open = undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (nextNewline === -1) break;
|
|
60
|
+
offset = nextNewline + 1;
|
|
61
|
+
}
|
|
62
|
+
if (open) {
|
|
63
|
+
// Unclosed at EOF — treat as spanning to end so a later break-point
|
|
64
|
+
// inside knows it's "in fence".
|
|
65
|
+
spans.push({
|
|
66
|
+
start: open.start, end: buffer.length,
|
|
67
|
+
openLine: open.openLine, marker: open.marker, indent: open.indent,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return spans;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findFenceSpanAt(spans, index) {
|
|
74
|
+
// Strict inequality: a break at exactly span.start is just before the
|
|
75
|
+
// opening fence (safe). At span.end, just after the close (also safe).
|
|
76
|
+
return spans.find((span) => index > span.start && index < span.end);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isSafeFenceBreak(spans, index) {
|
|
80
|
+
return !findFenceSpanAt(spans, index);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Paren-aware break-point scan ───────────────────────────────────
|
|
84
|
+
|
|
85
|
+
// Find the last newline / last whitespace in `window` that's NOT inside
|
|
86
|
+
// `(...)` parens. Used by both plain and markdown chunkers.
|
|
87
|
+
//
|
|
88
|
+
// `isAllowed(i)` is consulted before every candidate — passed by the
|
|
89
|
+
// markdown chunker to skip break points inside fence spans.
|
|
90
|
+
function scanParenAwareBreakpoints(window, isAllowed = () => true) {
|
|
91
|
+
let lastNewline = -1;
|
|
92
|
+
let lastWhitespace = -1;
|
|
93
|
+
let depth = 0;
|
|
94
|
+
for (let i = 0; i < window.length; i++) {
|
|
95
|
+
if (!isAllowed(i)) continue;
|
|
96
|
+
const char = window[i];
|
|
97
|
+
if (char === '(') { depth += 1; continue; }
|
|
98
|
+
if (char === ')' && depth > 0) { depth -= 1; continue; }
|
|
99
|
+
if (depth !== 0) continue;
|
|
100
|
+
if (char === '\n') lastNewline = i;
|
|
101
|
+
else if (/\s/.test(char)) lastWhitespace = i;
|
|
102
|
+
}
|
|
103
|
+
return { lastNewline, lastWhitespace };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Chunkers ────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
// Common early-out: empty / non-positive limit / fits-in-one returns
|
|
109
|
+
// directly so the loop bodies can assume there's real work to do.
|
|
110
|
+
function resolveChunkEarlyReturn(text, limit) {
|
|
111
|
+
if (!text) return [];
|
|
112
|
+
if (limit <= 0) return [text];
|
|
113
|
+
if (text.length <= limit) return [text];
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Generic break-resolver loop shared with markdown variant. The resolver
|
|
118
|
+
// receives a `window` (text.slice(0, limit)) and returns where to break.
|
|
119
|
+
// Negative / out-of-range break indices fall back to hard-cut at limit.
|
|
120
|
+
function chunkTextByBreakResolver(text, limit, resolveBreakIndex) {
|
|
121
|
+
if (!text) return [];
|
|
122
|
+
if (limit <= 0 || text.length <= limit) return [text];
|
|
123
|
+
const chunks = [];
|
|
124
|
+
let remaining = text;
|
|
125
|
+
while (remaining.length > limit) {
|
|
126
|
+
const candidateBreak = resolveBreakIndex(remaining.slice(0, limit));
|
|
127
|
+
const breakIdx = Number.isFinite(candidateBreak) && candidateBreak > 0 && candidateBreak <= limit
|
|
128
|
+
? candidateBreak
|
|
129
|
+
: limit;
|
|
130
|
+
const chunk = remaining.slice(0, breakIdx).trimEnd();
|
|
131
|
+
if (chunk.length > 0) chunks.push(chunk);
|
|
132
|
+
// If we broke on a separator (whitespace), consume it — don't carry it
|
|
133
|
+
// to the start of the next chunk where it'd just be trimmed anyway.
|
|
134
|
+
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
|
135
|
+
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
|
|
136
|
+
remaining = remaining.slice(nextStart).trimStart();
|
|
137
|
+
}
|
|
138
|
+
if (remaining.length) chunks.push(remaining);
|
|
139
|
+
return chunks;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Plain-text chunker: respects parens but ignores fences. Cheaper than
|
|
143
|
+
// chunkMarkdownText when caller knows the input has no code blocks.
|
|
144
|
+
function chunkText(text, limit) {
|
|
145
|
+
const early = resolveChunkEarlyReturn(text, limit);
|
|
146
|
+
if (early !== undefined) return early;
|
|
147
|
+
return chunkTextByBreakResolver(text, limit, (window) => {
|
|
148
|
+
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window);
|
|
149
|
+
return lastNewline > 0 ? lastNewline : lastWhitespace;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Strip leading newlines from the remainder after a chunk break — they
|
|
154
|
+
// would otherwise show up as blank lines at the top of the next bubble.
|
|
155
|
+
function stripLeadingNewlines(value) {
|
|
156
|
+
let i = 0;
|
|
157
|
+
while (i < value.length && value[i] === '\n') i++;
|
|
158
|
+
return i > 0 ? value.slice(i) : value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Inside a code-fence, prefer the last newline (line boundary) — but only
|
|
162
|
+
// inside the safe break region. Falls back to whitespace, then hard-cut.
|
|
163
|
+
function pickSafeBreakIndex(window, spans) {
|
|
164
|
+
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(
|
|
165
|
+
window,
|
|
166
|
+
(index) => isSafeFenceBreak(spans, index),
|
|
167
|
+
);
|
|
168
|
+
if (lastNewline > 0) return lastNewline;
|
|
169
|
+
if (lastWhitespace > 0) return lastWhitespace;
|
|
170
|
+
return -1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Markdown-aware chunker. The whole point of 0.7.0 over the previous
|
|
174
|
+
// `lastIndexOf('\n', maxLen)` chunker.
|
|
175
|
+
//
|
|
176
|
+
// Flow per iteration:
|
|
177
|
+
// 1. Parse fence spans of the remainder.
|
|
178
|
+
// 2. Pick the best (newline > whitespace) break inside `[0..limit]`
|
|
179
|
+
// that's NOT inside a fence. Fall back to hard-cut at limit.
|
|
180
|
+
// 3. If the break did land inside a fence (no safe alternative was
|
|
181
|
+
// reachable), search backwards for a newline within the fence body
|
|
182
|
+
// that still fits with a closing-fence appended; if found, split
|
|
183
|
+
// the fence — close it on this chunk and reopen with the same
|
|
184
|
+
// marker+language on the next.
|
|
185
|
+
// 4. Append the chunk; advance `remaining` past the break + the
|
|
186
|
+
// reopened fence header (if any).
|
|
187
|
+
function chunkMarkdownText(text, limit) {
|
|
188
|
+
const early = resolveChunkEarlyReturn(text, limit);
|
|
189
|
+
if (early !== undefined) return early;
|
|
190
|
+
const chunks = [];
|
|
191
|
+
let remaining = text;
|
|
192
|
+
while (remaining.length > limit) {
|
|
193
|
+
const spans = parseFenceSpans(remaining);
|
|
194
|
+
const softBreak = pickSafeBreakIndex(remaining.slice(0, limit), spans);
|
|
195
|
+
let breakIdx = softBreak > 0 ? softBreak : limit;
|
|
196
|
+
const initialFence = isSafeFenceBreak(spans, breakIdx) ? undefined : findFenceSpanAt(spans, breakIdx);
|
|
197
|
+
let fenceToSplit = initialFence;
|
|
198
|
+
if (initialFence) {
|
|
199
|
+
// The break landed inside a fence. We may still split the fence,
|
|
200
|
+
// but only if there's room for a closing line within the limit.
|
|
201
|
+
const closeLine = `${initialFence.indent}${initialFence.marker}`;
|
|
202
|
+
const maxIdxIfNeedNewline = limit - (closeLine.length + 1); // need a \n separator
|
|
203
|
+
if (maxIdxIfNeedNewline <= 0) {
|
|
204
|
+
// Even the close line wouldn't fit — give up and hard-cut.
|
|
205
|
+
// Caller will see a malformed chunk, but that's a degenerate
|
|
206
|
+
// input case (limit smaller than the close marker).
|
|
207
|
+
fenceToSplit = undefined;
|
|
208
|
+
breakIdx = limit;
|
|
209
|
+
} else {
|
|
210
|
+
// Look for a newline inside the fence body that's late enough
|
|
211
|
+
// to make progress (past the open line + at least one body line)
|
|
212
|
+
// and early enough that close line fits.
|
|
213
|
+
const minProgressIdx = Math.min(
|
|
214
|
+
remaining.length,
|
|
215
|
+
initialFence.start + initialFence.openLine.length + 2,
|
|
216
|
+
);
|
|
217
|
+
const maxIdxIfAlreadyNewline = limit - closeLine.length;
|
|
218
|
+
let pickedNewline = false;
|
|
219
|
+
let lastNewline = remaining.lastIndexOf('\n', Math.max(0, maxIdxIfAlreadyNewline - 1));
|
|
220
|
+
while (lastNewline !== -1) {
|
|
221
|
+
const candidateBreak = lastNewline + 1;
|
|
222
|
+
if (candidateBreak < minProgressIdx) break;
|
|
223
|
+
const candidateFence = findFenceSpanAt(spans, candidateBreak);
|
|
224
|
+
if (candidateFence && candidateFence.start === initialFence.start) {
|
|
225
|
+
breakIdx = Math.max(1, candidateBreak);
|
|
226
|
+
pickedNewline = true;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
lastNewline = remaining.lastIndexOf('\n', lastNewline - 1);
|
|
230
|
+
}
|
|
231
|
+
if (!pickedNewline) {
|
|
232
|
+
if (minProgressIdx > maxIdxIfAlreadyNewline) {
|
|
233
|
+
// No safe in-fence newline found and no room to add one —
|
|
234
|
+
// give up on splitting this fence; hard-cut at limit.
|
|
235
|
+
fenceToSplit = undefined;
|
|
236
|
+
breakIdx = limit;
|
|
237
|
+
} else {
|
|
238
|
+
// Force the break; chunker will append a synthetic newline
|
|
239
|
+
// before the close line.
|
|
240
|
+
breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Re-check the break: if our adjusted index is no longer inside
|
|
245
|
+
// the same fence, don't try to split it.
|
|
246
|
+
const fenceAtBreak = findFenceSpanAt(spans, breakIdx);
|
|
247
|
+
fenceToSplit = fenceAtBreak && fenceAtBreak.start === initialFence.start ? fenceAtBreak : undefined;
|
|
248
|
+
}
|
|
249
|
+
let rawChunk = remaining.slice(0, breakIdx);
|
|
250
|
+
if (!rawChunk) break;
|
|
251
|
+
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
|
252
|
+
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
|
|
253
|
+
let next = remaining.slice(nextStart);
|
|
254
|
+
if (fenceToSplit) {
|
|
255
|
+
// Close the fence on this chunk; reopen with the same marker line
|
|
256
|
+
// (preserving language hint) on the next.
|
|
257
|
+
const closeLine = `${fenceToSplit.indent}${fenceToSplit.marker}`;
|
|
258
|
+
rawChunk = rawChunk.endsWith('\n') ? `${rawChunk}${closeLine}` : `${rawChunk}\n${closeLine}`;
|
|
259
|
+
next = `${fenceToSplit.openLine}\n${next}`;
|
|
260
|
+
} else {
|
|
261
|
+
// Strip stray leading newlines on the next chunk so it doesn't
|
|
262
|
+
// open with blank lines.
|
|
263
|
+
next = stripLeadingNewlines(next);
|
|
264
|
+
}
|
|
265
|
+
chunks.push(rawChunk);
|
|
266
|
+
remaining = next;
|
|
267
|
+
}
|
|
268
|
+
if (remaining.length) chunks.push(remaining);
|
|
269
|
+
return chunks;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = {
|
|
273
|
+
chunkText,
|
|
274
|
+
chunkMarkdownText,
|
|
275
|
+
// Internals exported for tests; not part of the stable API.
|
|
276
|
+
parseFenceSpans,
|
|
277
|
+
scanParenAwareBreakpoints,
|
|
278
|
+
};
|
package/lib/telegram-format.js
CHANGED
|
@@ -272,4 +272,110 @@ function toTelegramHtml(text) {
|
|
|
272
272
|
|
|
273
273
|
function toTelegramMarkdown(text) { return toTelegramHtml(text); }
|
|
274
274
|
|
|
275
|
-
|
|
275
|
+
// 0.7.0 (Phase K) audit note: polygram's `toTelegramHtml` is functionally
|
|
276
|
+
// aligned with OpenClaw's `markdownToTelegramHtml` + `renderTelegramHtmlText`
|
|
277
|
+
// (extensions/telegram, send.ts:828-898). Both produce parse_mode=HTML
|
|
278
|
+
// output, both run `wrapFileReferencesInHtml` as a post-processor, and
|
|
279
|
+
// both handle the same set of formatting features (bold, italic, code,
|
|
280
|
+
// pre/fence, links, lists, spoilers, blockquotes, tables).
|
|
281
|
+
//
|
|
282
|
+
// OpenClaw uses an internal markdown-IR (markdownToIR → renderTelegramHtml);
|
|
283
|
+
// polygram uses `marked` + custom renderers. Both work; the IR approach
|
|
284
|
+
// is more amenable to multi-output (HTML / plain / Slack), but polygram
|
|
285
|
+
// only needs HTML so the regex-based path is simpler.
|
|
286
|
+
//
|
|
287
|
+
// The HTML→plain fallback shipped in 0.7.0 phase 2 makes converter
|
|
288
|
+
// correctness less load-bearing: if any edge case slips through and
|
|
289
|
+
// Telegram rejects the HTML, we automatically retry as plain text and
|
|
290
|
+
// no content is lost.
|
|
291
|
+
|
|
292
|
+
// ─── Telegram error classification ──────────────────────────────────
|
|
293
|
+
|
|
294
|
+
// Ported from OpenClaw (`send-DVX_zY9w.js:1075-1077`). HTML parse errors
|
|
295
|
+
// fire when our markdown→HTML conversion produces malformed output (e.g.
|
|
296
|
+
// the streamer's truncate cut mid-tag pre-0.7.0). Caller reacts by
|
|
297
|
+
// retrying the same call as plain text without parse_mode.
|
|
298
|
+
const HTML_PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
|
299
|
+
|
|
300
|
+
// 'message is not modified' fires when an editMessageText payload
|
|
301
|
+
// matches the current message text exactly. Not a real failure — the
|
|
302
|
+
// streamer's debounced edit just happened to land on a no-op. Swallow
|
|
303
|
+
// to keep error logs clean. The phrase is unique enough that we don't
|
|
304
|
+
// need to guard with a 400 prefix (grammy strips the status code in
|
|
305
|
+
// some error shapes).
|
|
306
|
+
const MESSAGE_NOT_MODIFIED_RE = /message is not modified|MESSAGE_NOT_MODIFIED/i;
|
|
307
|
+
|
|
308
|
+
// grammy attaches the underlying API error in different shapes
|
|
309
|
+
// depending on transport. Walk the candidates and pick the first
|
|
310
|
+
// stringifiable message.
|
|
311
|
+
function errorMessage(err) {
|
|
312
|
+
if (!err) return '';
|
|
313
|
+
return String(err.description || err.message || err.error_message || err);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isHtmlParseError(err) {
|
|
317
|
+
return HTML_PARSE_ERR_RE.test(errorMessage(err));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isMessageNotModifiedError(err) {
|
|
321
|
+
return MESSAGE_NOT_MODIFIED_RE.test(errorMessage(err));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 0.7.0 (Phase G): 429 rate-limit detection + retry_after extraction.
|
|
325
|
+
// Telegram returns "Too Many Requests: retry after N" (HTTP 429) when the
|
|
326
|
+
// per-bot rate limit is hit (~30 req/s). N is in seconds. grammy attaches
|
|
327
|
+
// retry_after as `err.parameters.retry_after` in some shapes; otherwise
|
|
328
|
+
// we parse it out of the message string.
|
|
329
|
+
const RATE_LIMIT_RE = /too many requests|429|retry after (\d+)/i;
|
|
330
|
+
|
|
331
|
+
function isRateLimitError(err) {
|
|
332
|
+
if (!err) return false;
|
|
333
|
+
return RATE_LIMIT_RE.test(errorMessage(err));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Returns retry_after in milliseconds, or null if not a 429 / not parseable.
|
|
337
|
+
function getRetryAfterMs(err) {
|
|
338
|
+
if (!err) return null;
|
|
339
|
+
// grammy / Telegram Bot API: err.parameters.retry_after (seconds)
|
|
340
|
+
const fromParams = err.parameters?.retry_after ?? err.error_parameters?.retry_after;
|
|
341
|
+
if (typeof fromParams === 'number' && Number.isFinite(fromParams)) {
|
|
342
|
+
return Math.max(0, fromParams * 1000);
|
|
343
|
+
}
|
|
344
|
+
// Fall back to parsing the message: "retry after 5" / "retry after 12 seconds"
|
|
345
|
+
const m = errorMessage(err).match(/retry after (\d+)/i);
|
|
346
|
+
if (m) return Math.max(0, parseInt(m[1], 10) * 1000);
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─── Caption length policy ──────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
// Telegram caps captions on sendPhoto / sendVideo / sendAudio /
|
|
353
|
+
// sendDocument at 1024 chars (vs 4096 for sendMessage). When a caption
|
|
354
|
+
// would exceed this, OpenClaw's choice is to send the media WITHOUT a
|
|
355
|
+
// caption and follow up with the text as a separate sendMessage. This
|
|
356
|
+
// is simpler than splitting mid-caption (which would visually fragment
|
|
357
|
+
// the description across the media bubble and a follow-up). Reused
|
|
358
|
+
// here for any future skill that wants to send media with rich text.
|
|
359
|
+
const TELEGRAM_MAX_CAPTION_LENGTH = 1024;
|
|
360
|
+
|
|
361
|
+
function splitTelegramCaption(text) {
|
|
362
|
+
const trimmed = (text || '').trim();
|
|
363
|
+
if (!trimmed) return { caption: undefined, followUpText: undefined };
|
|
364
|
+
if (trimmed.length > TELEGRAM_MAX_CAPTION_LENGTH) {
|
|
365
|
+
return { caption: undefined, followUpText: trimmed };
|
|
366
|
+
}
|
|
367
|
+
return { caption: trimmed, followUpText: undefined };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = {
|
|
371
|
+
toTelegramMarkdown,
|
|
372
|
+
toTelegramHtml,
|
|
373
|
+
wrapFileReferencesInHtml,
|
|
374
|
+
escapeHtml,
|
|
375
|
+
isHtmlParseError,
|
|
376
|
+
isMessageNotModifiedError,
|
|
377
|
+
splitTelegramCaption,
|
|
378
|
+
TELEGRAM_MAX_CAPTION_LENGTH,
|
|
379
|
+
isRateLimitError,
|
|
380
|
+
getRetryAfterMs,
|
|
381
|
+
};
|
package/lib/telegram.js
CHANGED
|
@@ -20,7 +20,13 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
const crypto = require('crypto');
|
|
23
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
toTelegramMarkdown,
|
|
25
|
+
isHtmlParseError,
|
|
26
|
+
isMessageNotModifiedError,
|
|
27
|
+
isRateLimitError,
|
|
28
|
+
getRetryAfterMs,
|
|
29
|
+
} = require('./telegram-format');
|
|
24
30
|
const { isSafeToRetry, redactBotToken } = require('./net-errors');
|
|
25
31
|
|
|
26
32
|
// Topic deletion race: a user can delete a forum topic while a turn is in
|
|
@@ -112,7 +118,30 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
|
|
|
112
118
|
const text = deriveOutboundText(method, params, meta);
|
|
113
119
|
const tracksMessage = !METHODS_WITHOUT_MSG.has(method);
|
|
114
120
|
|
|
121
|
+
// 0.7.0: snapshot the field that applyFormatting will convert and the
|
|
122
|
+
// current parse_mode state, so the HTML→plain fallback (if Telegram
|
|
123
|
+
// rejects the converted payload with `400 can't parse entities`) can
|
|
124
|
+
// restore the raw value and retry without parse_mode. Mirrors the
|
|
125
|
+
// applyFormatting decision (must run BEFORE applyFormatting mutates).
|
|
126
|
+
const willFormat = !meta.plainText
|
|
127
|
+
&& FORMATTABLE_METHODS.has(method)
|
|
128
|
+
&& params.parse_mode == null;
|
|
129
|
+
const formatField = willFormat ? (params.text ? 'text' : (params.caption ? 'caption' : null)) : null;
|
|
130
|
+
const rawFieldValue = formatField ? params[formatField] : null;
|
|
131
|
+
|
|
115
132
|
applyFormatting(method, params, meta);
|
|
133
|
+
const formattingApplied = formatField && params.parse_mode === 'HTML';
|
|
134
|
+
|
|
135
|
+
// 0.7.0: per-bot/chat link-preview opt-out. When meta.linkPreview is
|
|
136
|
+
// explicitly false, suppress Telegram's auto-generated preview cards
|
|
137
|
+
// for any URL in the body (they clutter chats and add visual noise).
|
|
138
|
+
// Default (no flag set) preserves Telegram's native preview behavior
|
|
139
|
+
// — matches OpenClaw's account.config.linkPreview opt-out.
|
|
140
|
+
if (meta.linkPreview === false
|
|
141
|
+
&& (method === 'sendMessage' || method === 'editMessageText')
|
|
142
|
+
&& params.link_preview_options == null) {
|
|
143
|
+
params.link_preview_options = { is_disabled: true };
|
|
144
|
+
}
|
|
116
145
|
|
|
117
146
|
// Capture which inbound this reply targets so the boot-replay dedupe
|
|
118
147
|
// (`hasOutboundReplyTo`) can match outbound→inbound. Without this every
|
|
@@ -142,60 +171,126 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
|
|
|
142
171
|
}
|
|
143
172
|
|
|
144
173
|
let res;
|
|
145
|
-
const
|
|
146
|
-
|
|
174
|
+
const rawAttempt = async (p) => bot.api.raw[method](p);
|
|
175
|
+
|
|
176
|
+
// safeAttempt wraps every API call with three OpenClaw-style fallbacks:
|
|
177
|
+
// 1. MESSAGE_NOT_MODIFIED on editMessageText → swallow as success.
|
|
178
|
+
// The streamer's debounced edit can land on text that exactly
|
|
179
|
+
// matches the bubble's current state (no-op edit). Telegram
|
|
180
|
+
// returns 400; we treat it as success and skip the noise.
|
|
181
|
+
// 2. HTML parse error (`can't parse entities` etc) → retry the
|
|
182
|
+
// same call as plain text, no parse_mode, original raw value
|
|
183
|
+
// restored to the formatted field. Saves the call when our
|
|
184
|
+
// markdown→HTML conversion produces malformed output (the
|
|
185
|
+
// msg-10794 case: streamer truncate cut mid `**bold**` marker).
|
|
186
|
+
// 3. 429 rate limit → sleep retry_after seconds, retry once.
|
|
187
|
+
// Telegram's per-bot limit is ~30 req/s; high-effort xhigh turns
|
|
188
|
+
// with many parallel sessions can occasionally hit it. Honor
|
|
189
|
+
// Telegram's hint rather than bombing the call.
|
|
190
|
+
const RETRY_AFTER_CAP_MS = 60_000;
|
|
191
|
+
const tryOnce = async (p) => {
|
|
192
|
+
try {
|
|
193
|
+
return await rawAttempt(p);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (method === 'editMessageText' && isMessageNotModifiedError(err)) {
|
|
196
|
+
try { db?.logEvent('telegram-edit-skip-not-modified', { chat_id: chatId, message_id: p.message_id }); }
|
|
197
|
+
catch {}
|
|
198
|
+
// Synthetic success — message_id known, date best-effort.
|
|
199
|
+
return { message_id: p.message_id, date: Math.floor(Date.now() / 1000), _notModified: true };
|
|
200
|
+
}
|
|
201
|
+
if (formattingApplied && isHtmlParseError(err)) {
|
|
202
|
+
logger.warn?.(`[telegram] ${method}: HTML parse error, retrying as plain text`);
|
|
203
|
+
try {
|
|
204
|
+
db?.logEvent('telegram-html-fallback', {
|
|
205
|
+
chat_id: chatId, method,
|
|
206
|
+
error: redactBotToken(err.message)?.slice(0, 200),
|
|
207
|
+
});
|
|
208
|
+
} catch {}
|
|
209
|
+
const plainParams = { ...p };
|
|
210
|
+
delete plainParams.parse_mode;
|
|
211
|
+
plainParams[formatField] = rawFieldValue;
|
|
212
|
+
return await rawAttempt(plainParams);
|
|
213
|
+
}
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const safeAttempt = async (p) => {
|
|
147
218
|
try {
|
|
148
|
-
|
|
219
|
+
return await tryOnce(p);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (!isRateLimitError(err)) throw err;
|
|
222
|
+
// Sleep retry_after then retry once. Cap at 60s so a misconfigured
|
|
223
|
+
// value can't park the call indefinitely.
|
|
224
|
+
const ms = Math.min(getRetryAfterMs(err) ?? 1000, RETRY_AFTER_CAP_MS);
|
|
225
|
+
logger.warn?.(`[telegram] ${method}: 429 rate-limited, retry-after ${ms}ms`);
|
|
226
|
+
try { db?.logEvent('telegram-rate-limit', { chat_id: chatId, method, retry_after_ms: ms }); }
|
|
227
|
+
catch {}
|
|
228
|
+
await sleep(ms);
|
|
229
|
+
return await tryOnce(p);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// 0.7.0: pre-connect retry layer (single retry on transient pre-conn
|
|
234
|
+
// errors). Composes inside thread-fallback so a re-attempt without
|
|
235
|
+
// thread_id also benefits from the retry.
|
|
236
|
+
const withPreConnectRetry = async (p) => {
|
|
237
|
+
try {
|
|
238
|
+
return await safeAttempt(p);
|
|
149
239
|
} catch (err) {
|
|
150
240
|
// Pre-connect errors (DNS flap, TCP refused, net unreach) never
|
|
151
|
-
// reached Telegram, so retrying can't double-send. Retry ONCE
|
|
152
|
-
// a short delay before treating as fatal. Post-connect
|
|
153
|
-
// (ETIMEDOUT, EPIPE, 5xx) are NOT retried — the message
|
|
154
|
-
// landed server-side.
|
|
241
|
+
// reached Telegram, so retrying can't double-send. Retry ONCE
|
|
242
|
+
// after a short delay before treating as fatal. Post-connect
|
|
243
|
+
// errors (ETIMEDOUT, EPIPE, 5xx) are NOT retried — the message
|
|
244
|
+
// might have landed server-side.
|
|
155
245
|
if (isSafeToRetry(err)) {
|
|
156
246
|
try { db?.logEvent('telegram-retry', { chat_id: chatId, method, code: err.code, name: err.name }); }
|
|
157
247
|
catch {}
|
|
158
248
|
await sleep(PRE_CONNECT_RETRY_DELAY_MS);
|
|
159
|
-
|
|
160
|
-
} else {
|
|
161
|
-
throw err;
|
|
249
|
+
return await safeAttempt(p);
|
|
162
250
|
}
|
|
251
|
+
throw err;
|
|
163
252
|
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// 0.7.0: thread-fallback layer (port of OpenClaw's
|
|
256
|
+
// withTelegramThreadFallback). On `message thread not found` /
|
|
257
|
+
// TOPIC_DELETED, retry once with thread_id stripped — the reply
|
|
258
|
+
// lands in the chat root instead of the deleted topic.
|
|
259
|
+
const withThreadFallback = async (p) => {
|
|
260
|
+
try {
|
|
261
|
+
return await withPreConnectRetry(p);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (!isThreadNotFound(err) || p.message_thread_id == null) throw err;
|
|
264
|
+
const retryParams = { ...p };
|
|
170
265
|
delete retryParams.message_thread_id;
|
|
266
|
+
logger.error?.(`[telegram] ${method}: thread gone, retrying without thread_id`);
|
|
171
267
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
try { db?.logEvent('telegram-thread-fallback', { chat_id: chatId, method, original_thread_id: String(params.message_thread_id) }); }
|
|
268
|
+
const out = await withPreConnectRetry(retryParams);
|
|
269
|
+
try { db?.logEvent('telegram-thread-fallback', { chat_id: chatId, method, original_thread_id: String(p.message_thread_id) }); }
|
|
175
270
|
catch {}
|
|
271
|
+
return out;
|
|
176
272
|
} catch (err2) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// some undici/network error shapes embed the request URL
|
|
180
|
-
// (which carries `bot${TOKEN}`) into err.message.
|
|
181
|
-
const safe2 = redactBotToken(err2.message);
|
|
182
|
-
try { db.markOutboundFailed(rowId, safe2); }
|
|
183
|
-
catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
|
|
184
|
-
try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: safe2 }); }
|
|
185
|
-
catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
|
|
186
|
-
}
|
|
273
|
+
// Re-throw with the SECOND error — that's the actually-fatal
|
|
274
|
+
// one (the thread-fallback retry didn't save us).
|
|
187
275
|
throw err2;
|
|
188
276
|
}
|
|
189
|
-
} else {
|
|
190
|
-
if (rowId != null && db) {
|
|
191
|
-
const safe = redactBotToken(err.message);
|
|
192
|
-
try { db.markOutboundFailed(rowId, safe); }
|
|
193
|
-
catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
|
|
194
|
-
try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: safe }); }
|
|
195
|
-
catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
|
|
196
|
-
}
|
|
197
|
-
throw err;
|
|
198
277
|
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
res = await withThreadFallback(params);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (rowId != null && db) {
|
|
284
|
+
// 0.6.14: redact bot tokens before persisting err.message —
|
|
285
|
+
// some undici/network error shapes embed the request URL
|
|
286
|
+
// (which carries `bot${TOKEN}`) into err.message.
|
|
287
|
+
const safe = redactBotToken(err.message);
|
|
288
|
+
try { db.markOutboundFailed(rowId, safe); }
|
|
289
|
+
catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
|
|
290
|
+
try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: safe }); }
|
|
291
|
+
catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
199
294
|
}
|
|
200
295
|
|
|
201
296
|
if (rowId != null && db) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc-client.js",
|
|
6
6
|
"bin": {
|