polygram 0.3.5 → 0.4.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 +23 -19
- package/config.example.json +10 -2
- package/lib/abort-detector.js +63 -0
- package/lib/db.js +15 -0
- package/lib/net-errors.js +94 -0
- package/lib/process-manager.js +77 -23
- package/lib/status-reactions.js +168 -0
- package/lib/stream-reply.js +5 -1
- package/lib/telegram-format.js +36 -0
- package/lib/telegram.js +98 -7
- package/lib/typing-indicator.js +143 -0
- package/migrations/005-polling-state.sql +14 -0
- package/package.json +5 -4
- package/polygram.js +251 -49
- package/scripts/doctor.js +324 -0
- package/scripts/smoke.js +0 -122
|
@@ -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.4.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
|
@@ -117,35 +117,39 @@ For production, LaunchAgent plists are in `ops/`. See `ops/README.md`.
|
|
|
117
117
|
|
|
118
118
|
## Health check
|
|
119
119
|
|
|
120
|
-
Every install includes
|
|
120
|
+
Every install includes `polygram-doctor` for operational diagnostics:
|
|
121
121
|
|
|
122
122
|
```bash
|
|
123
|
-
polygram-
|
|
123
|
+
polygram-doctor --bot my-bot
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
Runs static checks without touching live chats: config parseable,
|
|
127
|
+
DB schema current, IPC socket up, Telegram `getMe` succeeds, recent
|
|
128
|
+
errors from the last 24h, stuck pending outbound rows, pending
|
|
129
|
+
approvals. Exits 0 on pass, 1 on any failure (add `--strict` to
|
|
130
|
+
fail on warnings too).
|
|
127
131
|
|
|
128
|
-
|
|
129
|
-
2. **Outbound round-trip** — IPC `send` op → Telegram API → returns a `msg_id`
|
|
130
|
-
3. **DB read-back** — that `msg_id` is in the `messages` table with
|
|
131
|
-
`direction='out'`, `status='sent'`, matching text
|
|
132
|
-
|
|
133
|
-
A sample passing run:
|
|
132
|
+
Output:
|
|
134
133
|
|
|
135
134
|
```
|
|
136
|
-
✅
|
|
137
|
-
✅
|
|
138
|
-
✅
|
|
139
|
-
|
|
140
|
-
|
|
135
|
+
✅ config — bot found, 4 chat(s), admin=68861949
|
|
136
|
+
✅ db — schema v5
|
|
137
|
+
✅ ipc — socket responsive, bot=my-bot
|
|
138
|
+
✅ telegram — @my_bot (My Bot)
|
|
139
|
+
✅ recent-errors — no failure events in last 24h
|
|
140
|
+
✅ pending-outbound — no stale pending outbound rows
|
|
141
|
+
✅ approvals — no pending approvals
|
|
142
|
+
|
|
143
|
+
7 ok / 0 warn / 0 fail (bot=my-bot)
|
|
141
144
|
```
|
|
142
145
|
|
|
143
|
-
Flags: `--
|
|
144
|
-
`--timeout-ms <ms>`
|
|
146
|
+
Flags: `--json` for machine-readable output, `--db <path>` /
|
|
147
|
+
`POLYGRAM_DB` for non-default DB location, `--timeout-ms <ms>`
|
|
148
|
+
(default 8000), `--strict` to exit 1 on warnings.
|
|
145
149
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
For a full outbound round-trip verification (the old `polygram-smoke`),
|
|
151
|
+
add `--roundtrip --to <chat_id>`. That sends a silent tagged message
|
|
152
|
+
and verifies it lands in the DB with `status=sent`.
|
|
149
153
|
|
|
150
154
|
## Install as a Claude Code plugin
|
|
151
155
|
|
package/config.example.json
CHANGED
|
@@ -8,9 +8,17 @@
|
|
|
8
8
|
"_comment_adminChatId": "Required when allowConfigCommands is true for pairing commands (/pair-code, /pairings, /unpair) to work. These grant cross-chat trust and are gated to the admin chat only.",
|
|
9
9
|
"adminChatId": "123456789",
|
|
10
10
|
"needsToken": false,
|
|
11
|
-
"
|
|
11
|
+
"_comment_stream": "Streaming is always on. `streamMinChars` sets the initial-send debounce (default 30) — short responses below that stay idle until the final result. `streamThrottleMs` sets the edit cadence (default 1000ms, min 250).",
|
|
12
12
|
"streamMinChars": 30,
|
|
13
|
-
"streamThrottleMs":
|
|
13
|
+
"streamThrottleMs": 1000,
|
|
14
|
+
"_comment_pairedChatDefaults": "When a user sends /pair <CODE> from a private chat that isn't in config.chats yet, polygram auto-creates a chat entry using these defaults (merged over top-level `defaults`). Leave out `cwd` at your peril — without it, auto-onboarded DMs have no working directory and pairing will fail.",
|
|
15
|
+
"pairedChatDefaults": {
|
|
16
|
+
"agent": "admin",
|
|
17
|
+
"cwd": "/Users/you/admin-agent",
|
|
18
|
+
"model": "sonnet",
|
|
19
|
+
"effort": "medium",
|
|
20
|
+
"timeout": 600
|
|
21
|
+
},
|
|
14
22
|
"voice": {
|
|
15
23
|
"enabled": true,
|
|
16
24
|
"provider": "openai",
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect "stop working on the current turn" signals in natural language.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors OpenClaw's isAbortRequestText semantics: users should be able to
|
|
5
|
+
* say "stop" / "подожди" / "cancel" / or just `/stop` and have polygram
|
|
6
|
+
* interrupt the in-flight turn instead of queueing the message behind it.
|
|
7
|
+
*
|
|
8
|
+
* Conservative on purpose. False positives hijack user intent — "stop using
|
|
9
|
+
* emoji" should NOT abort. So we require:
|
|
10
|
+
* 1. The message (after stripping leading @-mention + trailing punctuation)
|
|
11
|
+
* must be an exact match against a known abort phrase, OR
|
|
12
|
+
* 2. It must start with an explicit slash command: /stop, /abort, /cancel.
|
|
13
|
+
*
|
|
14
|
+
* Not detected (on purpose):
|
|
15
|
+
* - "wait a sec while I finish typing" → too long, real content
|
|
16
|
+
* - "stop using markdown" → has trailing content
|
|
17
|
+
* - "I said stop" → not at start / not exact match
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const ABORT_PHRASES = new Set([
|
|
21
|
+
// English
|
|
22
|
+
'stop', 'wait', 'cancel', 'abort', 'halt',
|
|
23
|
+
'hold on', 'hold up', 'nevermind', 'never mind', 'nvm',
|
|
24
|
+
'forget it', 'forget that',
|
|
25
|
+
// Russian
|
|
26
|
+
'стоп', 'подожди', 'подожди-ка', 'остановись', 'остановить',
|
|
27
|
+
'отмена', 'отставить', 'прекрати', 'прекращай', 'хватит',
|
|
28
|
+
'забей', 'не надо', 'отмени',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const ABORT_SLASH_RE = /^\/(stop|abort|cancel)(\s|$|@)/i;
|
|
32
|
+
|
|
33
|
+
// Strip leading @botname mentions ("@shumobot stop" → "stop"). Matches any
|
|
34
|
+
// @-prefixed word up to the first whitespace — loose because we check the
|
|
35
|
+
// remainder against an allowlist anyway.
|
|
36
|
+
const LEADING_MENTION_RE = /^@\S+\s+/;
|
|
37
|
+
|
|
38
|
+
// Trailing punctuation that doesn't change the meaning.
|
|
39
|
+
const TRAILING_PUNCT_RE = /[.!?,;:\s]+$/;
|
|
40
|
+
|
|
41
|
+
function normalize(text) {
|
|
42
|
+
if (typeof text !== 'string') return '';
|
|
43
|
+
return text
|
|
44
|
+
.trim()
|
|
45
|
+
.replace(LEADING_MENTION_RE, '')
|
|
46
|
+
.replace(TRAILING_PUNCT_RE, '')
|
|
47
|
+
.toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isAbortRequest(text) {
|
|
51
|
+
if (!text || typeof text !== 'string') return false;
|
|
52
|
+
// Explicit slash command: /stop, /abort, /cancel (optionally @-suffixed)
|
|
53
|
+
if (ABORT_SLASH_RE.test(text.trim())) return true;
|
|
54
|
+
|
|
55
|
+
const n = normalize(text);
|
|
56
|
+
if (!n) return false;
|
|
57
|
+
// Cap length: a long message that happens to start with "stop" is real
|
|
58
|
+
// content, not an abort. 40 chars covers all phrases above with headroom.
|
|
59
|
+
if (n.length > 40) return false;
|
|
60
|
+
return ABORT_PHRASES.has(n);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { isAbortRequest, ABORT_PHRASES };
|
package/lib/db.js
CHANGED
|
@@ -285,6 +285,21 @@ function wrap(db) {
|
|
|
285
285
|
if (botName) return markStalePendingForBotStmt.run(cutoff, botName);
|
|
286
286
|
return markStalePendingStmt.run(cutoff);
|
|
287
287
|
},
|
|
288
|
+
|
|
289
|
+
// Polling offset persistence — see migrations/005-polling-state.sql.
|
|
290
|
+
// Exposed as its own pair of calls (not lazy-prepared) so tests can
|
|
291
|
+
// round-trip them without going through the full polygram boot flow.
|
|
292
|
+
getPollingOffset(botName) {
|
|
293
|
+
const row = db.prepare('SELECT last_update_id FROM polling_state WHERE bot_name = ?').get(botName);
|
|
294
|
+
return row?.last_update_id ?? 0;
|
|
295
|
+
},
|
|
296
|
+
savePollingOffset(botName, lastUpdateId) {
|
|
297
|
+
db.prepare(`
|
|
298
|
+
INSERT INTO polling_state (bot_name, last_update_id, ts)
|
|
299
|
+
VALUES (?, ?, ?)
|
|
300
|
+
ON CONFLICT(bot_name) DO UPDATE SET last_update_id = excluded.last_update_id, ts = excluded.ts
|
|
301
|
+
`).run(botName, lastUpdateId, Date.now());
|
|
302
|
+
},
|
|
288
303
|
};
|
|
289
304
|
}
|
|
290
305
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network error classification + safe-retry helpers.
|
|
3
|
+
*
|
|
4
|
+
* Polygram's outbound policy has been "write DB row first, then send; never
|
|
5
|
+
* auto-retry" — correctly paranoid about double-sends. That leaves a gap
|
|
6
|
+
* though: transient pre-connect failures (DNS flap, local network blip,
|
|
7
|
+
* TCP refused) never actually hit Telegram. Retrying them once is safe
|
|
8
|
+
* because the request never reached the server — no risk of delivering
|
|
9
|
+
* the same message twice.
|
|
10
|
+
*
|
|
11
|
+
* Set names and error codes ported from OpenClaw's extensions/telegram/
|
|
12
|
+
* src/network-errors.ts, which came from production experience.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Pre-connect errors: the TCP/TLS handshake never completed, so the HTTP
|
|
16
|
+
// request never went out. Retry is idempotent by definition.
|
|
17
|
+
const PRE_CONNECT_ERROR_CODES = new Set([
|
|
18
|
+
'ECONNREFUSED', // nothing listening on target port
|
|
19
|
+
'ENOTFOUND', // DNS failed
|
|
20
|
+
'EAI_AGAIN', // DNS timeout / temporary failure
|
|
21
|
+
'ENETUNREACH', // no route to host (WAN drop)
|
|
22
|
+
'EHOSTUNREACH', // host unreachable (local firewall / sleep)
|
|
23
|
+
'ECONNRESET', // peer sent RST before reply — *usually* safe to retry;
|
|
24
|
+
// technically the server might have started processing
|
|
25
|
+
// before resetting. Include conservatively because the
|
|
26
|
+
// alternative is a lost message. Telegram doesn't commit
|
|
27
|
+
// a sendMessage server-side until it returns 200.
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Transient errors that are recoverable but may have made it partway. DO
|
|
31
|
+
// NOT auto-retry these — the risk of double-delivery outweighs the gain.
|
|
32
|
+
// Surface them to the caller and let humans decide.
|
|
33
|
+
const RECOVERABLE_ERROR_CODES = new Set([
|
|
34
|
+
'ETIMEDOUT', // TCP timeout after connect (message may have landed)
|
|
35
|
+
'EPIPE', // write after close — outcome indeterminate
|
|
36
|
+
'EAGAIN', // socket would block — reader should retry
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
// Error.name values emitted by undici/node for transient conditions.
|
|
40
|
+
const RECOVERABLE_ERROR_NAMES = new Set([
|
|
41
|
+
'AbortError',
|
|
42
|
+
'TimeoutError',
|
|
43
|
+
'FetchError',
|
|
44
|
+
'SocketError',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
function extractCode(err) {
|
|
48
|
+
if (!err) return null;
|
|
49
|
+
return err.code
|
|
50
|
+
|| err.cause?.code
|
|
51
|
+
|| err.errno
|
|
52
|
+
|| null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractName(err) {
|
|
56
|
+
if (!err) return null;
|
|
57
|
+
return err.name || err.cause?.name || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Can we safely retry this error ONCE without risking double-delivery?
|
|
62
|
+
* Only true for errors that definitionally occurred before the HTTP request
|
|
63
|
+
* reached the server.
|
|
64
|
+
*/
|
|
65
|
+
function isSafeToRetry(err) {
|
|
66
|
+
const code = extractCode(err);
|
|
67
|
+
return code != null && PRE_CONNECT_ERROR_CODES.has(code);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Is this a transient network error — recoverable in the sense that the
|
|
72
|
+
* connection may work next time, but NOT safe to auto-retry because the
|
|
73
|
+
* message might have landed?
|
|
74
|
+
*/
|
|
75
|
+
function isTransientNetworkError(err) {
|
|
76
|
+
if (!err) return false;
|
|
77
|
+
const code = extractCode(err);
|
|
78
|
+
if (code && (PRE_CONNECT_ERROR_CODES.has(code) || RECOVERABLE_ERROR_CODES.has(code))) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const name = extractName(err);
|
|
82
|
+
if (name && RECOVERABLE_ERROR_NAMES.has(name)) return true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
PRE_CONNECT_ERROR_CODES,
|
|
88
|
+
RECOVERABLE_ERROR_CODES,
|
|
89
|
+
RECOVERABLE_ERROR_NAMES,
|
|
90
|
+
isSafeToRetry,
|
|
91
|
+
isTransientNetworkError,
|
|
92
|
+
extractCode,
|
|
93
|
+
extractName,
|
|
94
|
+
};
|
package/lib/process-manager.js
CHANGED
|
@@ -16,10 +16,13 @@ const DEFAULT_CAP = 10;
|
|
|
16
16
|
const DEFAULT_KILL_TIMEOUT_MS = 3000;
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Pull text from a stream-json `assistant` event.
|
|
19
|
+
* Pull user-visible text from a stream-json `assistant` event.
|
|
20
20
|
* Claude Code emits one event per assistant step; each carries a
|
|
21
|
-
* `message.content[]` of blocks.
|
|
22
|
-
* tool_use blocks
|
|
21
|
+
* `message.content[]` of blocks. Only `text` blocks are returned —
|
|
22
|
+
* `tool_use` blocks still trigger the idle-timer reset in the caller
|
|
23
|
+
* (they count as Claude activity) but are NOT rendered to Telegram.
|
|
24
|
+
* Streaming every tool call to chat produces a noisy "_Calling X_"
|
|
25
|
+
* ladder that adds no information users can act on.
|
|
23
26
|
*/
|
|
24
27
|
function extractAssistantText(event) {
|
|
25
28
|
const blocks = event?.message?.content;
|
|
@@ -29,8 +32,6 @@ function extractAssistantText(event) {
|
|
|
29
32
|
if (!b) continue;
|
|
30
33
|
if (b.type === 'text' && typeof b.text === 'string') {
|
|
31
34
|
parts.push(b.text);
|
|
32
|
-
} else if (b.type === 'tool_use' && b.name) {
|
|
33
|
-
parts.push(`_Calling \`${b.name}\`…_`);
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
return parts.join('\n\n').trim();
|
|
@@ -47,6 +48,7 @@ class ProcessManager {
|
|
|
47
48
|
onResult = null, // (sessionKey, event) → void (turn result)
|
|
48
49
|
onClose = null, // (sessionKey, code) → void
|
|
49
50
|
onStreamChunk = null,// (sessionKey, partialText, entry) → void (per assistant event)
|
|
51
|
+
onToolUse = null, // (sessionKey, toolName, entry) → void (per tool_use block)
|
|
50
52
|
} = {}) {
|
|
51
53
|
if (!spawnFn) throw new Error('spawnFn required');
|
|
52
54
|
this.cap = cap;
|
|
@@ -58,6 +60,7 @@ class ProcessManager {
|
|
|
58
60
|
this.onResult = onResult;
|
|
59
61
|
this.onClose = onClose;
|
|
60
62
|
this.onStreamChunk = onStreamChunk;
|
|
63
|
+
this.onToolUse = onToolUse;
|
|
61
64
|
this.procs = new Map();
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -188,6 +191,19 @@ class ProcessManager {
|
|
|
188
191
|
catch (err) { this.logger.error(`[${entry.label}] onStreamChunk: ${err.message}`); }
|
|
189
192
|
}
|
|
190
193
|
}
|
|
194
|
+
// Emit tool_use blocks separately so callers (e.g. status reactions)
|
|
195
|
+
// can react to each tool name without re-parsing stream text.
|
|
196
|
+
if (this.onToolUse) {
|
|
197
|
+
const blocks = event.message?.content;
|
|
198
|
+
if (Array.isArray(blocks)) {
|
|
199
|
+
for (const b of blocks) {
|
|
200
|
+
if (b?.type === 'tool_use' && b.name) {
|
|
201
|
+
try { this.onToolUse(sessionKey, b.name, entry); }
|
|
202
|
+
catch (err) { this.logger.error(`[${entry.label}] onToolUse: ${err.message}`); }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
191
207
|
}
|
|
192
208
|
if (event.type === 'result' && entry.pending) {
|
|
193
209
|
const { resolve } = entry.pending;
|
|
@@ -238,7 +254,7 @@ class ProcessManager {
|
|
|
238
254
|
return entry;
|
|
239
255
|
}
|
|
240
256
|
|
|
241
|
-
send(sessionKey, prompt, { timeoutMs = 600_000 } = {}) {
|
|
257
|
+
send(sessionKey, prompt, { timeoutMs = 600_000, maxTurnMs = 30 * 60_000 } = {}) {
|
|
242
258
|
return new Promise((resolve, reject) => {
|
|
243
259
|
const entry = this.procs.get(sessionKey);
|
|
244
260
|
if (!entry || entry.closed) return reject(new Error('No process for session'));
|
|
@@ -256,27 +272,65 @@ class ProcessManager {
|
|
|
256
272
|
entry.pending = { resolve, reject };
|
|
257
273
|
entry.streamText = '';
|
|
258
274
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
275
|
+
const clearTimers = () => {
|
|
276
|
+
if (entry.pending?.idleTimer) clearTimeout(entry.pending.idleTimer);
|
|
277
|
+
if (entry.pending?.maxTimer) clearTimeout(entry.pending.maxTimer);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Timer fire path. New in 0.3.9: after rejecting, SIGTERM the
|
|
281
|
+
// subprocess. Previously we only rejected the promise and left the
|
|
282
|
+
// stuck claude running — the next message would write stdin to a
|
|
283
|
+
// zombie process. Killing fires the 'close' handler which cleans
|
|
284
|
+
// up the LRU entry, so the next send() gets a fresh spawn.
|
|
285
|
+
const fireTimeout = (reason) => {
|
|
286
|
+
if (!entry.pending) return;
|
|
287
|
+
clearTimers();
|
|
288
|
+
entry.pending = null;
|
|
289
|
+
entry.inFlight = false;
|
|
290
|
+
try { entry.proc.kill('SIGTERM'); } catch {}
|
|
291
|
+
this._logEvent('turn-timeout', {
|
|
292
|
+
session_key: sessionKey,
|
|
293
|
+
chat_id: entry.chatId,
|
|
294
|
+
reason,
|
|
295
|
+
});
|
|
296
|
+
reject(new Error(reason));
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// Idle timeout: counts N seconds of SILENCE from Claude. Reset on
|
|
300
|
+
// every assistant event so long productive turns (multi-tool
|
|
301
|
+
// reasoning) don't falsely trip.
|
|
302
|
+
// .unref() so these timers don't hold the node event loop open in
|
|
303
|
+
// tests or when the parent process wants to exit. Real-world polygram
|
|
304
|
+
// stays alive via grammy's poll loop + stdin/stdout pipes; the timers
|
|
305
|
+
// don't need to keep it alive on their own.
|
|
306
|
+
const armIdle = () => setTimeout(
|
|
307
|
+
() => fireTimeout(`Timeout: ${timeoutMs / 1000}s idle with no Claude activity`),
|
|
308
|
+
timeoutMs,
|
|
309
|
+
).unref();
|
|
310
|
+
entry.pending.idleTimer = armIdle();
|
|
271
311
|
entry.pending.resetIdleTimer = () => {
|
|
272
|
-
if (entry.pending
|
|
273
|
-
|
|
312
|
+
if (!entry.pending) return;
|
|
313
|
+
clearTimeout(entry.pending.idleTimer);
|
|
314
|
+
entry.pending.idleTimer = armIdle();
|
|
274
315
|
};
|
|
275
316
|
|
|
317
|
+
// Wall-clock ceiling: fires at maxTurnMs regardless of activity.
|
|
318
|
+
// Catches stuck API calls that emit occasional events (keeping the
|
|
319
|
+
// idle timer alive) but never produce a result. OpenClaw's only
|
|
320
|
+
// timer was wall-clock; polygram's 0.3.5 change replaced it with
|
|
321
|
+
// idle-reset, creating a gap this restores as a last-resort.
|
|
322
|
+
entry.pending.maxTimer = setTimeout(
|
|
323
|
+
() => fireTimeout(`Turn exceeded ${maxTurnMs / 1000}s wall-clock ceiling`),
|
|
324
|
+
maxTurnMs,
|
|
325
|
+
).unref();
|
|
326
|
+
|
|
327
|
+
// Legacy alias: some callers / tests refer to entry.pending.timer.
|
|
328
|
+
entry.pending.timer = entry.pending.idleTimer;
|
|
329
|
+
|
|
276
330
|
const wrappedResolve = entry.pending.resolve;
|
|
277
331
|
const wrappedReject = entry.pending.reject;
|
|
278
|
-
entry.pending.resolve = (r) => {
|
|
279
|
-
entry.pending.reject = (e) => {
|
|
332
|
+
entry.pending.resolve = (r) => { clearTimers(); wrappedResolve(r); };
|
|
333
|
+
entry.pending.reject = (e) => { clearTimers(); wrappedReject(e); };
|
|
280
334
|
|
|
281
335
|
try {
|
|
282
336
|
entry.proc.stdin.write(JSON.stringify({
|
|
@@ -284,7 +338,7 @@ class ProcessManager {
|
|
|
284
338
|
message: { role: 'user', content: prompt },
|
|
285
339
|
}) + '\n');
|
|
286
340
|
} catch (err) {
|
|
287
|
-
|
|
341
|
+
clearTimers();
|
|
288
342
|
entry.pending = null;
|
|
289
343
|
entry.inFlight = false;
|
|
290
344
|
reject(err);
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-reaction state machine.
|
|
3
|
+
*
|
|
4
|
+
* Goal: give users a silent, non-intrusive progress signal during a turn.
|
|
5
|
+
* Telegram bot reactions are delivered quietly (no notification), update
|
|
6
|
+
* in place, and one emoji per message. Perfect for state like
|
|
7
|
+
* "thinking → coding → web → done".
|
|
8
|
+
*
|
|
9
|
+
* The state machine below translates Claude's stream-json event stream
|
|
10
|
+
* into a small set of states, each mapped to an emoji. The caller
|
|
11
|
+
* (usually polygram's handleMessage) holds a ReactionManager instance
|
|
12
|
+
* and calls setState() at transition points.
|
|
13
|
+
*
|
|
14
|
+
* Design choices:
|
|
15
|
+
* - We pick emojis from Telegram's default-available set so groups
|
|
16
|
+
* that haven't customised `available_reactions` still work. Callers
|
|
17
|
+
* can pass an allowlist probed from getChat().available_reactions
|
|
18
|
+
* for groups that have — we fall back through a chain for each
|
|
19
|
+
* state until we find an allowed one.
|
|
20
|
+
* - Rate-limit changes to every 800ms (Telegram allows ~1/s per
|
|
21
|
+
* message). Intermediate states are dropped.
|
|
22
|
+
* - Terminal states (DONE/ERROR/TIMEOUT) always flush, ignoring
|
|
23
|
+
* throttle, so the user sees the final outcome.
|
|
24
|
+
* - On abort or cleanup we clear the reaction entirely rather than
|
|
25
|
+
* leaving a stale "thinking" emoji.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Ordered fallback chains — first emoji is the preferred one; follow-ups
|
|
29
|
+
// are progressively safer. All endings in this list are in Telegram's
|
|
30
|
+
// default available reactions as of 2026-04.
|
|
31
|
+
const STATES = {
|
|
32
|
+
QUEUED: { label: 'queued', chain: ['👀', '🤔'] },
|
|
33
|
+
THINKING: { label: 'thinking', chain: ['🤔'] },
|
|
34
|
+
CODING: { label: 'coding', chain: ['👨💻', '✍', '🤔'] },
|
|
35
|
+
WEB: { label: 'web', chain: ['⚡', '🔥', '🤔'] },
|
|
36
|
+
TOOL: { label: 'tool', chain: ['🔥', '🤔'] },
|
|
37
|
+
WRITING: { label: 'writing', chain: ['✍', '🤔'] },
|
|
38
|
+
DONE: { label: 'done', chain: ['👍'] },
|
|
39
|
+
ERROR: { label: 'error', chain: ['🤯', '🤔'] },
|
|
40
|
+
STALL: { label: 'stall', chain: ['🥱', '🤔'] },
|
|
41
|
+
TIMEOUT: { label: 'timeout', chain: ['😨', '🤯'] },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const TERMINAL_STATES = new Set(['DONE', 'ERROR', 'TIMEOUT']);
|
|
45
|
+
const DEFAULT_THROTTLE_MS = 800;
|
|
46
|
+
|
|
47
|
+
// Tool name → state classifier. Case-insensitive contains for tool names
|
|
48
|
+
// that share a category (Read/Write/Edit are all "coding"; WebFetch +
|
|
49
|
+
// WebSearch are "web"; everything else is generic TOOL).
|
|
50
|
+
function classifyToolName(name) {
|
|
51
|
+
if (typeof name !== 'string' || !name) return 'TOOL';
|
|
52
|
+
if (/^(Web)/i.test(name)) return 'WEB';
|
|
53
|
+
if (/^(Bash|Read|Write|Edit|NotebookEdit|Glob|Grep)$/.test(name)) return 'CODING';
|
|
54
|
+
if (/^(TodoWrite|Task)$/.test(name)) return 'WRITING';
|
|
55
|
+
return 'TOOL';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the best-available emoji from a chain given an allowlist.
|
|
60
|
+
* If allowlist is null/undefined, assume default-available set and
|
|
61
|
+
* return the first entry.
|
|
62
|
+
*/
|
|
63
|
+
function resolveEmoji(chain, allowlist) {
|
|
64
|
+
if (!allowlist) return chain[0];
|
|
65
|
+
const allowed = allowlist instanceof Set ? allowlist : new Set(allowlist);
|
|
66
|
+
for (const emoji of chain) {
|
|
67
|
+
if (allowed.has(emoji)) return emoji;
|
|
68
|
+
}
|
|
69
|
+
// Nothing in the chain is allowed — signal "no reaction possible".
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a reaction manager for a single turn.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} deps
|
|
77
|
+
* @param {(emoji: string|null) => Promise<void>} deps.apply invoked with the
|
|
78
|
+
* resolved emoji when state changes. `null` means "clear reaction".
|
|
79
|
+
* @param {string[]|Set<string>|null} [deps.availableEmojis] allowlist probed
|
|
80
|
+
* from getChat().available_reactions. Null/undefined = assume defaults.
|
|
81
|
+
* @param {number} [deps.throttleMs] minimum ms between non-terminal changes.
|
|
82
|
+
* @param {(msg: string) => void} [deps.logError]
|
|
83
|
+
*/
|
|
84
|
+
function createReactionManager({
|
|
85
|
+
apply,
|
|
86
|
+
availableEmojis = null,
|
|
87
|
+
throttleMs = DEFAULT_THROTTLE_MS,
|
|
88
|
+
logError = () => {},
|
|
89
|
+
} = {}) {
|
|
90
|
+
if (typeof apply !== 'function') throw new Error('apply function required');
|
|
91
|
+
let currentState = null;
|
|
92
|
+
let currentEmoji = null;
|
|
93
|
+
let lastFlushTs = 0;
|
|
94
|
+
let pendingTimer = null;
|
|
95
|
+
let stopped = false;
|
|
96
|
+
|
|
97
|
+
const flush = async (stateName) => {
|
|
98
|
+
if (stopped) return;
|
|
99
|
+
const spec = STATES[stateName];
|
|
100
|
+
if (!spec) return;
|
|
101
|
+
const emoji = resolveEmoji(spec.chain, availableEmojis);
|
|
102
|
+
if (emoji === currentEmoji) return;
|
|
103
|
+
currentEmoji = emoji;
|
|
104
|
+
lastFlushTs = Date.now();
|
|
105
|
+
try {
|
|
106
|
+
await apply(emoji);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
logError(`reaction apply failed (${stateName} → ${emoji}): ${err?.message || err}`);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const setState = (stateName) => {
|
|
113
|
+
if (stopped) return;
|
|
114
|
+
if (!STATES[stateName]) return;
|
|
115
|
+
currentState = stateName;
|
|
116
|
+
|
|
117
|
+
// Terminal states flush immediately, bypassing throttle.
|
|
118
|
+
if (TERMINAL_STATES.has(stateName)) {
|
|
119
|
+
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
120
|
+
return flush(stateName);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const elapsed = Date.now() - lastFlushTs;
|
|
124
|
+
if (elapsed >= throttleMs) {
|
|
125
|
+
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
126
|
+
return flush(stateName);
|
|
127
|
+
}
|
|
128
|
+
// Inside throttle window: schedule for the soonest safe flush.
|
|
129
|
+
if (!pendingTimer) {
|
|
130
|
+
pendingTimer = setTimeout(() => {
|
|
131
|
+
pendingTimer = null;
|
|
132
|
+
flush(currentState);
|
|
133
|
+
}, throttleMs - elapsed);
|
|
134
|
+
pendingTimer.unref?.();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const clear = async () => {
|
|
139
|
+
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
140
|
+
if (currentEmoji == null) return;
|
|
141
|
+
currentEmoji = null;
|
|
142
|
+
try { await apply(null); }
|
|
143
|
+
catch (err) { logError(`reaction clear failed: ${err?.message || err}`); }
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const stop = () => {
|
|
147
|
+
stopped = true;
|
|
148
|
+
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
setState,
|
|
153
|
+
clear,
|
|
154
|
+
stop,
|
|
155
|
+
// Introspection for tests:
|
|
156
|
+
get currentState() { return currentState; },
|
|
157
|
+
get currentEmoji() { return currentEmoji; },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
createReactionManager,
|
|
163
|
+
classifyToolName,
|
|
164
|
+
resolveEmoji,
|
|
165
|
+
STATES,
|
|
166
|
+
TERMINAL_STATES,
|
|
167
|
+
DEFAULT_THROTTLE_MS,
|
|
168
|
+
};
|
package/lib/stream-reply.js
CHANGED
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const DEFAULT_MIN_CHARS = 30;
|
|
19
|
-
|
|
19
|
+
// Matches OpenClaw's edit throttle. 500ms was edit-storm territory on long
|
|
20
|
+
// turns — every token burst triggered an API call, risking 429s and burning
|
|
21
|
+
// Telegram's edit-rate budget faster than necessary. 1000ms feels
|
|
22
|
+
// identical to a viewer and halves the edit volume.
|
|
23
|
+
const DEFAULT_THROTTLE_MS = 1000;
|
|
20
24
|
|
|
21
25
|
function createStreamer({
|
|
22
26
|
send, // async (text) -> { message_id }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert Claude's CommonMark output into Telegram-safe MarkdownV2.
|
|
3
|
+
*
|
|
4
|
+
* Why: Claude emits standard GitHub-flavoured markdown (headings, bullets,
|
|
5
|
+
* `**bold**`, fenced code). Telegram does NOT support headings or bullet
|
|
6
|
+
* lists natively; `**bold**` is `*bold*` in its dialect; and MarkdownV2
|
|
7
|
+
* requires escaping `_*[]()~\`>#+-=|{}.!` in non-formatted text. Sending
|
|
8
|
+
* Claude's raw markdown with no parse_mode shows literal `**` and `#`
|
|
9
|
+
* in chat; sending it with parse_mode: MarkdownV2 crashes with "can't
|
|
10
|
+
* parse entities" the moment Claude writes a period or exclamation mark.
|
|
11
|
+
*
|
|
12
|
+
* telegramify-markdown handles both concerns: downgrades unsupported
|
|
13
|
+
* constructs (headings → bold, bullets → `•`) and escapes reserved chars.
|
|
14
|
+
*
|
|
15
|
+
* We wrap it here rather than calling it inline so:
|
|
16
|
+
* - Swapping libraries later is a one-file change.
|
|
17
|
+
* - Fallback-on-throw is centralised (if conversion explodes, we send
|
|
18
|
+
* the original text with no parse_mode — worse formatting, but the
|
|
19
|
+
* message still arrives).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const telegramify = require('telegramify-markdown');
|
|
23
|
+
|
|
24
|
+
function toTelegramMarkdown(text) {
|
|
25
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
26
|
+
return { text, parseMode: null };
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const converted = telegramify(text, 'escape');
|
|
30
|
+
return { text: converted, parseMode: 'MarkdownV2' };
|
|
31
|
+
} catch {
|
|
32
|
+
return { text, parseMode: null };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { toTelegramMarkdown };
|