polygram 0.6.16 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/lib/announces.js +150 -0
- package/lib/deliver.js +78 -0
- package/lib/net-errors.js +52 -3
- package/lib/process-manager.js +30 -6
- package/lib/sent-cache.js +119 -0
- package/lib/stream-reply.js +133 -17
- package/lib/telegram-chunk.js +288 -0
- package/lib/telegram-format.js +107 -1
- package/lib/telegram.js +159 -39
- package/package.json +1 -1
- package/polygram.js +177 -51
|
@@ -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.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
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
* 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.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SUBAGENT_DEBOUNCE_MS = 30_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
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.
|
|
36
|
+
*/
|
|
37
|
+
function createAnnouncer({
|
|
38
|
+
debounceMs = SUBAGENT_DEBOUNCE_MS,
|
|
39
|
+
clock = Date.now,
|
|
40
|
+
sweepThreshold = 1000,
|
|
41
|
+
} = {}) {
|
|
42
|
+
const lastAnnounceByChat = new Map();
|
|
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
|
+
*/
|
|
89
|
+
function shouldAnnounce(chatId, now = Date.now(), debounceMs = SUBAGENT_DEBOUNCE_MS) {
|
|
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);
|
|
98
|
+
if (prev != null && now - prev < debounceMs) return false;
|
|
99
|
+
_defaultLastAnnouncements.set(key, now);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
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
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Send a plain-text announce (no markdown processing, no reply linkage).
|
|
113
|
+
* Caller passes `tg(bot, method, params, meta)` as `send` so we don't
|
|
114
|
+
* have to import the full lib/telegram.js dependency tree here.
|
|
115
|
+
*/
|
|
116
|
+
async function announce({
|
|
117
|
+
send,
|
|
118
|
+
bot,
|
|
119
|
+
chatId,
|
|
120
|
+
threadId = null,
|
|
121
|
+
text,
|
|
122
|
+
meta = {},
|
|
123
|
+
logger = console,
|
|
124
|
+
}) {
|
|
125
|
+
if (!text) return null;
|
|
126
|
+
const params = {
|
|
127
|
+
chat_id: chatId,
|
|
128
|
+
text,
|
|
129
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
130
|
+
};
|
|
131
|
+
try {
|
|
132
|
+
return await send(bot, 'sendMessage', params, {
|
|
133
|
+
...meta,
|
|
134
|
+
source: meta.source || 'announce',
|
|
135
|
+
plainText: true, // skip markdown→HTML
|
|
136
|
+
linkPreview: false, // never preview-card for announces
|
|
137
|
+
});
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logger.error?.(`[announce] failed: ${err.message}`);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
announce,
|
|
146
|
+
shouldAnnounce,
|
|
147
|
+
createAnnouncer,
|
|
148
|
+
SUBAGENT_DEBOUNCE_MS,
|
|
149
|
+
_resetDefaultAnnouncerForTests,
|
|
150
|
+
};
|
package/lib/deliver.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
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: [], results: [] };
|
|
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 = [];
|
|
47
|
+
const sent = [];
|
|
48
|
+
const failed = [];
|
|
49
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
50
|
+
const params = {
|
|
51
|
+
chat_id: chatId,
|
|
52
|
+
text: chunks[i],
|
|
53
|
+
};
|
|
54
|
+
if (threadId != null) params.message_thread_id = threadId;
|
|
55
|
+
if (i === 0 && replyToMessageId != null) {
|
|
56
|
+
// allow_sending_without_reply: long turns give the user time to
|
|
57
|
+
// delete their original message; without this flag Telegram
|
|
58
|
+
// rejects with MESSAGE_NOT_FOUND and the whole reply is lost.
|
|
59
|
+
params.reply_parameters = { message_id: replyToMessageId, allow_sending_without_reply: true };
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const res = await send(bot, 'sendMessage', params, meta);
|
|
63
|
+
const msgId = res?.message_id ?? null;
|
|
64
|
+
results.push({ index: i, status: 'ok', messageId: msgId });
|
|
65
|
+
sent.push(msgId);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
logger.error?.(`[deliver] chunk ${i + 1}/${chunks.length} failed: ${err.message}`);
|
|
68
|
+
results.push({ index: i, status: 'fail', error: err.message });
|
|
69
|
+
failed.push({ index: i, error: err.message });
|
|
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).
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { sent, failed, results };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
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,119 @@
|
|
|
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
|
+
// 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;
|
|
28
|
+
|
|
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
|
+
} = {}) {
|
|
36
|
+
// chatKey → Map<msgId, ts>
|
|
37
|
+
const sentMessages = new Map();
|
|
38
|
+
|
|
39
|
+
function chatKey(chatId) { return String(chatId); }
|
|
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
|
+
|
|
68
|
+
function record(chatId, messageId) {
|
|
69
|
+
if (chatId == null || messageId == null) return;
|
|
70
|
+
const key = chatKey(chatId);
|
|
71
|
+
let entry = sentMessages.get(key);
|
|
72
|
+
if (!entry) {
|
|
73
|
+
entry = new Map();
|
|
74
|
+
sentMessages.set(key, entry);
|
|
75
|
+
}
|
|
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();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function wasSent(chatId, messageId) {
|
|
83
|
+
if (chatId == null || messageId == null) return false;
|
|
84
|
+
const key = chatKey(chatId);
|
|
85
|
+
const entry = sentMessages.get(key);
|
|
86
|
+
if (!entry) return false;
|
|
87
|
+
const ts = entry.get(messageId);
|
|
88
|
+
if (ts == null) return false;
|
|
89
|
+
if (clock() - ts > ttlMs) {
|
|
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);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function size() {
|
|
99
|
+
let total = 0;
|
|
100
|
+
for (const entry of sentMessages.values()) total += entry.size;
|
|
101
|
+
return total;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function chatCount() { return sentMessages.size; }
|
|
105
|
+
|
|
106
|
+
function clear() {
|
|
107
|
+
sentMessages.clear();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { record, wasSent, size, chatCount, clear };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
createSentCache,
|
|
115
|
+
TTL_MS,
|
|
116
|
+
CLEANUP_THRESHOLD,
|
|
117
|
+
MAX_PER_CHAT,
|
|
118
|
+
OUTER_SWEEP_THRESHOLD,
|
|
119
|
+
};
|