polygram 0.7.0 → 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 +91 -11
- package/lib/deliver.js +12 -3
- package/lib/sent-cache.js +59 -11
- package/lib/stream-reply.js +26 -19
- package/lib/telegram-chunk.js +29 -19
- package/lib/telegram.js +40 -15
- package/package.json +1 -1
- package/polygram.js +44 -15
|
@@ -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.1",
|
|
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 # 638 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.
|
|
@@ -51,11 +58,11 @@ function createStreamer({
|
|
|
51
58
|
let pendingEdit = null; // timer id
|
|
52
59
|
let flushPromise = null; // ongoing edit promise (for back-pressure)
|
|
53
60
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
61
|
+
// LIVE-EDIT truncation only — used during streaming when latestText
|
|
62
|
+
// overshoots maxLen. The trailing "..." signals to the user that more
|
|
63
|
+
// is coming. Finalize doesn't truncate: overflow is handled by
|
|
64
|
+
// signalling the caller to discard-and-redeliver via chunkMarkdownText,
|
|
65
|
+
// which preserves all content without any byte-cut.
|
|
59
66
|
function truncateForLive(s) {
|
|
60
67
|
if (s.length <= maxLen) return s;
|
|
61
68
|
return s.slice(0, maxLen - 3) + '...';
|
|
@@ -132,11 +139,11 @@ function createStreamer({
|
|
|
132
139
|
if (flushPromise) { try { await flushPromise; } catch {} }
|
|
133
140
|
}
|
|
134
141
|
|
|
135
|
-
//
|
|
136
|
-
// Used by
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
142
|
+
// Reset bubble state so the next onChunk creates a NEW message.
|
|
143
|
+
// Used by `onAssistantMessageStart` in process-manager.js when Claude
|
|
144
|
+
// emits a new top-level assistant message mid-turn (post tool-result):
|
|
145
|
+
// we want it in its own bubble below the previous one, not appended
|
|
146
|
+
// via editMessageText to the original.
|
|
140
147
|
function forceNewMessage() {
|
|
141
148
|
if (pendingEdit) { cancel(pendingEdit); pendingEdit = null; }
|
|
142
149
|
// Don't await flushPromise — the caller has decided to start a new
|
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.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
|
@@ -1748,12 +1748,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1748
1748
|
// at which point we flip to THINKING (🤔).
|
|
1749
1749
|
reactor.setState('QUEUED');
|
|
1750
1750
|
|
|
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
|
|
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.
|
|
1757
1758
|
const markReplied = () => dbWrite(() => db.setInboundHandlerStatus({
|
|
1758
1759
|
chat_id: chatId, msg_id: msg.message_id, status: 'replied',
|
|
1759
1760
|
}), 'set handler_status=replied');
|
|
@@ -1800,6 +1801,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1800
1801
|
// those still markReplied silently.
|
|
1801
1802
|
if (result.text === 'NO_REPLY') { markReplied(); return; }
|
|
1802
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
|
|
1803
1811
|
try {
|
|
1804
1812
|
await tg(bot, 'sendMessage', {
|
|
1805
1813
|
chat_id: chatId,
|
|
@@ -1808,7 +1816,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1808
1816
|
reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
|
|
1809
1817
|
}, { ...outMetaBase, source: 'empty-response-fallback' });
|
|
1810
1818
|
} catch (err) {
|
|
1811
|
-
|
|
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}`);
|
|
1812
1825
|
}
|
|
1813
1826
|
logEvent('telegram-empty-response-fallback', {
|
|
1814
1827
|
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
@@ -1820,7 +1833,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1820
1833
|
const parsed = parseResponse(result.text);
|
|
1821
1834
|
const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
|
|
1822
1835
|
|
|
1823
|
-
//
|
|
1836
|
+
// OpenClaw's preview-becomes-final flow:
|
|
1824
1837
|
//
|
|
1825
1838
|
// 1. flushDraft() — drain any pending throttled edit so the
|
|
1826
1839
|
// bubble's visible state is up-to-date before deciding.
|
|
@@ -1828,12 +1841,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1828
1841
|
// final body. Returns rich result describing whether the
|
|
1829
1842
|
// preview can stand as the final reply.
|
|
1830
1843
|
// 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.
|
|
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.
|
|
1837
1849
|
if (parsed.text) {
|
|
1838
1850
|
await streamer.flushDraft();
|
|
1839
1851
|
const fin = await streamer.finalize(parsed.text);
|
|
@@ -1868,7 +1880,24 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1868
1880
|
delivered: r.sent.length, failed: r.failed.length,
|
|
1869
1881
|
bot: BOT_NAME,
|
|
1870
1882
|
});
|
|
1871
|
-
|
|
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}`);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
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) || '?'}`);
|
|
1872
1901
|
markReplied();
|
|
1873
1902
|
return;
|
|
1874
1903
|
}
|