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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.3.5",
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 a round-trip smoke test:
120
+ Every install includes `polygram-doctor` for operational diagnostics:
121
121
 
122
122
  ```bash
123
- polygram-smoke --bot my-bot --to <admin-chat-id>
123
+ polygram-doctor --bot my-bot
124
124
  ```
125
125
 
126
- Exits 0 on success, 1 on any step failure. It verifies:
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
- 1. **IPC ping** — the per-bot unix socket is up
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
- ipc-ping — bot=my-bot
137
- outbound-sendmsg_id=12345
138
- db-readbacksent row confirmed (source=polygram-smoke)
139
-
140
- polygram-smoke: PASS my-bot 2026-04-22T15:30:00.000Z
135
+ config — bot found, 4 chat(s), admin=68861949
136
+ dbschema v5
137
+ ipcsocket 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: `--db <path>` or `POLYGRAM_DB` for non-default DB location;
144
- `--timeout-ms <ms>` (default 8000).
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
- The test sends a tagged message (`polygram-smoke:<timestamp>`) silently
147
- to the target chat. Use your own DM as `--to` so the marker arrives
148
- somewhere you control.
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
 
@@ -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
- "streamReplies": true,
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": 500,
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
+ };
@@ -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. Text blocks have `{type:'text', text:'…'}`;
22
- * tool_use blocks we summarise inline so the user sees what Claude is doing.
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
- // Idle timeout: counts N seconds of SILENCE from Claude, not total
260
- // wall-clock. Long multi-tool turns that produce visible progress
261
- // (streaming chunks, tool_use events) should not time out as long as
262
- // they're actively working. onStreamChunk resets this below.
263
- const arm = () => setTimeout(() => {
264
- if (entry.pending) {
265
- entry.pending = null;
266
- entry.inFlight = false;
267
- reject(new Error(`Timeout: ${timeoutMs / 1000}s idle with no Claude activity`));
268
- }
269
- }, timeoutMs);
270
- entry.pending.timer = arm();
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?.timer) clearTimeout(entry.pending.timer);
273
- if (entry.pending) entry.pending.timer = arm();
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) => { if (entry.pending?.timer) clearTimeout(entry.pending.timer); wrappedResolve(r); };
279
- entry.pending.reject = (e) => { if (entry.pending?.timer) clearTimeout(entry.pending.timer); wrappedReject(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
- if (entry.pending?.timer) clearTimeout(entry.pending.timer);
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
+ };
@@ -16,7 +16,11 @@
16
16
  */
17
17
 
18
18
  const DEFAULT_MIN_CHARS = 30;
19
- const DEFAULT_THROTTLE_MS = 500;
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 };