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
|
@@ -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.7.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 and a history skill.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
package/README.md
CHANGED
|
@@ -364,7 +364,7 @@ foreign-chat clicks are rejected. Default-deny on IPC error.
|
|
|
364
364
|
## Development
|
|
365
365
|
|
|
366
366
|
```bash
|
|
367
|
-
npm test #
|
|
367
|
+
npm test # 619 tests, 153 suites, node:test, no external services
|
|
368
368
|
npm run coverage # native test coverage (Node 22+, no devDeps)
|
|
369
369
|
npm start -- --bot my-bot
|
|
370
370
|
npm run split-db -- --config config.json --dry-run
|
package/lib/announces.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent / informational announces β a thin OpenClaw-style helper for
|
|
3
|
+
* sending small "I'm doing X" messages to a chat without mixing into
|
|
4
|
+
* the main reply flow.
|
|
5
|
+
*
|
|
6
|
+
* Polygram's user-facing surface for "shumabit is working" is the
|
|
7
|
+
* status reaction on the user's message (π β π€ β tool icons β
|
|
8
|
+
* π/π₯). For tool-heavy turns where the user wants more visibility
|
|
9
|
+
* β e.g. shumabit delegating to a subagent via Claude Code's Task
|
|
10
|
+
* tool β an opt-in announce can post a brief informational message
|
|
11
|
+
* to the chat. Off by default (`config.bots.<bot>.announceSubagents`
|
|
12
|
+
* or `config.chats.<id>.announceSubagents`), so existing chats see
|
|
13
|
+
* no behavior change.
|
|
14
|
+
*
|
|
15
|
+
* Note: this is the minimal MVP of OpenClaw's full subagent-announce
|
|
16
|
+
* queue (which has debounce, drop policies, multi-channel routing).
|
|
17
|
+
* Polygram's "subagents" are in-process (Claude Code Task tool), so
|
|
18
|
+
* the announce path is just a one-shot informational sendMessage.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const SUBAGENT_DEBOUNCE_MS = 30_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Per-chat debounce so a turn that spawns 5 subagents back-to-back
|
|
25
|
+
* doesn't post 5 announces. Module-scoped Map keyed by chatId β
|
|
26
|
+
* timestamp of last announce.
|
|
27
|
+
*/
|
|
28
|
+
const lastAnnounceByChat = new Map();
|
|
29
|
+
|
|
30
|
+
function shouldAnnounce(chatId, now = Date.now(), debounceMs = SUBAGENT_DEBOUNCE_MS) {
|
|
31
|
+
const prev = lastAnnounceByChat.get(String(chatId));
|
|
32
|
+
if (prev != null && now - prev < debounceMs) return false;
|
|
33
|
+
lastAnnounceByChat.set(String(chatId), now);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Send a plain-text announce (no markdown processing, no reply linkage).
|
|
39
|
+
* Caller passes `tg(bot, method, params, meta)` as `send` so we don't
|
|
40
|
+
* have to import the full lib/telegram.js dependency tree here.
|
|
41
|
+
*/
|
|
42
|
+
async function announce({
|
|
43
|
+
send,
|
|
44
|
+
bot,
|
|
45
|
+
chatId,
|
|
46
|
+
threadId = null,
|
|
47
|
+
text,
|
|
48
|
+
meta = {},
|
|
49
|
+
logger = console,
|
|
50
|
+
}) {
|
|
51
|
+
if (!text) return null;
|
|
52
|
+
const params = {
|
|
53
|
+
chat_id: chatId,
|
|
54
|
+
text,
|
|
55
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
return await send(bot, 'sendMessage', params, {
|
|
59
|
+
...meta,
|
|
60
|
+
source: meta.source || 'announce',
|
|
61
|
+
plainText: true, // skip markdownβHTML
|
|
62
|
+
linkPreview: false, // never preview-card for announces
|
|
63
|
+
});
|
|
64
|
+
} catch (err) {
|
|
65
|
+
logger.error?.(`[announce] failed: ${err.message}`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { announce, shouldAnnounce, SUBAGENT_DEBOUNCE_MS };
|
package/lib/deliver.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chunked reply delivery primitive.
|
|
3
|
+
*
|
|
4
|
+
* `deliverReplies` is the polygram analog of OpenClaw's `deliverReplies` β
|
|
5
|
+
* a small loop over already-chunked text that sends each chunk as its own
|
|
6
|
+
* Telegram message via the `send()` wrapper from lib/telegram.js (which
|
|
7
|
+
* does write-before-send, HTMLβplain fallback, and MESSAGE_NOT_MODIFIED
|
|
8
|
+
* swallowing).
|
|
9
|
+
*
|
|
10
|
+
* Polygram's old code path inlined a `for (chunk of chunkText(rest))` loop
|
|
11
|
+
* inside polygram.js with an ad-hoc `tg()` call. That worked, but mixed
|
|
12
|
+
* delivery concerns into the streaming-finalize logic, and made testing
|
|
13
|
+
* the multi-message path painful. With this primitive, the new
|
|
14
|
+
* "preview-becomes-final" flow in handleMessage (Phase 5) just calls:
|
|
15
|
+
*
|
|
16
|
+
* await deliverReplies({ bot, chatId, threadId, chunks, replyToMessageId, ... })
|
|
17
|
+
*
|
|
18
|
+
* β and gets back `{ sent, failed }` arrays of message_ids per chunk.
|
|
19
|
+
*
|
|
20
|
+
* Behavior:
|
|
21
|
+
* - Sends `chunks[0]` first with `reply_parameters` (so the answer
|
|
22
|
+
* visually anchors to the user's question). Subsequent chunks omit
|
|
23
|
+
* `reply_parameters` β chaining replies would clutter the chat.
|
|
24
|
+
* - On chunk failure, logs and continues to the next chunk. We'd
|
|
25
|
+
* rather deliver partial content than abort the whole reply.
|
|
26
|
+
* - Empty input returns `{ sent: [], failed: [] }` immediately.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
async function deliverReplies({
|
|
30
|
+
bot,
|
|
31
|
+
send, // (bot, method, params, meta) β res β usually createSender(db, logger)(...) or tg
|
|
32
|
+
chatId,
|
|
33
|
+
threadId = null,
|
|
34
|
+
chunks,
|
|
35
|
+
replyToMessageId = null,
|
|
36
|
+
meta = {},
|
|
37
|
+
logger = console,
|
|
38
|
+
}) {
|
|
39
|
+
if (!Array.isArray(chunks) || chunks.length === 0) {
|
|
40
|
+
return { sent: [], failed: [] };
|
|
41
|
+
}
|
|
42
|
+
const sent = [];
|
|
43
|
+
const failed = [];
|
|
44
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
45
|
+
const params = {
|
|
46
|
+
chat_id: chatId,
|
|
47
|
+
text: chunks[i],
|
|
48
|
+
};
|
|
49
|
+
if (threadId != null) params.message_thread_id = threadId;
|
|
50
|
+
if (i === 0 && replyToMessageId != null) {
|
|
51
|
+
// allow_sending_without_reply: long turns give the user time to
|
|
52
|
+
// delete their original message; without this flag Telegram
|
|
53
|
+
// rejects with MESSAGE_NOT_FOUND and the whole reply is lost.
|
|
54
|
+
params.reply_parameters = { message_id: replyToMessageId, allow_sending_without_reply: true };
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const res = await send(bot, 'sendMessage', params, meta);
|
|
58
|
+
const msgId = res?.message_id ?? null;
|
|
59
|
+
sent.push(msgId);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
logger.error?.(`[deliver] chunk ${i + 1}/${chunks.length} failed: ${err.message}`);
|
|
62
|
+
failed.push({ index: i, error: err.message });
|
|
63
|
+
// Keep going β partial delivery is better than total loss.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { sent, failed };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { deliverReplies };
|
package/lib/net-errors.js
CHANGED
|
@@ -30,20 +30,55 @@ const PRE_CONNECT_ERROR_CODES = new Set([
|
|
|
30
30
|
// Transient errors that are recoverable but may have made it partway. DO
|
|
31
31
|
// NOT auto-retry these β the risk of double-delivery outweighs the gain.
|
|
32
32
|
// Surface them to the caller and let humans decide.
|
|
33
|
+
//
|
|
34
|
+
// 0.7.0 added the UND_ERR_* family + ECONNABORTED / ERR_NETWORK to match
|
|
35
|
+
// OpenClaw's set (extensions/telegram/src/network-errors.ts). Node 22+
|
|
36
|
+
// uses undici as its default fetch impl, so these surface in real
|
|
37
|
+
// production traffic β pre-0.7.0 we'd silently misclassify them as
|
|
38
|
+
// non-network errors.
|
|
33
39
|
const RECOVERABLE_ERROR_CODES = new Set([
|
|
34
|
-
'ETIMEDOUT',
|
|
35
|
-
'EPIPE',
|
|
36
|
-
'EAGAIN',
|
|
40
|
+
'ETIMEDOUT', // TCP timeout after connect (message may have landed)
|
|
41
|
+
'EPIPE', // write after close β outcome indeterminate
|
|
42
|
+
'EAGAIN', // socket would block β reader should retry
|
|
43
|
+
'ESOCKETTIMEDOUT', // socket-level timeout (axios/legacy node)
|
|
44
|
+
'ECONNABORTED', // connection aborted by client (timeout-induced)
|
|
45
|
+
'ERR_NETWORK', // generic network error code
|
|
46
|
+
'UND_ERR_CONNECT_TIMEOUT', // undici: connection timeout
|
|
47
|
+
'UND_ERR_HEADERS_TIMEOUT', // undici: response headers timeout
|
|
48
|
+
'UND_ERR_BODY_TIMEOUT', // undici: response body timeout
|
|
49
|
+
'UND_ERR_SOCKET', // undici: socket error
|
|
50
|
+
'UND_ERR_ABORTED', // undici: request aborted
|
|
37
51
|
]);
|
|
38
52
|
|
|
39
53
|
// Error.name values emitted by undici/node for transient conditions.
|
|
54
|
+
// 0.7.0 added the undici-specific timeout error names; the new fetch
|
|
55
|
+
// impl in Node 22+ surfaces these as `err.name` rather than `err.code`
|
|
56
|
+
// in some shapes.
|
|
40
57
|
const RECOVERABLE_ERROR_NAMES = new Set([
|
|
41
58
|
'AbortError',
|
|
42
59
|
'TimeoutError',
|
|
43
60
|
'FetchError',
|
|
44
61
|
'SocketError',
|
|
62
|
+
'ConnectTimeoutError',
|
|
63
|
+
'HeadersTimeoutError',
|
|
64
|
+
'BodyTimeoutError',
|
|
45
65
|
]);
|
|
46
66
|
|
|
67
|
+
// Message-substring matchers for transient errors. undici sometimes
|
|
68
|
+
// wraps a network failure in a generic "fetch failed" without setting
|
|
69
|
+
// .code or .name β only the message tells us it's a network error.
|
|
70
|
+
//
|
|
71
|
+
// These are matched ONLY when the error doesn't already have a code or
|
|
72
|
+
// name we recognise, to avoid double-counting and to keep the broad
|
|
73
|
+
// matcher from catching unrelated errors that happen to include the
|
|
74
|
+
// substring.
|
|
75
|
+
const RECOVERABLE_MESSAGE_SNIPPETS = [
|
|
76
|
+
'fetch failed',
|
|
77
|
+
'undici',
|
|
78
|
+
'network error',
|
|
79
|
+
'network request',
|
|
80
|
+
];
|
|
81
|
+
|
|
47
82
|
function extractCode(err) {
|
|
48
83
|
if (!err) return null;
|
|
49
84
|
return err.code
|
|
@@ -67,6 +102,11 @@ function isSafeToRetry(err) {
|
|
|
67
102
|
return code != null && PRE_CONNECT_ERROR_CODES.has(code);
|
|
68
103
|
}
|
|
69
104
|
|
|
105
|
+
function extractMessage(err) {
|
|
106
|
+
if (!err) return '';
|
|
107
|
+
return String(err.message || err.cause?.message || err.description || '').toLowerCase();
|
|
108
|
+
}
|
|
109
|
+
|
|
70
110
|
/**
|
|
71
111
|
* Is this a transient network error β recoverable in the sense that the
|
|
72
112
|
* connection may work next time, but NOT safe to auto-retry because the
|
|
@@ -80,6 +120,13 @@ function isTransientNetworkError(err) {
|
|
|
80
120
|
}
|
|
81
121
|
const name = extractName(err);
|
|
82
122
|
if (name && RECOVERABLE_ERROR_NAMES.has(name)) return true;
|
|
123
|
+
// 0.7.0: only fall through to message-snippet matching when the
|
|
124
|
+
// error has no recognised code/name β avoids false-positive matches
|
|
125
|
+
// on unrelated errors that happen to mention "network".
|
|
126
|
+
const message = extractMessage(err);
|
|
127
|
+
if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((s) => message.includes(s))) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
83
130
|
return false;
|
|
84
131
|
}
|
|
85
132
|
|
|
@@ -113,9 +160,11 @@ module.exports = {
|
|
|
113
160
|
PRE_CONNECT_ERROR_CODES,
|
|
114
161
|
RECOVERABLE_ERROR_CODES,
|
|
115
162
|
RECOVERABLE_ERROR_NAMES,
|
|
163
|
+
RECOVERABLE_MESSAGE_SNIPPETS,
|
|
116
164
|
isSafeToRetry,
|
|
117
165
|
isTransientNetworkError,
|
|
118
166
|
extractCode,
|
|
119
167
|
extractName,
|
|
168
|
+
extractMessage,
|
|
120
169
|
redactBotToken,
|
|
121
170
|
};
|
package/lib/process-manager.js
CHANGED
|
@@ -59,6 +59,7 @@ class ProcessManager {
|
|
|
59
59
|
onClose = null, // (sessionKey, code, entry) β void
|
|
60
60
|
onStreamChunk = null, // (sessionKey, partialText, entry) β void β routes to pendingQueue[0]
|
|
61
61
|
onToolUse = null, // (sessionKey, toolName, entry) β void β routes to pendingQueue[0]
|
|
62
|
+
onAssistantMessageStart = null, // (sessionKey, entry) β void β fires when a NEW top-level assistant message begins (after a previous one ended). Used by polygram.js to call streamer.forceNewMessage() so each assistant message gets its own bubble.
|
|
62
63
|
onRespawn = null, // (sessionKey, reason, entry) β void β fires after graceful drain-and-kill
|
|
63
64
|
} = {}) {
|
|
64
65
|
if (!spawnFn) throw new Error('spawnFn required');
|
|
@@ -72,6 +73,7 @@ class ProcessManager {
|
|
|
72
73
|
this.onClose = onClose;
|
|
73
74
|
this.onStreamChunk = onStreamChunk;
|
|
74
75
|
this.onToolUse = onToolUse;
|
|
76
|
+
this.onAssistantMessageStart = onAssistantMessageStart;
|
|
75
77
|
this.onRespawn = onRespawn;
|
|
76
78
|
this.procs = new Map();
|
|
77
79
|
}
|
|
@@ -275,12 +277,34 @@ class ProcessManager {
|
|
|
275
277
|
}
|
|
276
278
|
|
|
277
279
|
if (event.type === 'assistant' && head) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
280
|
+
// 0.7.0 (Phase F): detect message_id transitions to split bubbles
|
|
281
|
+
// per top-level assistant message. Each Anthropic stream-json
|
|
282
|
+
// 'assistant' event carries event.message.id; the same id across
|
|
283
|
+
// events means cumulative updates to the same message, a new
|
|
284
|
+
// id means a new message (typically after a tool-result cycle).
|
|
285
|
+
const messageId = event.message?.id;
|
|
286
|
+
const added = extractAssistantText(event);
|
|
287
|
+
if (added) {
|
|
288
|
+
// Pre-0.7.0 we did `streamText = streamText + '\n\n' + added`,
|
|
289
|
+
// which DUPLICATED text on every update because `added` is
|
|
290
|
+
// the cumulative full text-so-far of the current assistant
|
|
291
|
+
// message (not a delta). 0.7.0 REPLACES instead β the new
|
|
292
|
+
// text is already cumulative β and uses messageId boundaries
|
|
293
|
+
// to fire onAssistantMessageStart for each new top-level
|
|
294
|
+
// assistant message. The streamer responds by force-creating
|
|
295
|
+
// a fresh bubble, so each assistant message gets its own.
|
|
296
|
+
const isNewMessage = head.lastAssistantMessageId != null
|
|
297
|
+
&& messageId != null
|
|
298
|
+
&& head.lastAssistantMessageId !== messageId
|
|
299
|
+
&& head.streamText
|
|
300
|
+
&& head.streamText.length > 0;
|
|
301
|
+
if (isNewMessage && this.onAssistantMessageStart) {
|
|
302
|
+
try { this.onAssistantMessageStart(sessionKey, entry); }
|
|
303
|
+
catch (err) { this.logger.error(`[${entry.label}] onAssistantMessageStart: ${err.message}`); }
|
|
304
|
+
}
|
|
305
|
+
if (messageId != null) head.lastAssistantMessageId = messageId;
|
|
306
|
+
head.streamText = added;
|
|
307
|
+
if (this.onStreamChunk) {
|
|
284
308
|
try { this.onStreamChunk(sessionKey, head.streamText, entry); }
|
|
285
309
|
catch (err) { this.logger.error(`[${entry.label}] onStreamChunk: ${err.message}`); }
|
|
286
310
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache of message IDs the bot has sent, per chat.
|
|
3
|
+
*
|
|
4
|
+
* Port of OpenClaw's `sent-message-cache` (send-DVX_zY9w.js:1014-1041).
|
|
5
|
+
* Use case: filter the bot's own messages out of activation logic in
|
|
6
|
+
* group chats β a bot reply with a URL would otherwise auto-trigger
|
|
7
|
+
* a self-reply if the chat's activation rule includes "any message
|
|
8
|
+
* with a URL". Polygram's existing `messages` table can answer the
|
|
9
|
+
* same question via SQL (`direction = 'out' AND chat_id AND msg_id`),
|
|
10
|
+
* but the in-memory cache is O(1) for the high-frequency callers
|
|
11
|
+
* (every inbound message reaction handler).
|
|
12
|
+
*
|
|
13
|
+
* 24-hour TTL: Telegram messages older than 48h can't be reacted to
|
|
14
|
+
* anyway, so 24h is a comfortable working set. Per-chat cleanup runs
|
|
15
|
+
* lazily when the chat exceeds 100 entries.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
const CLEANUP_THRESHOLD = 100;
|
|
20
|
+
|
|
21
|
+
function createSentCache() {
|
|
22
|
+
// chatKey β Map<msgId, ts>
|
|
23
|
+
const sentMessages = new Map();
|
|
24
|
+
|
|
25
|
+
function chatKey(chatId) { return String(chatId); }
|
|
26
|
+
|
|
27
|
+
function record(chatId, messageId) {
|
|
28
|
+
if (chatId == null || messageId == null) return;
|
|
29
|
+
const key = chatKey(chatId);
|
|
30
|
+
let entry = sentMessages.get(key);
|
|
31
|
+
if (!entry) {
|
|
32
|
+
entry = new Map();
|
|
33
|
+
sentMessages.set(key, entry);
|
|
34
|
+
}
|
|
35
|
+
entry.set(messageId, Date.now());
|
|
36
|
+
// Lazy GC: when the per-chat map gets crowded, drop expired
|
|
37
|
+
// entries. Cheap (O(n) over n β€ 100 + a bit) and amortises to O(1).
|
|
38
|
+
if (entry.size > CLEANUP_THRESHOLD) {
|
|
39
|
+
const cutoff = Date.now() - TTL_MS;
|
|
40
|
+
for (const [id, ts] of entry) if (ts < cutoff) entry.delete(id);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function wasSent(chatId, messageId) {
|
|
45
|
+
if (chatId == null || messageId == null) return false;
|
|
46
|
+
const key = chatKey(chatId);
|
|
47
|
+
const entry = sentMessages.get(key);
|
|
48
|
+
if (!entry) return false;
|
|
49
|
+
const ts = entry.get(messageId);
|
|
50
|
+
if (ts == null) return false;
|
|
51
|
+
if (Date.now() - ts > TTL_MS) {
|
|
52
|
+
entry.delete(messageId);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function size() {
|
|
59
|
+
let total = 0;
|
|
60
|
+
for (const entry of sentMessages.values()) total += entry.size;
|
|
61
|
+
return total;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clear() {
|
|
65
|
+
sentMessages.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { record, wasSent, size, clear };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { createSentCache, TTL_MS, CLEANUP_THRESHOLD };
|
package/lib/stream-reply.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Live streaming-reply state machine for a single turn.
|
|
3
3
|
*
|
|
4
|
-
* Lifecycle
|
|
4
|
+
* Lifecycle (0.7.0):
|
|
5
5
|
* idle -> (text >= minChars) -> live
|
|
6
6
|
* live -> (subsequent chunks) -> live (throttled edits)
|
|
7
|
-
*
|
|
7
|
+
* live -> forceNewMessage() -> idle (next chunk = new bubble)
|
|
8
|
+
* live -> discard() -> finalized (bubble deleted)
|
|
9
|
+
* any -> finalize(finalText) -> finalized
|
|
8
10
|
*
|
|
9
|
-
* The streamer never talks to Telegram directly β callers inject
|
|
10
|
-
* `
|
|
11
|
-
* polygram.js in charge of transcript writes, sticker/reaction
|
|
12
|
-
* error handling; this module is just a cadence machine.
|
|
11
|
+
* The streamer never talks to Telegram directly β callers inject `send(text)`,
|
|
12
|
+
* `edit(msg_id, text)`, and (new in 0.7.0) optional `deleteMessage(msg_id)`.
|
|
13
|
+
* That keeps polygram.js in charge of transcript writes, sticker/reaction
|
|
14
|
+
* routing, and error handling; this module is just a cadence machine.
|
|
15
|
+
*
|
|
16
|
+
* 0.7.0 finalize() returns rich result so the caller can decide whether the
|
|
17
|
+
* preview's last edit IS the final reply, or whether to discard the preview
|
|
18
|
+
* and redeliver via deliverReplies (overflow / final edit failed). This is
|
|
19
|
+
* the OpenClaw pattern: short replies preview-becomes-final (no flicker),
|
|
20
|
+
* long replies preview-deleted-redelivered (single coherent bubble flow at
|
|
21
|
+
* chat bottom).
|
|
13
22
|
*
|
|
14
23
|
* Test-friendly: inject `clock` (now() fn) and `schedule` (setTimeout-like)
|
|
15
24
|
* so a fake clock can drive throttle timing deterministically.
|
|
@@ -25,6 +34,7 @@ const DEFAULT_THROTTLE_MS = 1000;
|
|
|
25
34
|
function createStreamer({
|
|
26
35
|
send, // async (text) -> { message_id }
|
|
27
36
|
edit, // async (msg_id, text) -> void
|
|
37
|
+
deleteMessage = null, // async (msg_id) -> void [optional]
|
|
28
38
|
minChars = DEFAULT_MIN_CHARS,
|
|
29
39
|
throttleMs = DEFAULT_THROTTLE_MS,
|
|
30
40
|
maxLen = 4096,
|
|
@@ -41,7 +51,12 @@ function createStreamer({
|
|
|
41
51
|
let pendingEdit = null; // timer id
|
|
42
52
|
let flushPromise = null; // ongoing edit promise (for back-pressure)
|
|
43
53
|
|
|
44
|
-
|
|
54
|
+
// 0.7.0: this is the LIVE-EDIT truncation, used during streaming
|
|
55
|
+
// when latestText overshoots maxLen. The trailing "..." signals to
|
|
56
|
+
// the user that more is coming. At finalize time, we DON'T truncate
|
|
57
|
+
// β we either edit-to-final-as-is (caller already chunked correctly)
|
|
58
|
+
// or signal overflow back to the caller.
|
|
59
|
+
function truncateForLive(s) {
|
|
45
60
|
if (s.length <= maxLen) return s;
|
|
46
61
|
return s.slice(0, maxLen - 3) + '...';
|
|
47
62
|
}
|
|
@@ -56,7 +71,7 @@ function createStreamer({
|
|
|
56
71
|
if (state === 'idle') {
|
|
57
72
|
if (text.length < minChars) return;
|
|
58
73
|
state = 'live';
|
|
59
|
-
currentText =
|
|
74
|
+
currentText = truncateForLive(text);
|
|
60
75
|
try {
|
|
61
76
|
const res = await send(currentText);
|
|
62
77
|
msgId = res?.message_id ?? null;
|
|
@@ -90,7 +105,7 @@ function createStreamer({
|
|
|
90
105
|
async function flush() {
|
|
91
106
|
pendingEdit = null;
|
|
92
107
|
if (state !== 'live' || msgId == null) return;
|
|
93
|
-
const next =
|
|
108
|
+
const next = truncateForLive(latestText);
|
|
94
109
|
if (next === currentText) return;
|
|
95
110
|
lastEditTs = clock();
|
|
96
111
|
currentText = next;
|
|
@@ -98,38 +113,132 @@ function createStreamer({
|
|
|
98
113
|
flushPromise = edit(msgId, currentText);
|
|
99
114
|
await flushPromise;
|
|
100
115
|
} catch (err) {
|
|
101
|
-
// Non-fatal β maybe 429. Log and keep going; next
|
|
116
|
+
// Non-fatal β maybe 429 or transient. Log and keep going; next
|
|
117
|
+
// chunk will retry. The HTMLβplain fallback in lib/telegram.js
|
|
118
|
+
// already handles the most common cause (parse error from
|
|
119
|
+
// truncate cutting mid-tag).
|
|
102
120
|
logger.error(`[stream] edit failed: ${err.message}`);
|
|
103
121
|
} finally {
|
|
104
122
|
flushPromise = null;
|
|
105
123
|
}
|
|
106
124
|
}
|
|
107
125
|
|
|
126
|
+
// 0.7.0: explicitly drain any pending edit. Useful when the caller
|
|
127
|
+
// is about to make a finalize/discard decision and wants the bubble's
|
|
128
|
+
// visual state to be accurate (no stale half-rendered text under a
|
|
129
|
+
// pending timer).
|
|
130
|
+
async function flushDraft() {
|
|
131
|
+
if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; await flush(); }
|
|
132
|
+
if (flushPromise) { try { await flushPromise; } catch {} }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 0.7.0: reset bubble state so the next onChunk creates a NEW message.
|
|
136
|
+
// Used by the upcoming Phase 7 F (forceNewMessage on assistant-
|
|
137
|
+
// message-start) β when Claude emits a new top-level assistant message
|
|
138
|
+
// mid-turn (post tool-result), we want it in its own bubble below
|
|
139
|
+
// the previous one, not appended via edit.
|
|
140
|
+
function forceNewMessage() {
|
|
141
|
+
if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
|
|
142
|
+
// Don't await flushPromise β the caller has decided to start a new
|
|
143
|
+
// message; whatever the old bubble shows is "done".
|
|
144
|
+
msgId = null;
|
|
145
|
+
currentText = '';
|
|
146
|
+
latestText = '';
|
|
147
|
+
state = 'idle';
|
|
148
|
+
lastEditTs = 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 0.7.0: delete the current bubble via the injected deleteMessage
|
|
152
|
+
// callback. Used when the final reply overflows the preview's single-
|
|
153
|
+
// message capacity, so handleMessage will discard the preview and
|
|
154
|
+
// redeliver via deliverReplies (chunks land at chat bottom).
|
|
155
|
+
//
|
|
156
|
+
// Works whether state is 'live' OR 'finalized' β handleMessage's
|
|
157
|
+
// typical flow is finalize() β finalEditOk false β discard. The
|
|
158
|
+
// bubble's msgId is preserved through finalize so we can still
|
|
159
|
+
// delete it. If deleteMessage isn't provided, we just transition
|
|
160
|
+
// state without touching Telegram β the bubble stays at its last
|
|
161
|
+
// edited content, becoming a vestigial "head" of the conversation.
|
|
162
|
+
async function discard() {
|
|
163
|
+
if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
|
|
164
|
+
if (flushPromise) { try { await flushPromise; } catch {} }
|
|
165
|
+
const idToDelete = msgId;
|
|
166
|
+
state = 'finalized';
|
|
167
|
+
msgId = null;
|
|
168
|
+
let deleted = false;
|
|
169
|
+
if (idToDelete && typeof deleteMessage === 'function') {
|
|
170
|
+
try {
|
|
171
|
+
await deleteMessage(idToDelete);
|
|
172
|
+
deleted = true;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// Telegram rejects deletions of messages older than 48h or
|
|
175
|
+
// already-deleted ones. Non-fatal β the redelivery happens
|
|
176
|
+
// either way.
|
|
177
|
+
logger.warn?.(`[stream] discard deleteMessage failed: ${err.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { msgId: idToDelete, deleted };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 0.7.0: snapshot for callers that want to track the bubble's id
|
|
184
|
+
// for later cleanup (e.g. archive a superseded preview when
|
|
185
|
+
// forceNewMessage was called and the previous bubble should be
|
|
186
|
+
// deleted at end-of-turn).
|
|
187
|
+
function archive() {
|
|
188
|
+
return { msgId, currentText };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 0.7.0: rich result. `finalEditOk` tells caller whether the preview
|
|
192
|
+
// can stand as the final reply (true) or needs to be replaced via
|
|
193
|
+
// discard + deliverReplies (false). `overflow` is the one specific
|
|
194
|
+
// reason: body wouldn't fit in a single Telegram message.
|
|
108
195
|
async function finalize(finalText, { errorSuffix = null } = {}) {
|
|
109
|
-
if (state === 'finalized') return { streamed: false, msgId };
|
|
196
|
+
if (state === 'finalized') return { streamed: false, msgId, finalEditOk: false, overflow: false };
|
|
110
197
|
if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
|
|
111
198
|
if (flushPromise) { try { await flushPromise; } catch {} }
|
|
112
199
|
|
|
113
200
|
if (state === 'idle') {
|
|
114
201
|
state = 'finalized';
|
|
115
|
-
return { streamed: false, msgId: null };
|
|
202
|
+
return { streamed: false, msgId: null, finalEditOk: false, overflow: false };
|
|
116
203
|
}
|
|
117
204
|
|
|
118
|
-
// live β finalize
|
|
205
|
+
// live β finalize.
|
|
119
206
|
state = 'finalized';
|
|
120
207
|
let body = finalText ?? latestText;
|
|
121
208
|
if (errorSuffix) body = `${body}\n\nβ οΈ ${errorSuffix}`;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
209
|
+
|
|
210
|
+
// If body overflows the single-message cap, the caller needs to
|
|
211
|
+
// discard this bubble and redeliver via chunks. Don't try to edit.
|
|
212
|
+
if (body.length > maxLen) {
|
|
213
|
+
return { streamed: true, msgId, finalText: body, finalEditOk: false, overflow: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Body fits. Try one last edit to bring the bubble to the final
|
|
217
|
+
// text. If that succeeds, preview-IS-final and caller can return
|
|
218
|
+
// without redelivering. If it fails (e.g. parse error after our
|
|
219
|
+
// wrapper exhausts its retry, or a 5xx), caller should discard
|
|
220
|
+
// and redeliver β the bubble's content is unreliable.
|
|
221
|
+
if (body === currentText) {
|
|
222
|
+
// Already correct β no edit needed.
|
|
223
|
+
return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
await edit(msgId, body);
|
|
227
|
+
currentText = body;
|
|
228
|
+
return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
|
|
229
|
+
} catch (err) {
|
|
230
|
+
logger.error(`[stream] final edit failed: ${err.message}`);
|
|
231
|
+
return { streamed: true, msgId, finalText: body, finalEditOk: false, overflow: false };
|
|
126
232
|
}
|
|
127
|
-
return { streamed: true, msgId, finalText: next };
|
|
128
233
|
}
|
|
129
234
|
|
|
130
235
|
return {
|
|
131
236
|
onChunk,
|
|
132
237
|
finalize,
|
|
238
|
+
flushDraft,
|
|
239
|
+
forceNewMessage,
|
|
240
|
+
discard,
|
|
241
|
+
archive,
|
|
133
242
|
// Introspection for tests:
|
|
134
243
|
get state() { return state; },
|
|
135
244
|
get msgId() { return msgId; },
|