polygram 0.6.15 → 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.
@@ -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
+ };
@@ -272,4 +272,110 @@ function toTelegramHtml(text) {
272
272
 
273
273
  function toTelegramMarkdown(text) { return toTelegramHtml(text); }
274
274
 
275
- module.exports = { toTelegramMarkdown, toTelegramHtml, wrapFileReferencesInHtml, escapeHtml };
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 { toTelegramMarkdown } = require('./telegram-format');
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 attempt = async (p) => bot.api.raw[method](p);
146
- try {
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
- res = await attempt(params);
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 after
152
- // a short delay before treating as fatal. Post-connect errors
153
- // (ETIMEDOUT, EPIPE, 5xx) are NOT retried — the message might have
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
- res = await attempt(params);
160
- } else {
161
- throw err;
249
+ return await safeAttempt(p);
162
250
  }
251
+ throw err;
163
252
  }
164
- } catch (err) {
165
- // Forum topic was deleted mid-turn — retry to chat root rather than
166
- // failing the whole reply. Only for methods that accept a thread id
167
- // (send*), and only once per call.
168
- if (isThreadNotFound(err) && params.message_thread_id != null) {
169
- const retryParams = { ...params };
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
- logger.error?.(`[telegram] ${method}: thread gone, retrying without thread_id`);
173
- res = await bot.api.raw[method](retryParams);
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
- if (rowId != null && db) {
178
- // 0.6.14: redact bot tokens before persisting err.message —
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.6.15",
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": {