polygram 0.7.0 → 0.7.2
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 +91 -11
- package/lib/deliver.js +12 -3
- package/lib/sent-cache.js +59 -11
- package/lib/stream-reply.js +44 -19
- package/lib/telegram-chunk.js +29 -19
- package/lib/telegram.js +40 -15
- package/package.json +1 -1
- package/polygram.js +97 -25
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.2",
|
|
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 # 643 tests, 158 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
CHANGED
|
@@ -12,28 +12,102 @@
|
|
|
12
12
|
* or `config.chats.<id>.announceSubagents`), so existing chats see
|
|
13
13
|
* no behavior change.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* 0.7.1 redesign: factory-based with split read/write predicates
|
|
16
|
+
* (canAnnounce / markAnnounced) and lazy GC. Pre-0.7.1 had a
|
|
17
|
+
* module-scoped Map and a mutate-on-check `shouldAnnounce` predicate
|
|
18
|
+
* — both anti-patterns flagged in design review. The free-function
|
|
19
|
+
* API (`shouldAnnounce`, `announce`) is preserved for back-compat,
|
|
20
|
+
* delegating to a default singleton.
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
const SUBAGENT_DEBOUNCE_MS = 30_000;
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
|
-
* Per-chat debounce
|
|
25
|
-
*
|
|
26
|
-
*
|
|
26
|
+
* Per-chat debounce tracker. Returns:
|
|
27
|
+
* - canAnnounce(chatId): true if this chat hasn't announced within
|
|
28
|
+
* the debounce window. Pure read, NO mutation — safe for
|
|
29
|
+
* speculative checks.
|
|
30
|
+
* - markAnnounced(chatId): records `now` as the last announce time
|
|
31
|
+
* for this chat. Caller invokes after a successful send.
|
|
32
|
+
* - sweep(): drops entries older than `2 * debounceMs`. Called lazily
|
|
33
|
+
* on every canAnnounce check past a soft threshold.
|
|
34
|
+
* - size(): for tests / diagnostics.
|
|
35
|
+
* - clear(): for test isolation.
|
|
27
36
|
*/
|
|
28
|
-
|
|
37
|
+
function createAnnouncer({
|
|
38
|
+
debounceMs = SUBAGENT_DEBOUNCE_MS,
|
|
39
|
+
clock = Date.now,
|
|
40
|
+
sweepThreshold = 1000,
|
|
41
|
+
} = {}) {
|
|
42
|
+
const lastAnnounceByChat = new Map();
|
|
29
43
|
|
|
44
|
+
function key(chatId) { return String(chatId); }
|
|
45
|
+
|
|
46
|
+
function sweep() {
|
|
47
|
+
const cutoff = clock() - 2 * debounceMs;
|
|
48
|
+
for (const [k, ts] of lastAnnounceByChat) {
|
|
49
|
+
if (ts < cutoff) lastAnnounceByChat.delete(k);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function canAnnounce(chatId) {
|
|
54
|
+
if (lastAnnounceByChat.size > sweepThreshold) sweep();
|
|
55
|
+
const prev = lastAnnounceByChat.get(key(chatId));
|
|
56
|
+
return prev == null || (clock() - prev) >= debounceMs;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function markAnnounced(chatId) {
|
|
60
|
+
lastAnnounceByChat.set(key(chatId), clock());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
canAnnounce, markAnnounced, sweep,
|
|
65
|
+
get size() { return lastAnnounceByChat.size; },
|
|
66
|
+
clear() { lastAnnounceByChat.clear(); },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Default per-process state for the back-compat free-function API.
|
|
71
|
+
// Pre-0.7.1, this was the only API. Long-running daemons should still
|
|
72
|
+
// prefer createAnnouncer() for tests / multi-bot isolation, but
|
|
73
|
+
// polygram.js's single-bot-per-process model means the singleton works
|
|
74
|
+
// fine for the production path. The Map is pruned lazily inside
|
|
75
|
+
// shouldAnnounce when it grows past the sweep threshold.
|
|
76
|
+
const _defaultLastAnnouncements = new Map();
|
|
77
|
+
const _DEFAULT_SWEEP_THRESHOLD = 1000;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Back-compat: pre-0.7.1 callers used `shouldAnnounce(chatId, now?,
|
|
81
|
+
* debounceMs?)` which is a "predicate that mutates" — call site is
|
|
82
|
+
* `if (shouldAnnounce(id)) await sendAnnounce()`. The mutation happens
|
|
83
|
+
* eagerly. Preserved verbatim for callers; new code should use
|
|
84
|
+
* `createAnnouncer()` and the explicit canAnnounce/markAnnounced split.
|
|
85
|
+
*
|
|
86
|
+
* 0.7.1: added lazy sweep so the Map doesn't grow unbounded over a
|
|
87
|
+
* multi-week-uptime daemon.
|
|
88
|
+
*/
|
|
30
89
|
function shouldAnnounce(chatId, now = Date.now(), debounceMs = SUBAGENT_DEBOUNCE_MS) {
|
|
31
|
-
|
|
90
|
+
if (_defaultLastAnnouncements.size > _DEFAULT_SWEEP_THRESHOLD) {
|
|
91
|
+
const cutoff = now - 2 * debounceMs;
|
|
92
|
+
for (const [k, ts] of _defaultLastAnnouncements) {
|
|
93
|
+
if (ts < cutoff) _defaultLastAnnouncements.delete(k);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const key = String(chatId);
|
|
97
|
+
const prev = _defaultLastAnnouncements.get(key);
|
|
32
98
|
if (prev != null && now - prev < debounceMs) return false;
|
|
33
|
-
|
|
99
|
+
_defaultLastAnnouncements.set(key, now);
|
|
34
100
|
return true;
|
|
35
101
|
}
|
|
36
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Reset the default singleton state (for tests). Not exported in
|
|
105
|
+
* production docs.
|
|
106
|
+
*/
|
|
107
|
+
function _resetDefaultAnnouncerForTests() {
|
|
108
|
+
_defaultLastAnnouncements.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
37
111
|
/**
|
|
38
112
|
* Send a plain-text announce (no markdown processing, no reply linkage).
|
|
39
113
|
* Caller passes `tg(bot, method, params, meta)` as `send` so we don't
|
|
@@ -67,4 +141,10 @@ async function announce({
|
|
|
67
141
|
}
|
|
68
142
|
}
|
|
69
143
|
|
|
70
|
-
module.exports = {
|
|
144
|
+
module.exports = {
|
|
145
|
+
announce,
|
|
146
|
+
shouldAnnounce,
|
|
147
|
+
createAnnouncer,
|
|
148
|
+
SUBAGENT_DEBOUNCE_MS,
|
|
149
|
+
_resetDefaultAnnouncerForTests,
|
|
150
|
+
};
|
package/lib/deliver.js
CHANGED
|
@@ -37,8 +37,13 @@ async function deliverReplies({
|
|
|
37
37
|
logger = console,
|
|
38
38
|
}) {
|
|
39
39
|
if (!Array.isArray(chunks) || chunks.length === 0) {
|
|
40
|
-
return { sent: [], failed: [] };
|
|
40
|
+
return { sent: [], failed: [], results: [] };
|
|
41
41
|
}
|
|
42
|
+
// 0.7.1: results[] preserves correspondence with chunks[] — results[i]
|
|
43
|
+
// describes what happened to chunks[i]. sent[]/failed[] are projections
|
|
44
|
+
// for back-compat with callers that already use them; they no longer
|
|
45
|
+
// ambiguously mean "the i-th success/failure" vs "chunk i's outcome".
|
|
46
|
+
const results = [];
|
|
42
47
|
const sent = [];
|
|
43
48
|
const failed = [];
|
|
44
49
|
for (let i = 0; i < chunks.length; i++) {
|
|
@@ -56,14 +61,18 @@ async function deliverReplies({
|
|
|
56
61
|
try {
|
|
57
62
|
const res = await send(bot, 'sendMessage', params, meta);
|
|
58
63
|
const msgId = res?.message_id ?? null;
|
|
64
|
+
results.push({ index: i, status: 'ok', messageId: msgId });
|
|
59
65
|
sent.push(msgId);
|
|
60
66
|
} catch (err) {
|
|
61
67
|
logger.error?.(`[deliver] chunk ${i + 1}/${chunks.length} failed: ${err.message}`);
|
|
68
|
+
results.push({ index: i, status: 'fail', error: err.message });
|
|
62
69
|
failed.push({ index: i, error: err.message });
|
|
63
|
-
// Keep going — partial delivery is better than total loss.
|
|
70
|
+
// Keep going — partial delivery is better than total loss. Caller
|
|
71
|
+
// should inspect failed.length and surface a warning to the user
|
|
72
|
+
// (see polygram.js handleMessage's stream-redeliver event log).
|
|
64
73
|
}
|
|
65
74
|
}
|
|
66
|
-
return { sent, failed };
|
|
75
|
+
return { sent, failed, results };
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
module.exports = { deliverReplies };
|
package/lib/sent-cache.js
CHANGED
|
@@ -17,13 +17,54 @@
|
|
|
17
17
|
|
|
18
18
|
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
19
19
|
const CLEANUP_THRESHOLD = 100;
|
|
20
|
+
// 0.7.1: hard cap on per-chat Map size. CLEANUP_THRESHOLD only drops
|
|
21
|
+
// EXPIRED entries — if a busy chat sends >100 fresh messages within
|
|
22
|
+
// 24h, GC finds nothing to drop and the inner Map grows unbounded.
|
|
23
|
+
// Cap evicts oldest entries past this point regardless of TTL.
|
|
24
|
+
const MAX_PER_CHAT = 500;
|
|
25
|
+
// 0.7.1: outer Map sweep — drop chats whose inner Map has been empty
|
|
26
|
+
// long enough that we're sure no live caller still references it.
|
|
27
|
+
const OUTER_SWEEP_THRESHOLD = 1000;
|
|
20
28
|
|
|
21
|
-
function createSentCache(
|
|
29
|
+
function createSentCache({
|
|
30
|
+
ttlMs = TTL_MS,
|
|
31
|
+
cleanupThreshold = CLEANUP_THRESHOLD,
|
|
32
|
+
maxPerChat = MAX_PER_CHAT,
|
|
33
|
+
outerSweepThreshold = OUTER_SWEEP_THRESHOLD,
|
|
34
|
+
clock = Date.now,
|
|
35
|
+
} = {}) {
|
|
22
36
|
// chatKey → Map<msgId, ts>
|
|
23
37
|
const sentMessages = new Map();
|
|
24
38
|
|
|
25
39
|
function chatKey(chatId) { return String(chatId); }
|
|
26
40
|
|
|
41
|
+
function gcInner(entry) {
|
|
42
|
+
const cutoff = clock() - ttlMs;
|
|
43
|
+
for (const [id, ts] of entry) if (ts < cutoff) entry.delete(id);
|
|
44
|
+
// After TTL prune, if still over the hard cap, drop oldest entries
|
|
45
|
+
// (insertion order in Map iteration). This handles the busy-chat
|
|
46
|
+
// case where 1000 messages all sent within 24h would otherwise
|
|
47
|
+
// leak.
|
|
48
|
+
if (entry.size > maxPerChat) {
|
|
49
|
+
const dropCount = entry.size - maxPerChat;
|
|
50
|
+
let i = 0;
|
|
51
|
+
for (const id of entry.keys()) {
|
|
52
|
+
if (i >= dropCount) break;
|
|
53
|
+
entry.delete(id);
|
|
54
|
+
i += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function gcOuter() {
|
|
60
|
+
// Drop chat entries that are entirely empty (their inner Map was
|
|
61
|
+
// drained by gcInner). Without this the outer Map's chatId set
|
|
62
|
+
// grows by one per ever-active-then-idle chat, slowly leaking.
|
|
63
|
+
for (const [k, entry] of sentMessages) {
|
|
64
|
+
if (entry.size === 0) sentMessages.delete(k);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
27
68
|
function record(chatId, messageId) {
|
|
28
69
|
if (chatId == null || messageId == null) return;
|
|
29
70
|
const key = chatKey(chatId);
|
|
@@ -32,13 +73,10 @@ function createSentCache() {
|
|
|
32
73
|
entry = new Map();
|
|
33
74
|
sentMessages.set(key, entry);
|
|
34
75
|
}
|
|
35
|
-
entry.set(messageId,
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
if (
|
|
39
|
-
const cutoff = Date.now() - TTL_MS;
|
|
40
|
-
for (const [id, ts] of entry) if (ts < cutoff) entry.delete(id);
|
|
41
|
-
}
|
|
76
|
+
entry.set(messageId, clock());
|
|
77
|
+
if (entry.size > cleanupThreshold) gcInner(entry);
|
|
78
|
+
// Periodic outer sweep — runs only when the outer Map gets crowded.
|
|
79
|
+
if (sentMessages.size > outerSweepThreshold) gcOuter();
|
|
42
80
|
}
|
|
43
81
|
|
|
44
82
|
function wasSent(chatId, messageId) {
|
|
@@ -48,8 +86,10 @@ function createSentCache() {
|
|
|
48
86
|
if (!entry) return false;
|
|
49
87
|
const ts = entry.get(messageId);
|
|
50
88
|
if (ts == null) return false;
|
|
51
|
-
if (
|
|
89
|
+
if (clock() - ts > ttlMs) {
|
|
52
90
|
entry.delete(messageId);
|
|
91
|
+
// If we just emptied the inner Map, drop the outer entry too.
|
|
92
|
+
if (entry.size === 0) sentMessages.delete(key);
|
|
53
93
|
return false;
|
|
54
94
|
}
|
|
55
95
|
return true;
|
|
@@ -61,11 +101,19 @@ function createSentCache() {
|
|
|
61
101
|
return total;
|
|
62
102
|
}
|
|
63
103
|
|
|
104
|
+
function chatCount() { return sentMessages.size; }
|
|
105
|
+
|
|
64
106
|
function clear() {
|
|
65
107
|
sentMessages.clear();
|
|
66
108
|
}
|
|
67
109
|
|
|
68
|
-
return { record, wasSent, size, clear };
|
|
110
|
+
return { record, wasSent, size, chatCount, clear };
|
|
69
111
|
}
|
|
70
112
|
|
|
71
|
-
module.exports = {
|
|
113
|
+
module.exports = {
|
|
114
|
+
createSentCache,
|
|
115
|
+
TTL_MS,
|
|
116
|
+
CLEANUP_THRESHOLD,
|
|
117
|
+
MAX_PER_CHAT,
|
|
118
|
+
OUTER_SWEEP_THRESHOLD,
|
|
119
|
+
};
|
package/lib/stream-reply.js
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Live streaming-reply state machine for a single turn.
|
|
3
3
|
*
|
|
4
|
-
* Lifecycle
|
|
4
|
+
* Lifecycle:
|
|
5
5
|
* idle -> (text >= minChars) -> live
|
|
6
6
|
* live -> (subsequent chunks) -> live (throttled edits)
|
|
7
|
+
* live -> flushDraft() -> live (drains pending edit)
|
|
7
8
|
* live -> forceNewMessage() -> idle (next chunk = new bubble)
|
|
8
9
|
* live -> discard() -> finalized (bubble deleted)
|
|
9
10
|
* any -> finalize(finalText) -> finalized
|
|
10
11
|
*
|
|
11
|
-
* The streamer never talks to Telegram directly — callers inject
|
|
12
|
-
* `edit(msg_id, text)`, and (
|
|
12
|
+
* The streamer never talks to Telegram directly — callers inject
|
|
13
|
+
* `send(text)`, `edit(msg_id, text)`, and (optional) `deleteMessage(msg_id)`.
|
|
13
14
|
* That keeps polygram.js in charge of transcript writes, sticker/reaction
|
|
14
15
|
* routing, and error handling; this module is just a cadence machine.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
* preview's last edit IS the final reply, or whether to discard the
|
|
18
|
-
* and redeliver via deliverReplies (overflow / final edit failed)
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* `finalize()` returns a rich result so the caller can decide whether the
|
|
18
|
+
* preview's last edit IS the final reply, or whether to discard the
|
|
19
|
+
* preview and redeliver via deliverReplies (overflow / final edit failed):
|
|
20
|
+
*
|
|
21
|
+
* { kind: implicit, see flags below }
|
|
22
|
+
* { streamed: false } — never went live
|
|
23
|
+
* { streamed: true, finalEditOk: true } — preview = final
|
|
24
|
+
* { streamed: true, finalEditOk: false, overflow: true } — body too long
|
|
25
|
+
* { streamed: true, finalEditOk: false, overflow: false } — edit failed
|
|
26
|
+
*
|
|
27
|
+
* Short replies preview-becomes-final (no flicker, single bubble); long
|
|
28
|
+
* replies preview-deleted-redelivered (chunks land at chat bottom).
|
|
22
29
|
*
|
|
23
30
|
* Test-friendly: inject `clock` (now() fn) and `schedule` (setTimeout-like)
|
|
24
31
|
* so a fake clock can drive throttle timing deterministically.
|
|
@@ -50,12 +57,19 @@ function createStreamer({
|
|
|
50
57
|
let lastEditTs = 0;
|
|
51
58
|
let pendingEdit = null; // timer id
|
|
52
59
|
let flushPromise = null; // ongoing edit promise (for back-pressure)
|
|
60
|
+
// 0.7.2: msg_ids of bubbles that have been superseded by
|
|
61
|
+
// forceNewMessage(). The caller (polygram.js handleMessage at
|
|
62
|
+
// end-of-turn) reads getArchived() and issues deleteMessage on
|
|
63
|
+
// each — matches OpenClaw's archivedAnswerPreviews cleanup so
|
|
64
|
+
// the user sees only the final answer's bubble, not every
|
|
65
|
+
// "thinking out loud" intermediate from a tool-heavy turn.
|
|
66
|
+
const archived = [];
|
|
53
67
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
68
|
+
// LIVE-EDIT truncation only — used during streaming when latestText
|
|
69
|
+
// overshoots maxLen. The trailing "..." signals to the user that more
|
|
70
|
+
// is coming. Finalize doesn't truncate: overflow is handled by
|
|
71
|
+
// signalling the caller to discard-and-redeliver via chunkMarkdownText,
|
|
72
|
+
// which preserves all content without any byte-cut.
|
|
59
73
|
function truncateForLive(s) {
|
|
60
74
|
if (s.length <= maxLen) return s;
|
|
61
75
|
return s.slice(0, maxLen - 3) + '...';
|
|
@@ -132,15 +146,20 @@ function createStreamer({
|
|
|
132
146
|
if (flushPromise) { try { await flushPromise; } catch {} }
|
|
133
147
|
}
|
|
134
148
|
|
|
135
|
-
//
|
|
136
|
-
// Used by
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
149
|
+
// Reset bubble state so the next onChunk creates a NEW message.
|
|
150
|
+
// Used by `onAssistantMessageStart` in process-manager.js when Claude
|
|
151
|
+
// emits a new top-level assistant message mid-turn (post tool-result):
|
|
152
|
+
// we want it in its own bubble below the previous one, not appended
|
|
153
|
+
// via editMessageText to the original.
|
|
140
154
|
function forceNewMessage() {
|
|
141
155
|
if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
|
|
142
156
|
// Don't await flushPromise — the caller has decided to start a new
|
|
143
157
|
// message; whatever the old bubble shows is "done".
|
|
158
|
+
// 0.7.2: track the previous bubble's msgId for end-of-turn cleanup.
|
|
159
|
+
// Without this, every intermediate "thinking out loud" assistant
|
|
160
|
+
// message in a tool-heavy turn leaves a permanent bubble in the
|
|
161
|
+
// chat — the user wants only the final answer's bubble visible.
|
|
162
|
+
if (msgId != null) archived.push(msgId);
|
|
144
163
|
msgId = null;
|
|
145
164
|
currentText = '';
|
|
146
165
|
latestText = '';
|
|
@@ -232,6 +251,11 @@ function createStreamer({
|
|
|
232
251
|
}
|
|
233
252
|
}
|
|
234
253
|
|
|
254
|
+
// 0.7.2: snapshot of bubble msgIds that forceNewMessage() superseded.
|
|
255
|
+
// Returns a copy so callers can't mutate internal state. polygram.js
|
|
256
|
+
// reads this at end-of-turn and issues deleteMessage on each.
|
|
257
|
+
function getArchived() { return archived.slice(); }
|
|
258
|
+
|
|
235
259
|
return {
|
|
236
260
|
onChunk,
|
|
237
261
|
finalize,
|
|
@@ -239,6 +263,7 @@ function createStreamer({
|
|
|
239
263
|
forceNewMessage,
|
|
240
264
|
discard,
|
|
241
265
|
archive,
|
|
266
|
+
getArchived,
|
|
242
267
|
// Introspection for tests:
|
|
243
268
|
get state() { return state; },
|
|
244
269
|
get msgId() { return msgId; },
|
package/lib/telegram-chunk.js
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Markdown-aware chunking for Telegram-bound replies.
|
|
3
3
|
*
|
|
4
|
-
* Direct port of OpenClaw's chunkMarkdownText
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Direct port of OpenClaw's chunkMarkdownText. The naive byte-cut
|
|
5
|
+
* chunker we shipped before this would land boundaries mid-word and
|
|
6
|
+
* mid-HTML-tag, which Telegram's parse_mode=HTML rejected with
|
|
7
|
+
* `400 can't parse entities` — bubbles froze and content got dropped.
|
|
8
|
+
*
|
|
9
|
+
* Guarantees:
|
|
9
10
|
*
|
|
10
11
|
* 1. No chunk exceeds `limit`.
|
|
11
12
|
* 2. Breaks prefer newlines over whitespace over hard-cut.
|
|
12
|
-
* 3. Code fences (```...```) are never broken silently — if a chunk
|
|
13
|
-
* land inside a fence, we close it on chunk N and re-open
|
|
14
|
-
* marker + language on chunk N+1, so each chunk is
|
|
15
|
-
* parseable.
|
|
16
|
-
* 4. Parenthesised expressions `(...)` aren't broken at whitespace
|
|
17
|
-
* the parens (avoids splitting
|
|
13
|
+
* 3. Code fences (```...```) are never broken silently — if a chunk
|
|
14
|
+
* would land inside a fence, we close it on chunk N and re-open
|
|
15
|
+
* with the same marker + language on chunk N+1, so each chunk is
|
|
16
|
+
* independently parseable.
|
|
17
|
+
* 4. Parenthesised expressions `(...)` aren't broken at whitespace
|
|
18
|
+
* inside the parens (avoids splitting markdown-link syntax like
|
|
19
|
+
* `[label](http://example.com/...)`).
|
|
18
20
|
*
|
|
19
|
-
* Plain `chunkText` (no fence handling) is exported for callers that
|
|
20
|
-
* know the input has no markdown — primarily code paths
|
|
21
|
-
* input echoes or non-text payloads.
|
|
21
|
+
* Plain `chunkText` (no fence handling) is exported for callers that
|
|
22
|
+
* already know the input has no markdown — primarily code paths
|
|
23
|
+
* handling raw user input echoes or non-text payloads.
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
// ─── Code-fence span detection ──────────────────────────────────────
|
|
@@ -105,11 +107,19 @@ function scanParenAwareBreakpoints(window, isAllowed = () => true) {
|
|
|
105
107
|
|
|
106
108
|
// ─── Chunkers ────────────────────────────────────────────────────────
|
|
107
109
|
|
|
108
|
-
// Common early-out: empty /
|
|
109
|
-
//
|
|
110
|
+
// Common early-out: empty / fits-in-one returns directly so the loop
|
|
111
|
+
// bodies can assume there's real work to do. `limit ≤ 0` is treated as
|
|
112
|
+
// a programmer error and throws — silently returning [text] would let
|
|
113
|
+
// a misread config pass through a body that exceeds Telegram's actual
|
|
114
|
+
// 4096-char cap, which the chunker exists to prevent.
|
|
110
115
|
function resolveChunkEarlyReturn(text, limit) {
|
|
111
|
-
if (!
|
|
112
|
-
|
|
116
|
+
if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0) {
|
|
117
|
+
throw new RangeError(`chunk limit must be a positive number; got ${limit}`);
|
|
118
|
+
}
|
|
119
|
+
if (text == null || text === '') return [];
|
|
120
|
+
if (typeof text !== 'string') {
|
|
121
|
+
throw new TypeError(`chunk text must be a string; got ${typeof text}`);
|
|
122
|
+
}
|
|
113
123
|
if (text.length <= limit) return [text];
|
|
114
124
|
return undefined;
|
|
115
125
|
}
|
|
@@ -119,7 +129,7 @@ function resolveChunkEarlyReturn(text, limit) {
|
|
|
119
129
|
// Negative / out-of-range break indices fall back to hard-cut at limit.
|
|
120
130
|
function chunkTextByBreakResolver(text, limit, resolveBreakIndex) {
|
|
121
131
|
if (!text) return [];
|
|
122
|
-
if (
|
|
132
|
+
if (text.length <= limit) return [text];
|
|
123
133
|
const chunks = [];
|
|
124
134
|
let remaining = text;
|
|
125
135
|
while (remaining.length > limit) {
|
package/lib/telegram.js
CHANGED
|
@@ -173,20 +173,32 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
|
|
|
173
173
|
let res;
|
|
174
174
|
const rawAttempt = async (p) => bot.api.raw[method](p);
|
|
175
175
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
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.
|
|
190
202
|
const RETRY_AFTER_CAP_MS = 60_000;
|
|
191
203
|
const tryOnce = async (p) => {
|
|
192
204
|
try {
|
|
@@ -209,7 +221,20 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
|
|
|
209
221
|
const plainParams = { ...p };
|
|
210
222
|
delete plainParams.parse_mode;
|
|
211
223
|
plainParams[formatField] = rawFieldValue;
|
|
212
|
-
|
|
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
|
+
}
|
|
213
238
|
}
|
|
214
239
|
throw err;
|
|
215
240
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
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
|
@@ -1664,21 +1664,34 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1664
1664
|
...(linkPreview === false ? { linkPreview: false } : {}),
|
|
1665
1665
|
};
|
|
1666
1666
|
|
|
1667
|
+
// 0.7.2: only the FIRST bubble in a turn quotes the user's message
|
|
1668
|
+
// via reply_parameters. When a tool-heavy turn produces multiple
|
|
1669
|
+
// assistant messages (each spawning its own bubble via
|
|
1670
|
+
// forceNewMessage), subsequent bubbles shouldn't re-quote the user
|
|
1671
|
+
// — the chat would show N copies of the same quoted message stacked
|
|
1672
|
+
// vertically. After the first send, the flag flips and subsequent
|
|
1673
|
+
// initial-sends omit reply_parameters.
|
|
1674
|
+
let firstBubbleSent = false;
|
|
1667
1675
|
// Streaming is unconditional as of 0.4.0 — matches OpenClaw's model and
|
|
1668
1676
|
// eliminates the "stuck at 15min typing" complaint from the non-streaming
|
|
1669
1677
|
// code path. For short responses the streamer stays idle and we fall
|
|
1670
1678
|
// through to the normal send path via finalize() returning streamed=false.
|
|
1671
1679
|
const streamer = createStreamer({
|
|
1672
|
-
send: async (text) =>
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1680
|
+
send: async (text) => {
|
|
1681
|
+
const params = {
|
|
1682
|
+
chat_id: chatId, text,
|
|
1683
|
+
...(threadId && { message_thread_id: threadId }),
|
|
1684
|
+
};
|
|
1685
|
+
if (!firstBubbleSent) {
|
|
1686
|
+
// allow_sending_without_reply: long-running turns give the user
|
|
1687
|
+
// plenty of time to delete their original message. Without this
|
|
1688
|
+
// flag, Telegram rejects the reply with MESSAGE_NOT_FOUND and the
|
|
1689
|
+
// whole streamed answer is lost.
|
|
1690
|
+
params.reply_parameters = { message_id: msg.message_id, allow_sending_without_reply: true };
|
|
1691
|
+
firstBubbleSent = true;
|
|
1692
|
+
}
|
|
1693
|
+
return tg(bot, 'sendMessage', params, outMetaBase);
|
|
1694
|
+
},
|
|
1682
1695
|
edit: async (messageId, text) => {
|
|
1683
1696
|
try {
|
|
1684
1697
|
// Route edits through tg() so applyFormatting runs (MarkdownV2
|
|
@@ -1725,6 +1738,34 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1725
1738
|
});
|
|
1726
1739
|
// streamer is registered with this turn via pm.send's context (below)
|
|
1727
1740
|
|
|
1741
|
+
// 0.7.2: clean up bubbles superseded by forceNewMessage() — the
|
|
1742
|
+
// intermediate "thinking out loud" assistant messages that fired in
|
|
1743
|
+
// a tool-heavy turn. Without this, every tool-result cycle leaves a
|
|
1744
|
+
// permanent bubble in the chat (see the screenshot from the post-
|
|
1745
|
+
// 0.7.1 deploy where six bubbles appeared for one logical turn).
|
|
1746
|
+
// Matches OpenClaw's archivedAnswerPreviews end-of-turn cleanup.
|
|
1747
|
+
// Call AFTER finalize/discard decisions so we never delete the
|
|
1748
|
+
// bubble that's the final reply.
|
|
1749
|
+
async function cleanupArchivedBubbles() {
|
|
1750
|
+
const archived = streamer.getArchived?.() || [];
|
|
1751
|
+
if (archived.length === 0) return;
|
|
1752
|
+
for (const messageId of archived) {
|
|
1753
|
+
try {
|
|
1754
|
+
await tg(bot, 'deleteMessage', {
|
|
1755
|
+
chat_id: chatId, message_id: messageId,
|
|
1756
|
+
}, { source: 'bot-reply-archived-cleanup', botName: BOT_NAME });
|
|
1757
|
+
} catch (err) {
|
|
1758
|
+
// Non-fatal — message may be >48h old or already gone.
|
|
1759
|
+
// Operator-visible only via the events table.
|
|
1760
|
+
console.error(`[${label}] archived-cleanup ${messageId}: ${err.message}`);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
logEvent('telegram-archived-cleanup', {
|
|
1764
|
+
chat_id: chatId, msg_id: msg.message_id, count: archived.length,
|
|
1765
|
+
bot: BOT_NAME,
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1728
1769
|
// Status reactions on the user's message: 👀 queued → 🤔 thinking →
|
|
1729
1770
|
// 👨💻 coding / ⚡ web / 🔥 tool → 👍 done / 🤯 error. Silent (no
|
|
1730
1771
|
// notifications), updates in place, one emoji per message. Uses
|
|
@@ -1748,12 +1789,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1748
1789
|
// at which point we flip to THINKING (🤔).
|
|
1749
1790
|
reactor.setState('QUEUED');
|
|
1750
1791
|
|
|
1751
|
-
// Mark the inbound row terminal so boot replay doesn't pick it up
|
|
1752
|
-
// Must fire down EVERY non-throwing exit path (early returns
|
|
1753
|
-
// NO_REPLY, streamed-reply
|
|
1754
|
-
//
|
|
1755
|
-
//
|
|
1756
|
-
// stuck at 'dispatched' forever
|
|
1792
|
+
// Mark the inbound row terminal so boot replay doesn't pick it up
|
|
1793
|
+
// again. Must fire down EVERY non-throwing exit path (early returns
|
|
1794
|
+
// for error / NO_REPLY, streamed-reply preview-becomes-final, the
|
|
1795
|
+
// discard+redeliver branch, regular reply at end). Earlier versions
|
|
1796
|
+
// only marked at the bottom of try, so streamed-reply early returns
|
|
1797
|
+
// left handler_status stuck at 'dispatched' forever and the next
|
|
1798
|
+
// boot replayed every long turn.
|
|
1757
1799
|
const markReplied = () => dbWrite(() => db.setInboundHandlerStatus({
|
|
1758
1800
|
chat_id: chatId, msg_id: msg.message_id, status: 'replied',
|
|
1759
1801
|
}), 'set handler_status=replied');
|
|
@@ -1800,6 +1842,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1800
1842
|
// those still markReplied silently.
|
|
1801
1843
|
if (result.text === 'NO_REPLY') { markReplied(); return; }
|
|
1802
1844
|
if (!result.text) {
|
|
1845
|
+
// 0.7.1: if the fallback send itself fails, throw rather than
|
|
1846
|
+
// silently markReplied — the user gets nothing AND the inbound
|
|
1847
|
+
// is marked replied so boot replay won't redispatch. Same
|
|
1848
|
+
// anti-pattern that caused msg-10794. Promote to a thrown error
|
|
1849
|
+
// so dispatchHandleMessage's catch branches correctly:
|
|
1850
|
+
// shutdown → 'replay-pending' (boot replay retries)
|
|
1851
|
+
// runtime → 'failed' + user-visible apology via errorReplyText
|
|
1803
1852
|
try {
|
|
1804
1853
|
await tg(bot, 'sendMessage', {
|
|
1805
1854
|
chat_id: chatId,
|
|
@@ -1808,7 +1857,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1808
1857
|
reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
|
|
1809
1858
|
}, { ...outMetaBase, source: 'empty-response-fallback' });
|
|
1810
1859
|
} catch (err) {
|
|
1811
|
-
|
|
1860
|
+
reactor.setState('ERROR');
|
|
1861
|
+
logEvent('telegram-empty-response-fallback-failed', {
|
|
1862
|
+
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
1863
|
+
error: err.message?.slice(0, 200),
|
|
1864
|
+
});
|
|
1865
|
+
throw new Error(`empty-response fallback send failed: ${err.message}`);
|
|
1812
1866
|
}
|
|
1813
1867
|
logEvent('telegram-empty-response-fallback', {
|
|
1814
1868
|
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
@@ -1820,7 +1874,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1820
1874
|
const parsed = parseResponse(result.text);
|
|
1821
1875
|
const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
|
|
1822
1876
|
|
|
1823
|
-
//
|
|
1877
|
+
// OpenClaw's preview-becomes-final flow:
|
|
1824
1878
|
//
|
|
1825
1879
|
// 1. flushDraft() — drain any pending throttled edit so the
|
|
1826
1880
|
// bubble's visible state is up-to-date before deciding.
|
|
@@ -1828,12 +1882,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1828
1882
|
// final body. Returns rich result describing whether the
|
|
1829
1883
|
// preview can stand as the final reply.
|
|
1830
1884
|
// 3a. finalEditOk:true → preview IS final, done.
|
|
1831
|
-
// 3b. overflow OR !finalEditOk → discard preview, redeliver
|
|
1832
|
-
// deliverReplies(chunkMarkdownText(...)).
|
|
1833
|
-
//
|
|
1834
|
-
//
|
|
1835
|
-
//
|
|
1836
|
-
// content lost, no stranded edit-failure bubble.
|
|
1885
|
+
// 3b. overflow OR !finalEditOk → discard preview, redeliver
|
|
1886
|
+
// via deliverReplies(chunkMarkdownText(...)). The bubble
|
|
1887
|
+
// couldn't render the full body (size or parse error), so
|
|
1888
|
+
// we delete it cleanly and send the proper chunks fresh at
|
|
1889
|
+
// chat bottom — no content lost, no stranded bubble.
|
|
1837
1890
|
if (parsed.text) {
|
|
1838
1891
|
await streamer.flushDraft();
|
|
1839
1892
|
const fin = await streamer.finalize(parsed.text);
|
|
@@ -1841,6 +1894,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1841
1894
|
if (fin.finalEditOk) {
|
|
1842
1895
|
// Preview was successfully edited to the final text.
|
|
1843
1896
|
// No follow-up messages needed.
|
|
1897
|
+
await cleanupArchivedBubbles();
|
|
1844
1898
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1845
1899
|
markReplied();
|
|
1846
1900
|
return;
|
|
@@ -1868,7 +1922,25 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1868
1922
|
delivered: r.sent.length, failed: r.failed.length,
|
|
1869
1923
|
bot: BOT_NAME,
|
|
1870
1924
|
});
|
|
1871
|
-
|
|
1925
|
+
// 0.7.1: surface partial-failure to the user. Without this,
|
|
1926
|
+
// a chunk-3-of-5 failure leaves a coherent-looking reply with
|
|
1927
|
+
// a silent gap (the user reads chunks 1, 2, 4, 5 unaware
|
|
1928
|
+
// that chunk 3 was dropped). Append a warning + flip the
|
|
1929
|
+
// reactor to ERROR so something visible signals "look here".
|
|
1930
|
+
if (r.failed.length > 0) {
|
|
1931
|
+
reactor.setState('ERROR');
|
|
1932
|
+
try {
|
|
1933
|
+
await tg(bot, 'sendMessage', {
|
|
1934
|
+
chat_id: chatId,
|
|
1935
|
+
text: `⚠️ ${r.failed.length} of ${chunks.length} message parts failed to deliver. The reply may be incomplete — please retry.`,
|
|
1936
|
+
...(threadId && { message_thread_id: threadId }),
|
|
1937
|
+
}, { ...outMetaBase, source: 'partial-delivery-warning' });
|
|
1938
|
+
} catch (warnErr) {
|
|
1939
|
+
console.error(`[${label}] partial-delivery warning failed: ${warnErr.message}`);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
await cleanupArchivedBubbles();
|
|
1943
|
+
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) || '?'}`);
|
|
1872
1944
|
markReplied();
|
|
1873
1945
|
return;
|
|
1874
1946
|
}
|