polygram 0.6.16 → 0.7.1
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 +150 -0
- package/lib/deliver.js +78 -0
- package/lib/net-errors.js +52 -3
- package/lib/process-manager.js +30 -6
- package/lib/sent-cache.js +119 -0
- package/lib/stream-reply.js +133 -17
- package/lib/telegram-chunk.js +288 -0
- package/lib/telegram-format.js +107 -1
- package/lib/telegram.js +159 -39
- package/package.json +1 -1
- package/polygram.js +177 -51
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,151 @@ 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
|
+
// OpenClaw-style fallback layers, composing outermost-to-innermost:
|
|
177
|
+
//
|
|
178
|
+
// withThreadFallback (outer) — strips message_thread_id and
|
|
179
|
+
// retries on TOPIC_DELETED / "thread
|
|
180
|
+
// not found"
|
|
181
|
+
// withPreConnectRetry — single retry on transient pre-
|
|
182
|
+
// connect errors (DNS flap, TCP
|
|
183
|
+
// refused, ENETUNREACH); never
|
|
184
|
+
// retries post-connect errors that
|
|
185
|
+
// might have landed
|
|
186
|
+
// safeAttempt — sleeps `retry_after` and retries
|
|
187
|
+
// once on 429
|
|
188
|
+
// tryOnce (innermost) — handles two per-call recoveries:
|
|
189
|
+
// (a) MESSAGE_NOT_MODIFIED on
|
|
190
|
+
// editMessageText → synthetic
|
|
191
|
+
// success (streamer debounce often
|
|
192
|
+
// lands on no-op edits, Telegram
|
|
193
|
+
// returns 400 we don't want to
|
|
194
|
+
// propagate); (b) HTML parse error
|
|
195
|
+
// → retry as plain text with the
|
|
196
|
+
// raw pre-conversion field value
|
|
197
|
+
// restored
|
|
198
|
+
// rawAttempt — bot.api.raw[method](params)
|
|
199
|
+
//
|
|
200
|
+
// Each layer is a closure built per call; allocation cost is
|
|
201
|
+
// negligible vs. network RTT.
|
|
202
|
+
const RETRY_AFTER_CAP_MS = 60_000;
|
|
203
|
+
const tryOnce = async (p) => {
|
|
147
204
|
try {
|
|
148
|
-
|
|
205
|
+
return await rawAttempt(p);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (method === 'editMessageText' && isMessageNotModifiedError(err)) {
|
|
208
|
+
try { db?.logEvent('telegram-edit-skip-not-modified', { chat_id: chatId, message_id: p.message_id }); }
|
|
209
|
+
catch {}
|
|
210
|
+
// Synthetic success — message_id known, date best-effort.
|
|
211
|
+
return { message_id: p.message_id, date: Math.floor(Date.now() / 1000), _notModified: true };
|
|
212
|
+
}
|
|
213
|
+
if (formattingApplied && isHtmlParseError(err)) {
|
|
214
|
+
logger.warn?.(`[telegram] ${method}: HTML parse error, retrying as plain text`);
|
|
215
|
+
try {
|
|
216
|
+
db?.logEvent('telegram-html-fallback', {
|
|
217
|
+
chat_id: chatId, method,
|
|
218
|
+
error: redactBotToken(err.message)?.slice(0, 200),
|
|
219
|
+
});
|
|
220
|
+
} catch {}
|
|
221
|
+
const plainParams = { ...p };
|
|
222
|
+
delete plainParams.parse_mode;
|
|
223
|
+
plainParams[formatField] = rawFieldValue;
|
|
224
|
+
try {
|
|
225
|
+
return await rawAttempt(plainParams);
|
|
226
|
+
} catch (plainErr) {
|
|
227
|
+
// 0.7.1: if the plain retry also fails, preserve BOTH errors
|
|
228
|
+
// in the message that propagates. Pre-fix, only `plainErr`
|
|
229
|
+
// bubbled up — operators investigating from markOutboundFailed
|
|
230
|
+
// saw e.g. "Forbidden: bot was kicked" and missed that the
|
|
231
|
+
// ORIGINAL failure was a markdown→HTML parse bug.
|
|
232
|
+
const origMsg = redactBotToken(err.message)?.slice(0, 200);
|
|
233
|
+
const wrapped = new Error(`plain-retry failed (after HTML parse error: ${origMsg}): ${plainErr.message}`);
|
|
234
|
+
if (plainErr.code) wrapped.code = plainErr.code;
|
|
235
|
+
if (plainErr.parameters) wrapped.parameters = plainErr.parameters;
|
|
236
|
+
throw wrapped;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const safeAttempt = async (p) => {
|
|
243
|
+
try {
|
|
244
|
+
return await tryOnce(p);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (!isRateLimitError(err)) throw err;
|
|
247
|
+
// Sleep retry_after then retry once. Cap at 60s so a misconfigured
|
|
248
|
+
// value can't park the call indefinitely.
|
|
249
|
+
const ms = Math.min(getRetryAfterMs(err) ?? 1000, RETRY_AFTER_CAP_MS);
|
|
250
|
+
logger.warn?.(`[telegram] ${method}: 429 rate-limited, retry-after ${ms}ms`);
|
|
251
|
+
try { db?.logEvent('telegram-rate-limit', { chat_id: chatId, method, retry_after_ms: ms }); }
|
|
252
|
+
catch {}
|
|
253
|
+
await sleep(ms);
|
|
254
|
+
return await tryOnce(p);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// 0.7.0: pre-connect retry layer (single retry on transient pre-conn
|
|
259
|
+
// errors). Composes inside thread-fallback so a re-attempt without
|
|
260
|
+
// thread_id also benefits from the retry.
|
|
261
|
+
const withPreConnectRetry = async (p) => {
|
|
262
|
+
try {
|
|
263
|
+
return await safeAttempt(p);
|
|
149
264
|
} catch (err) {
|
|
150
265
|
// 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.
|
|
266
|
+
// reached Telegram, so retrying can't double-send. Retry ONCE
|
|
267
|
+
// after a short delay before treating as fatal. Post-connect
|
|
268
|
+
// errors (ETIMEDOUT, EPIPE, 5xx) are NOT retried — the message
|
|
269
|
+
// might have landed server-side.
|
|
155
270
|
if (isSafeToRetry(err)) {
|
|
156
271
|
try { db?.logEvent('telegram-retry', { chat_id: chatId, method, code: err.code, name: err.name }); }
|
|
157
272
|
catch {}
|
|
158
273
|
await sleep(PRE_CONNECT_RETRY_DELAY_MS);
|
|
159
|
-
|
|
160
|
-
} else {
|
|
161
|
-
throw err;
|
|
274
|
+
return await safeAttempt(p);
|
|
162
275
|
}
|
|
276
|
+
throw err;
|
|
163
277
|
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// 0.7.0: thread-fallback layer (port of OpenClaw's
|
|
281
|
+
// withTelegramThreadFallback). On `message thread not found` /
|
|
282
|
+
// TOPIC_DELETED, retry once with thread_id stripped — the reply
|
|
283
|
+
// lands in the chat root instead of the deleted topic.
|
|
284
|
+
const withThreadFallback = async (p) => {
|
|
285
|
+
try {
|
|
286
|
+
return await withPreConnectRetry(p);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (!isThreadNotFound(err) || p.message_thread_id == null) throw err;
|
|
289
|
+
const retryParams = { ...p };
|
|
170
290
|
delete retryParams.message_thread_id;
|
|
291
|
+
logger.error?.(`[telegram] ${method}: thread gone, retrying without thread_id`);
|
|
171
292
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
try { db?.logEvent('telegram-thread-fallback', { chat_id: chatId, method, original_thread_id: String(params.message_thread_id) }); }
|
|
293
|
+
const out = await withPreConnectRetry(retryParams);
|
|
294
|
+
try { db?.logEvent('telegram-thread-fallback', { chat_id: chatId, method, original_thread_id: String(p.message_thread_id) }); }
|
|
175
295
|
catch {}
|
|
296
|
+
return out;
|
|
176
297
|
} 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
|
-
}
|
|
298
|
+
// Re-throw with the SECOND error — that's the actually-fatal
|
|
299
|
+
// one (the thread-fallback retry didn't save us).
|
|
187
300
|
throw err2;
|
|
188
301
|
}
|
|
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
302
|
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
res = await withThreadFallback(params);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (rowId != null && db) {
|
|
309
|
+
// 0.6.14: redact bot tokens before persisting err.message —
|
|
310
|
+
// some undici/network error shapes embed the request URL
|
|
311
|
+
// (which carries `bot${TOKEN}`) into err.message.
|
|
312
|
+
const safe = redactBotToken(err.message);
|
|
313
|
+
try { db.markOutboundFailed(rowId, safe); }
|
|
314
|
+
catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
|
|
315
|
+
try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: safe }); }
|
|
316
|
+
catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
|
|
317
|
+
}
|
|
318
|
+
throw err;
|
|
199
319
|
}
|
|
200
320
|
|
|
201
321
|
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.1",
|
|
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": {
|
package/polygram.js
CHANGED
|
@@ -32,6 +32,9 @@ const { parseBotArg, parseDbArg, filterConfigToBot } = require('./lib/config-sco
|
|
|
32
32
|
const { createStore: createPairingsStore, parseTtl: parsePairingTtl } = require('./lib/pairings');
|
|
33
33
|
const { transcribe: transcribeVoice, isVoiceAttachment } = require('./lib/voice');
|
|
34
34
|
const { createStreamer } = require('./lib/stream-reply');
|
|
35
|
+
const { chunkMarkdownText } = require('./lib/telegram-chunk');
|
|
36
|
+
const { deliverReplies } = require('./lib/deliver');
|
|
37
|
+
const { announce, shouldAnnounce } = require('./lib/announces');
|
|
35
38
|
const { isAbortRequest } = require('./lib/abort-detector');
|
|
36
39
|
const { startTyping } = require('./lib/typing-indicator');
|
|
37
40
|
const { redactBotToken } = require('./lib/net-errors');
|
|
@@ -909,22 +912,6 @@ function parseResponse(text) {
|
|
|
909
912
|
return { text: trimmed, sticker: null, stickerLabel: null, reaction: null };
|
|
910
913
|
}
|
|
911
914
|
|
|
912
|
-
// ─── Reply chunking ─────────────────────────────────────────────────
|
|
913
|
-
|
|
914
|
-
function chunkText(text, maxLen = TG_MAX_LEN) {
|
|
915
|
-
if (text.length <= maxLen) return [text];
|
|
916
|
-
const chunks = [];
|
|
917
|
-
let remaining = text;
|
|
918
|
-
while (remaining.length > 0) {
|
|
919
|
-
if (remaining.length <= maxLen) { chunks.push(remaining); break; }
|
|
920
|
-
let splitAt = remaining.lastIndexOf('\n', maxLen);
|
|
921
|
-
if (splitAt < maxLen * 0.3) splitAt = maxLen;
|
|
922
|
-
chunks.push(remaining.slice(0, splitAt));
|
|
923
|
-
remaining = remaining.slice(splitAt).replace(/^\n/, '');
|
|
924
|
-
}
|
|
925
|
-
return chunks;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
915
|
// ─── Cron/IPC send ─────────────────────────────────────────────────
|
|
929
916
|
|
|
930
917
|
// Allowlist of Telegram Bot API methods external callers (cron) may invoke.
|
|
@@ -1663,11 +1650,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1663
1650
|
});
|
|
1664
1651
|
|
|
1665
1652
|
const botCfg = config.bot || {};
|
|
1653
|
+
// 0.7.0: per-chat / per-bot link-preview opt-out (port from OpenClaw).
|
|
1654
|
+
// chat-level wins over bot-level. Default (both undefined) preserves
|
|
1655
|
+
// Telegram's native auto-preview behavior.
|
|
1656
|
+
const linkPreview = chatConfig.linkPreview != null
|
|
1657
|
+
? chatConfig.linkPreview
|
|
1658
|
+
: botCfg.linkPreview;
|
|
1666
1659
|
const outMetaBase = {
|
|
1667
1660
|
source: 'bot-reply-stream',
|
|
1668
1661
|
botName: BOT_NAME,
|
|
1669
1662
|
model: chatConfig.model,
|
|
1670
1663
|
effort: chatConfig.effort,
|
|
1664
|
+
...(linkPreview === false ? { linkPreview: false } : {}),
|
|
1671
1665
|
};
|
|
1672
1666
|
|
|
1673
1667
|
// Streaming is unconditional as of 0.4.0 — matches OpenClaw's model and
|
|
@@ -1710,6 +1704,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1710
1704
|
throw err;
|
|
1711
1705
|
}
|
|
1712
1706
|
},
|
|
1707
|
+
deleteMessage: async (messageId) => {
|
|
1708
|
+
// 0.7.0: route preview-discard through tg() so the action is
|
|
1709
|
+
// visible in the events log and gets the same retry/network
|
|
1710
|
+
// protections as other API calls. Failure is non-fatal: stale
|
|
1711
|
+
// bubble at chat top is acceptable when the actual reply has
|
|
1712
|
+
// already been redelivered as fresh chunks.
|
|
1713
|
+
try {
|
|
1714
|
+
await tg(bot, 'deleteMessage', {
|
|
1715
|
+
chat_id: chatId, message_id: messageId,
|
|
1716
|
+
}, { source: 'bot-reply-stream-discard', botName: BOT_NAME });
|
|
1717
|
+
} catch (err) {
|
|
1718
|
+
console.error(`[${label}] discard preview failed: ${err.message}`);
|
|
1719
|
+
throw err;
|
|
1720
|
+
}
|
|
1721
|
+
},
|
|
1713
1722
|
minChars: botCfg.streamMinChars,
|
|
1714
1723
|
throttleMs: botCfg.streamThrottleMs,
|
|
1715
1724
|
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
@@ -1739,12 +1748,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1739
1748
|
// at which point we flip to THINKING (🤔).
|
|
1740
1749
|
reactor.setState('QUEUED');
|
|
1741
1750
|
|
|
1742
|
-
// Mark the inbound row terminal so boot replay doesn't pick it up
|
|
1743
|
-
// Must fire down EVERY non-throwing exit path (early returns
|
|
1744
|
-
// NO_REPLY, streamed-reply
|
|
1745
|
-
//
|
|
1746
|
-
//
|
|
1747
|
-
// stuck at 'dispatched' forever
|
|
1751
|
+
// Mark the inbound row terminal so boot replay doesn't pick it up
|
|
1752
|
+
// again. Must fire down EVERY non-throwing exit path (early returns
|
|
1753
|
+
// for error / NO_REPLY, streamed-reply preview-becomes-final, the
|
|
1754
|
+
// discard+redeliver branch, regular reply at end). Earlier versions
|
|
1755
|
+
// only marked at the bottom of try, so streamed-reply early returns
|
|
1756
|
+
// left handler_status stuck at 'dispatched' forever and the next
|
|
1757
|
+
// boot replayed every long turn.
|
|
1748
1758
|
const markReplied = () => dbWrite(() => db.setInboundHandlerStatus({
|
|
1749
1759
|
chat_id: chatId, msg_id: msg.message_id, status: 'replied',
|
|
1750
1760
|
}), 'set handler_status=replied');
|
|
@@ -1782,34 +1792,117 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1782
1792
|
reactor.clear().catch(() => {});
|
|
1783
1793
|
}
|
|
1784
1794
|
|
|
1785
|
-
|
|
1795
|
+
// 0.7.0: empty-response fallback (port from OpenClaw —
|
|
1796
|
+
// EMPTY_RESPONSE_FALLBACK at reply-CdjLMJxg.js:40323). When
|
|
1797
|
+
// Claude finishes WITHOUT producing any text (e.g. only tool
|
|
1798
|
+
// calls, or aborted before writing the assistant message), send
|
|
1799
|
+
// a placeholder so the user doesn't see silence with no reaction.
|
|
1800
|
+
// NO_REPLY is an explicit "stay silent" signal from the agent —
|
|
1801
|
+
// those still markReplied silently.
|
|
1802
|
+
if (result.text === 'NO_REPLY') { markReplied(); return; }
|
|
1803
|
+
if (!result.text) {
|
|
1804
|
+
// 0.7.1: if the fallback send itself fails, throw rather than
|
|
1805
|
+
// silently markReplied — the user gets nothing AND the inbound
|
|
1806
|
+
// is marked replied so boot replay won't redispatch. Same
|
|
1807
|
+
// anti-pattern that caused msg-10794. Promote to a thrown error
|
|
1808
|
+
// so dispatchHandleMessage's catch branches correctly:
|
|
1809
|
+
// shutdown → 'replay-pending' (boot replay retries)
|
|
1810
|
+
// runtime → 'failed' + user-visible apology via errorReplyText
|
|
1811
|
+
try {
|
|
1812
|
+
await tg(bot, 'sendMessage', {
|
|
1813
|
+
chat_id: chatId,
|
|
1814
|
+
text: 'No response generated. Please try again.',
|
|
1815
|
+
...(threadId && { message_thread_id: threadId }),
|
|
1816
|
+
reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
|
|
1817
|
+
}, { ...outMetaBase, source: 'empty-response-fallback' });
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
reactor.setState('ERROR');
|
|
1820
|
+
logEvent('telegram-empty-response-fallback-failed', {
|
|
1821
|
+
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
1822
|
+
error: err.message?.slice(0, 200),
|
|
1823
|
+
});
|
|
1824
|
+
throw new Error(`empty-response fallback send failed: ${err.message}`);
|
|
1825
|
+
}
|
|
1826
|
+
logEvent('telegram-empty-response-fallback', {
|
|
1827
|
+
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
1828
|
+
});
|
|
1829
|
+
markReplied();
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1786
1832
|
|
|
1787
1833
|
const parsed = parseResponse(result.text);
|
|
1788
1834
|
const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
|
|
1789
1835
|
|
|
1790
|
-
//
|
|
1791
|
-
//
|
|
1836
|
+
// OpenClaw's preview-becomes-final flow:
|
|
1837
|
+
//
|
|
1838
|
+
// 1. flushDraft() — drain any pending throttled edit so the
|
|
1839
|
+
// bubble's visible state is up-to-date before deciding.
|
|
1840
|
+
// 2. finalize(parsed.text) — try to bring the bubble to the
|
|
1841
|
+
// final body. Returns rich result describing whether the
|
|
1842
|
+
// preview can stand as the final reply.
|
|
1843
|
+
// 3a. finalEditOk:true → preview IS final, done.
|
|
1844
|
+
// 3b. overflow OR !finalEditOk → discard preview, redeliver
|
|
1845
|
+
// via deliverReplies(chunkMarkdownText(...)). The bubble
|
|
1846
|
+
// couldn't render the full body (size or parse error), so
|
|
1847
|
+
// we delete it cleanly and send the proper chunks fresh at
|
|
1848
|
+
// chat bottom — no content lost, no stranded bubble.
|
|
1792
1849
|
if (parsed.text) {
|
|
1850
|
+
await streamer.flushDraft();
|
|
1793
1851
|
const fin = await streamer.finalize(parsed.text);
|
|
1794
1852
|
if (fin.streamed) {
|
|
1795
|
-
if (
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1853
|
+
if (fin.finalEditOk) {
|
|
1854
|
+
// Preview was successfully edited to the final text.
|
|
1855
|
+
// No follow-up messages needed.
|
|
1856
|
+
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1857
|
+
markReplied();
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
// Preview can't hold the final body (overflow OR last edit
|
|
1861
|
+
// failed even after our HTML→plain fallback). Delete it and
|
|
1862
|
+
// send the body as proper chunks.
|
|
1863
|
+
try { await streamer.discard(); }
|
|
1864
|
+
catch (err) { console.error(`[${label}] discard failed: ${err.message}`); }
|
|
1865
|
+
const chunks = chunkMarkdownText(parsed.text, TG_MAX_LEN);
|
|
1866
|
+
const r = await deliverReplies({
|
|
1867
|
+
bot,
|
|
1868
|
+
send: (b, method, params, m) => tg(b, method, params, m),
|
|
1869
|
+
chatId,
|
|
1870
|
+
threadId,
|
|
1871
|
+
chunks,
|
|
1872
|
+
replyToMessageId: msg.message_id,
|
|
1873
|
+
meta: outMeta,
|
|
1874
|
+
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
1875
|
+
});
|
|
1876
|
+
const reason = fin.overflow ? 'overflow' : 'edit-failed';
|
|
1877
|
+
logEvent('telegram-stream-redeliver', {
|
|
1878
|
+
chat_id: chatId, msg_id: msg.message_id,
|
|
1879
|
+
reason, chunks: chunks.length,
|
|
1880
|
+
delivered: r.sent.length, failed: r.failed.length,
|
|
1881
|
+
bot: BOT_NAME,
|
|
1882
|
+
});
|
|
1883
|
+
// 0.7.1: surface partial-failure to the user. Without this,
|
|
1884
|
+
// a chunk-3-of-5 failure leaves a coherent-looking reply with
|
|
1885
|
+
// a silent gap (the user reads chunks 1, 2, 4, 5 unaware
|
|
1886
|
+
// that chunk 3 was dropped). Append a warning + flip the
|
|
1887
|
+
// reactor to ERROR so something visible signals "look here".
|
|
1888
|
+
if (r.failed.length > 0) {
|
|
1889
|
+
reactor.setState('ERROR');
|
|
1890
|
+
try {
|
|
1891
|
+
await tg(bot, 'sendMessage', {
|
|
1892
|
+
chat_id: chatId,
|
|
1893
|
+
text: `⚠️ ${r.failed.length} of ${chunks.length} message parts failed to deliver. The reply may be incomplete — please retry.`,
|
|
1894
|
+
...(threadId && { message_thread_id: threadId }),
|
|
1895
|
+
}, { ...outMetaBase, source: 'partial-delivery-warning' });
|
|
1896
|
+
} catch (warnErr) {
|
|
1897
|
+
console.error(`[${label}] partial-delivery warning failed: ${warnErr.message}`);
|
|
1806
1898
|
}
|
|
1807
1899
|
}
|
|
1808
|
-
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1900
|
+
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed-redeliver(${reason}, ${chunks.length} chunks${r.failed.length ? `, ${r.failed.length} failed` : ''}) | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1809
1901
|
markReplied();
|
|
1810
1902
|
return;
|
|
1811
1903
|
}
|
|
1812
|
-
// Not streamed (response too short
|
|
1904
|
+
// Not streamed (response too short — never crossed minChars).
|
|
1905
|
+
// Fall through to the normal send path below.
|
|
1813
1906
|
}
|
|
1814
1907
|
|
|
1815
1908
|
if (parsed.reaction) {
|
|
@@ -1829,19 +1922,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1829
1922
|
console.error(`[${label}] sendSticker failed: ${err.message}`);
|
|
1830
1923
|
});
|
|
1831
1924
|
} else if (parsed.text) {
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1925
|
+
// 0.7.0: use markdown-aware chunker + deliverReplies primitive.
|
|
1926
|
+
// The old chunkText was newline/byte-only; chunkMarkdownText also
|
|
1927
|
+
// respects code-fence boundaries (closes + reopens across chunks).
|
|
1928
|
+
const chunks = chunkMarkdownText(parsed.text, TG_MAX_LEN);
|
|
1929
|
+
await deliverReplies({
|
|
1930
|
+
bot,
|
|
1931
|
+
send: (b, method, params, m) => tg(b, method, params, m),
|
|
1932
|
+
chatId,
|
|
1933
|
+
threadId,
|
|
1934
|
+
chunks,
|
|
1935
|
+
replyToMessageId: msg.message_id,
|
|
1936
|
+
meta: outMeta,
|
|
1937
|
+
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
1938
|
+
});
|
|
1845
1939
|
}
|
|
1846
1940
|
|
|
1847
1941
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
@@ -2480,6 +2574,38 @@ async function main() {
|
|
|
2480
2574
|
const head = entry.pendingQueue?.[0];
|
|
2481
2575
|
const r = head?.context?.reactor;
|
|
2482
2576
|
if (r) r.setState(classifyToolName(toolName));
|
|
2577
|
+
// 0.7.0 (Phase J): opt-in subagent announce. When Claude uses
|
|
2578
|
+
// the Task tool to spawn a subagent, post a brief informational
|
|
2579
|
+
// message to the chat so the user knows a heavier turn is in
|
|
2580
|
+
// progress. Off by default (per-bot or per-chat
|
|
2581
|
+
// `announceSubagents: true` opts in). Per-chat debounce 30s
|
|
2582
|
+
// prevents announce-storms in tool-heavy turns.
|
|
2583
|
+
const chatCfg = config.chats[entry.chatId] || {};
|
|
2584
|
+
const optIn = chatCfg.announceSubagents != null
|
|
2585
|
+
? chatCfg.announceSubagents
|
|
2586
|
+
: config.bot?.announceSubagents;
|
|
2587
|
+
if (toolName === 'Task' && optIn === true) {
|
|
2588
|
+
if (shouldAnnounce(entry.chatId)) {
|
|
2589
|
+
announce({
|
|
2590
|
+
send: (b, method, params, m) => tg(b, method, params, m),
|
|
2591
|
+
bot, chatId: entry.chatId,
|
|
2592
|
+
threadId: head?.context?.threadId ?? null,
|
|
2593
|
+
text: '🤖 Spawning subagent…',
|
|
2594
|
+
meta: { botName: BOT_NAME, source: 'subagent-announce' },
|
|
2595
|
+
logger: { error: (m) => console.error(`[${entry.label}] ${m}`) },
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
},
|
|
2600
|
+
// 0.7.0 (Phase F): each new top-level assistant message gets its
|
|
2601
|
+
// own bubble. When Claude emits text, then tool_use, then more
|
|
2602
|
+
// text in a NEW assistant message (typical of tool-heavy turns),
|
|
2603
|
+
// the previous bubble's content stays visible as a "thinking out
|
|
2604
|
+
// loud" intermediate; the new message starts fresh below.
|
|
2605
|
+
onAssistantMessageStart: (sessionKey, entry) => {
|
|
2606
|
+
const head = entry.pendingQueue?.[0];
|
|
2607
|
+
const s = head?.context?.streamer;
|
|
2608
|
+
if (s) s.forceNewMessage();
|
|
2483
2609
|
},
|
|
2484
2610
|
// Fires after a graceful /model or /effort drain has actually
|
|
2485
2611
|
// swapped to the new settings. Post a confirmation back to the
|