polygram 0.8.0 → 0.9.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/{agent-loader.js → agents/loader.js} +6 -8
  3. package/lib/{approvals.js → approvals/store.js} +28 -5
  4. package/lib/{approval-ui.js → approvals/ui.js} +1 -17
  5. package/lib/config.js +121 -0
  6. package/lib/{error-classify.js → error/classify.js} +25 -34
  7. package/lib/handlers/abort.js +89 -0
  8. package/lib/handlers/approvals.js +361 -0
  9. package/lib/handlers/autosteer.js +94 -0
  10. package/lib/handlers/config-callback.js +118 -0
  11. package/lib/handlers/config-ui.js +104 -0
  12. package/lib/handlers/dispatcher.js +263 -0
  13. package/lib/handlers/download.js +182 -0
  14. package/lib/handlers/extract-attachments.js +97 -0
  15. package/lib/handlers/ipc-send.js +80 -0
  16. package/lib/handlers/poll.js +140 -0
  17. package/lib/handlers/record-inbound.js +88 -0
  18. package/lib/handlers/slash-commands.js +319 -0
  19. package/lib/handlers/voice.js +107 -0
  20. package/lib/pm-interface.js +27 -29
  21. package/lib/sdk/build-options.js +177 -0
  22. package/lib/sdk/callbacks.js +213 -0
  23. package/lib/{process-manager-sdk.js → sdk/process-manager.js} +19 -31
  24. package/lib/{telegram.js → telegram/api.js} +2 -2
  25. package/lib/{telegram-prompt.js → telegram/display-hint.js} +0 -14
  26. package/lib/{stream-reply.js → telegram/streamer.js} +4 -4
  27. package/package.json +2 -3
  28. package/polygram.js +347 -2581
  29. package/scripts/doctor.js +1 -1
  30. package/scripts/ipc-smoke.js +1 -10
  31. package/bin/approval-hook.js +0 -113
  32. package/lib/approval-waiters.js +0 -201
  33. package/lib/pm-router.js +0 -201
  34. package/lib/process-manager.js +0 -806
  35. /package/lib/{auto-resume.js → db/auto-resume.js} +0 -0
  36. /package/lib/{inbox.js → db/inbox.js} +0 -0
  37. /package/lib/{pairings.js → db/pairings.js} +0 -0
  38. /package/lib/{replay-window.js → db/replay-window.js} +0 -0
  39. /package/lib/{sent-cache.js → db/sent-cache.js} +0 -0
  40. /package/lib/{sessions.js → db/sessions.js} +0 -0
  41. /package/lib/{net-errors.js → error/net.js} +0 -0
  42. /package/lib/{ipc-client.js → ipc/client.js} +0 -0
  43. /package/lib/{ipc-file-validator.js → ipc/file-validator.js} +0 -0
  44. /package/lib/{ipc-server.js → ipc/server.js} +0 -0
  45. /package/lib/{telegram-chunk.js → telegram/chunk.js} +0 -0
  46. /package/lib/{deliver.js → telegram/deliver.js} +0 -0
  47. /package/lib/{telegram-format.js → telegram/format.js} +0 -0
  48. /package/lib/{parse-response.js → telegram/parse.js} +0 -0
  49. /package/lib/{status-reactions.js → telegram/reactions.js} +0 -0
  50. /package/lib/{typing-indicator.js → telegram/typing.js} +0 -0
  51. /package/lib/{voice.js → telegram/voice.js} +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.8.0",
4
+ "version": "0.9.0-rc.2",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -1,13 +1,11 @@
1
1
  /**
2
- * Per-chat agent loader for the SDK migration (Phase 1 step 14 /
3
- * v4 plan §6.5.5).
2
+ * Per-chat agent loader.
4
3
  *
5
- * Background: today's CLI pm passes `--agent <name>` on spawn; the
6
- * Claude CLI then loads that agent's content. Phase 0 gate 15 was
7
- * DEFER the SDK's `Options.agents` is for in-memory subagent
8
- * definitions (the Task tool), NOT a "run THIS query AS this agent"
9
- * mechanism. So polygram reads the agent file itself and passes its
10
- * content as `systemPrompt`.
4
+ * polygram reads the per-chat agent file itself and passes its
5
+ * content as `systemPrompt`. The SDK's `Options.agents` is for
6
+ * in-memory subagent definitions (the Task tool), NOT a "run THIS
7
+ * query AS this agent" mechanism so we resolve the agent file
8
+ * out-of-band and inject the system prompt directly.
11
9
  *
12
10
  * Search order (rc.13+ — supports BOTH Claude Code's standard
13
11
  * single-file convention AND polygram's pre-0.8.0 directory layout):
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  const crypto = require('crypto');
14
- const { canonicalizeToolInput } = require('./canonical-json');
14
+ const { canonicalizeToolInput } = require('../canonical-json');
15
15
 
16
16
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
17
17
  // 16 random bytes → 22 base64url chars ≈ 128 bits of entropy. Prevents
@@ -103,15 +103,27 @@ function matchesAnyPattern(toolName, toolInput, patterns = []) {
103
103
  function createStore(rawDb, now = () => Date.now()) {
104
104
  const insertStmt = rawDb.prepare(`
105
105
  INSERT INTO pending_approvals (
106
- bot_name, turn_id, requester_chat_id, approver_chat_id,
106
+ bot_name, turn_id, tool_use_id, requester_chat_id, approver_chat_id,
107
107
  tool_name, tool_input_json, tool_input_digest,
108
108
  callback_token, requested_ts, timeout_ts
109
109
  ) VALUES (
110
- @bot_name, @turn_id, @requester_chat_id, @approver_chat_id,
110
+ @bot_name, @turn_id, @tool_use_id, @requester_chat_id, @approver_chat_id,
111
111
  @tool_name, @tool_input_json, @tool_input_digest,
112
112
  @callback_token, @requested_ts, @timeout_ts
113
113
  )
114
114
  `);
115
+ // 0.9.0-cleanup commit 10: stronger dedup via tool_use_id when the
116
+ // SDK provides one. tool_use_id is the SDK's stable per-call ID;
117
+ // unlike the legacy (turn_id, tool_input_digest) tuple, it survives
118
+ // JSON-key reordering between retries within a turn. Migration 010
119
+ // added the column + partial index `idx_pending_approvals_tool_use_id`
120
+ // which had been unused since rc.6 because no insert path populated
121
+ // the column. issue() now does.
122
+ const findDedupByToolUseIdStmt = rawDb.prepare(`
123
+ SELECT * FROM pending_approvals
124
+ WHERE bot_name = ? AND tool_use_id = ? AND status = 'pending'
125
+ LIMIT 1
126
+ `);
115
127
  const findDedupStmt = rawDb.prepare(`
116
128
  SELECT * FROM pending_approvals
117
129
  WHERE bot_name = ? AND turn_id IS ? AND tool_input_digest = ?
@@ -143,7 +155,8 @@ function createStore(rawDb, now = () => Date.now()) {
143
155
 
144
156
  return {
145
157
  issue({
146
- bot_name, turn_id = null, requester_chat_id, approver_chat_id,
158
+ bot_name, turn_id = null, tool_use_id = null,
159
+ requester_chat_id, approver_chat_id,
147
160
  tool_name, tool_input, timeoutMs = DEFAULT_TIMEOUT_MS,
148
161
  }) {
149
162
  if (!bot_name) throw new Error('bot_name required');
@@ -164,13 +177,23 @@ function createStore(rawDb, now = () => Date.now()) {
164
177
  // the existing row and insert two. SQLite's UPSERT would also work if
165
178
  // we added a UNIQUE partial index in a migration; keeping this in
166
179
  // application code avoids a schema bump.
180
+ //
181
+ // 0.9.0: prefer dedup by SDK's stable tool_use_id when available.
182
+ // The legacy (turn_id, tool_input_digest) tuple survives only when
183
+ // the SDK doesn't provide a tool_use_id (cron-driven sends, IPC
184
+ // callers from older Claude versions). Both code paths route
185
+ // through the same INSERT below; the only thing that varies is
186
+ // which existing row counts as a "match."
167
187
  let row, reused = false;
168
188
  const tx = rawDb.transaction(() => {
169
- const existing = findDedupStmt.get(bot_name, turn_id, tool_input_digest);
189
+ const existing = tool_use_id
190
+ ? findDedupByToolUseIdStmt.get(bot_name, tool_use_id)
191
+ : findDedupStmt.get(bot_name, turn_id, tool_input_digest);
170
192
  if (existing) { row = existing; reused = true; return; }
171
193
  const res = insertStmt.run({
172
194
  bot_name,
173
195
  turn_id,
196
+ tool_use_id,
174
197
  requester_chat_id: String(requester_chat_id),
175
198
  approver_chat_id: String(approver_chat_id),
176
199
  tool_name,
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Pure UI builders for the approval flow's Telegram surface.
3
3
  *
4
- * - 2-button keyboard (CLI pm IPC approval-hook flow)
5
- * - 4-button keyboard (rc.6 SDK pm canUseTool flow with persisted
4
+ * - 4-button keyboard (SDK pm canUseTool flow with persisted
6
5
  * "Always allow / Always deny" via chat_tool_decisions)
7
6
  * - Card text with friendly heading + clipped tool_input body
8
7
  *
@@ -13,20 +12,6 @@
13
12
 
14
13
  'use strict';
15
14
 
16
- /**
17
- * 2-button keyboard for the legacy IPC approval flow.
18
- * @param {number|string} approvalId
19
- * @param {string} token
20
- */
21
- function buildApprovalKeyboard(approvalId, token) {
22
- return {
23
- inline_keyboard: [[
24
- { text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
25
- { text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
26
- ]],
27
- };
28
- }
29
-
30
15
  /**
31
16
  * 4-button keyboard for the SDK canUseTool flow (rc.6 Phase 2 step 6).
32
17
  * "Always allow" / "Always deny" rows persist the decision into
@@ -126,7 +111,6 @@ function safeParse(s) {
126
111
  }
127
112
 
128
113
  module.exports = {
129
- buildApprovalKeyboard,
130
114
  buildApprovalKeyboardWithAlways,
131
115
  formatToolInputForCard,
132
116
  approvalCardText,
package/lib/config.js ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Config loader / saver / sticker-map loader.
3
+ *
4
+ * Pure I/O functions extracted from polygram.js. The caller owns
5
+ * the module-level mutable `config` / `stickerMap` state and
6
+ * assigns the return values; this module never touches process
7
+ * globals.
8
+ *
9
+ * - `loadConfig(configPath)` — sync read + JSON.parse. Throws on
10
+ * parse error so the caller fails fast at boot.
11
+ * - `saveConfig({ configPath, botName, config })` — atomic
12
+ * read-merge-write. In-memory `config` is filtered (one bot's
13
+ * scope only); to avoid clobbering OTHER bots on disk we read
14
+ * the live file fresh, overlay our bot's section + chats, then
15
+ * rename a temp file in place. Top-level non-bot-scoped fields
16
+ * are NOT touched (ops-wide policy lives there).
17
+ * - `loadStickers(stickersPath)` — read sticker map. Returns
18
+ * `{ stickerMap, emojiToSticker }`. Missing file is non-fatal:
19
+ * returns empty maps and logs "No sticker map found".
20
+ * - `isWellFormedMessage(msg)` — pure predicate; quick shape
21
+ * check before recordInbound runs hashing/DB-writes on
22
+ * user-controlled Telegram updates.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+
29
+ function loadConfig(configPath) {
30
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
31
+ }
32
+
33
+ /**
34
+ * Atomic read-merge-write. We only ever write our bot's section +
35
+ * its chats; other bots in the same on-disk file are preserved.
36
+ * The temp file + rename is for atomicity (a crash mid-write
37
+ * leaves the .tmp.<pid> rather than a truncated config).
38
+ */
39
+ function saveConfig({ configPath, botName, config }) {
40
+ const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf8'));
41
+
42
+ if (botName && config.bots?.[botName]) {
43
+ onDisk.bots = onDisk.bots || {};
44
+ onDisk.bots[botName] = config.bots[botName];
45
+ }
46
+ if (config.chats) {
47
+ onDisk.chats = onDisk.chats || {};
48
+ for (const [chatId, chat] of Object.entries(config.chats)) {
49
+ onDisk.chats[chatId] = chat;
50
+ }
51
+ }
52
+ // Top-level non-bot-scoped fields (defaults, maxWarmProcesses,
53
+ // etc.) are ops-wide policy — leave them as-is on disk.
54
+
55
+ const tmp = `${configPath}.tmp.${process.pid}`;
56
+ fs.writeFileSync(tmp, JSON.stringify(onDisk, null, 2));
57
+ fs.renameSync(tmp, configPath);
58
+ }
59
+
60
+ /**
61
+ * @returns {{ stickerMap: object, emojiToSticker: object }}
62
+ */
63
+ function loadStickers(stickersPath, { logger = console } = {}) {
64
+ const stickerMap = {};
65
+ const emojiToSticker = {};
66
+ try {
67
+ const data = JSON.parse(fs.readFileSync(stickersPath, 'utf8'));
68
+ for (const [name, s] of Object.entries(data.stickers || {})) {
69
+ stickerMap[name] = s.file_id;
70
+ if (s.emoji) emojiToSticker[s.emoji] = s.file_id;
71
+ }
72
+ logger.log?.(`Stickers: ${Object.keys(stickerMap).join(', ')}`);
73
+ } catch {
74
+ logger.log?.('No sticker map found');
75
+ }
76
+ return { stickerMap, emojiToSticker };
77
+ }
78
+
79
+ /**
80
+ * Quick shape check before recordInbound runs. Telegram updates
81
+ * are user-controlled; a hostile or malformed payload without
82
+ * chat.id / message_id would throw deep in the writer.
83
+ */
84
+ function isWellFormedMessage(msg) {
85
+ return !!(msg
86
+ && msg.chat
87
+ && (typeof msg.chat.id === 'number' || typeof msg.chat.id === 'bigint')
88
+ && typeof msg.message_id === 'number');
89
+ }
90
+
91
+ /**
92
+ * Quick shape check on `callback_query`. The handlers use optional
93
+ * chaining so they don't crash on malformed payloads, but skipping
94
+ * obviously-wrong shapes early keeps the events table cleaner and
95
+ * documents what we expect.
96
+ *
97
+ * Polygram only sends message-attached inline buttons — never
98
+ * INLINE-MODE keyboards. A callback with `inline_message_id` and
99
+ * no `message` is therefore either a leaked old keyboard from a
100
+ * deleted chat, an attacker-crafted update, or a Telegram API
101
+ * quirk; either way: skip.
102
+ */
103
+ function isWellFormedCallbackQuery(cb) {
104
+ if (!cb) return false;
105
+ if (typeof cb.id !== 'string' && typeof cb.id !== 'number') return false;
106
+ if (!cb.from || typeof cb.from.id !== 'number') return false;
107
+ if (typeof cb.data !== 'string') return false;
108
+ // Inline-mode callbacks have inline_message_id in lieu of message;
109
+ // polygram never emits inline-mode keyboards, so reject.
110
+ if (!cb.message) return false;
111
+ if (!isWellFormedMessage(cb.message)) return false;
112
+ return true;
113
+ }
114
+
115
+ module.exports = {
116
+ loadConfig,
117
+ saveConfig,
118
+ loadStickers,
119
+ isWellFormedMessage,
120
+ isWellFormedCallbackQuery,
121
+ };
@@ -1,34 +1,26 @@
1
1
  /**
2
2
  * Error classifier — maps any error from any source to a stable shape.
3
3
  *
4
- * Sources today (0.7.7): stream-json `result` events with error
5
- * subtypes, child_process `'close'`/`'error'` event errors, idle
6
- * timer fires, polygram-internal Errors with `err.code` set.
4
+ * Sources covered: SDK iterator throws (`AbortError` named class plus
5
+ * plain `Error`s), `SDKResultMessage` with subtypes
6
+ * `error_during_execution` / `error_max_turns` / `error_max_budget_usd`
7
+ * / `error_max_structured_output_retries`, per-message
8
+ * `SDKAssistantMessage.error` subtypes (`authentication_failed` /
9
+ * `billing_error` / `rate_limit` / `invalid_request` / `server_error`
10
+ * / `unknown` / `max_output_tokens`), 5xx HTTP errors that bubble
11
+ * through the SDK transport, idle timer fires, polygram-internal
12
+ * Errors with `err.code` set (`INTERRUPTED`, `RESET_SESSION`, etc).
7
13
  *
8
- * Sources after 0.8.0 SDK migration: SDK iterator throws
9
- * (`AbortError` named class plus plain `Error`s), `SDKResultMessage`
10
- * with subtypes `error_during_execution` / `error_max_turns` /
11
- * `error_max_budget_usd` / `error_max_structured_output_retries`,
12
- * per-message `SDKAssistantMessage.error` subtypes
13
- * (`authentication_failed` / `billing_error` / `rate_limit` /
14
- * `invalid_request` / `server_error` / `unknown` / `max_output_tokens`),
15
- * 5xx HTTP errors that bubble through the SDK transport.
16
- *
17
- * Returning the same shape regardless of transport means
14
+ * Returning the same shape regardless of source means
18
15
  * `errorReplyText` in polygram.js doesn't grow N branches every time
19
16
  * a new error class shows up — we just add a row to PATTERNS or a
20
17
  * `code:` short-circuit at the top.
21
18
  *
22
- * Layered ship order (per v4 plan §6.5.1):
23
- * - 0.7.7 (this file): transport-agnostic patterns and the public
24
- * `classify()` API. Polygram.js's `errorReplyText` consults this
25
- * module directly.
26
- * - Phase 1 of 0.8.0 (later): adds typed-code branches for
27
- * `INTERRUPTED`, plus SDK `error_max_structured_output_retries`,
28
- * plus per-message SDK error subtypes.
29
- * - Phase 2 of 0.8.0 (later): adds AUTO_RECOVER actions
30
- * (`reset_session` etc) so pm can self-heal stuck sessions
31
- * without waiting for the user to type /new.
19
+ * Returned shape:
20
+ * { kind, userMessage, isTransient, autoRecover, shouldResetSession }
21
+ *
22
+ * AUTO_RECOVER actions ('reset_session' etc) let pm self-heal stuck
23
+ * sessions without waiting for the user to type /new.
32
24
  */
33
25
 
34
26
  'use strict';
@@ -104,8 +96,8 @@ const USER_MESSAGES = {
104
96
  };
105
97
 
106
98
  // Auto-recovery actions for kinds where the session is irrecoverable
107
- // without a reset. Phase 2 of 0.8.0 wires `pm.resetSession()` to
108
- // these; 0.7.7 just exports the table for forward-compat.
99
+ // without a reset. polygram.js consults this when result.error fires
100
+ // and dispatches `pm.resetSession()` accordingly.
109
101
  //
110
102
  // Values map to action names that pm understands:
111
103
  // 'reset_session' — close current Query, clear sessionId, fresh start
@@ -117,8 +109,8 @@ const AUTO_RECOVER = {
117
109
  };
118
110
 
119
111
  // Typed-code short-circuits — set on errors polygram throws itself
120
- // (see lib/process-manager.js), not pattern-matched. Keep these in
121
- // sync with the codes pm emits.
112
+ // (see lib/process-manager-sdk.js), not pattern-matched. Keep these
113
+ // in sync with the codes pm emits.
122
114
  const CODES = {
123
115
  // 0.7.6 (item H): queue cap drop. Pre-empts pattern matching so
124
116
  // the queue-overflow message is exact, not classified.
@@ -128,18 +120,17 @@ const CODES = {
128
120
  isTransient: false,
129
121
  autoRecover: null,
130
122
  },
131
- // 0.8.0 Phase 1 will set this on pendings rejected via
132
- // pm.interrupt(). Matched here so the abort-grace silence works
133
- // before the SDK migration lands (pm could start setting it
134
- // earlier as a no-op).
123
+ // Set on pendings rejected via pm.interrupt() (e.g. /stop). Matched
124
+ // here so the abort-grace silence works — user already saw the
125
+ // /stop ack, no need to surface another error.
135
126
  INTERRUPTED: {
136
127
  kind: 'interrupted',
137
- userMessage: null, // suppressed; user already saw the /stop ack
128
+ userMessage: null,
138
129
  isTransient: false,
139
130
  autoRecover: null,
140
131
  },
141
- // Phase 2 will set this when pm.resetSession() drains the queue
142
- // for any reason (auto-recovery, /new, /reset, auth-expired).
132
+ // Set when pm.resetSession() drains the queue for any reason
133
+ // (auto-recovery, /new, /reset, auth-expired).
143
134
  RESET_SESSION: {
144
135
  kind: 'resetSession',
145
136
  userMessage: '✨ Started a fresh session.',
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Stop / abort handler.
3
+ *
4
+ * Detects natural-language stop cues ("stop" / "стоп" / "cancel" /
5
+ * "отмена") and explicit slash commands (/stop, /abort, /cancel) via
6
+ * the injected isAbortRequest predicate. On match:
7
+ *
8
+ * 1. Mark the session aborted BEFORE the SDK interrupt fires —
9
+ * pm-sdk's close handler races; if we marked after, the
10
+ * generic error-reply could slip through.
11
+ * 2. pm.interrupt() — non-destructive cancel of the in-flight
12
+ * turn (preserves Query for the next user message).
13
+ * 3. pm.drainQueue() — rejects queued pendings with
14
+ * err.code='INTERRUPTED' so the abort-grace classifier
15
+ * suppresses error replies on the way out.
16
+ * 4. Clear ✍ reactions on already-autosteered messages from
17
+ * this turn (now dead context).
18
+ * 5. Acknowledge in the language the user aborted in (en/ru).
19
+ *
20
+ * Returns true when the message was handled as an abort, false
21
+ * otherwise. Caller short-circuits on true.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ function createHandleAbort({
27
+ pm,
28
+ bot,
29
+ tg,
30
+ logEvent,
31
+ isAbortRequest,
32
+ markSessionAborted,
33
+ clearAutosteeredReactions,
34
+ getSessionKey,
35
+ botName,
36
+ logger = console,
37
+ } = {}) {
38
+
39
+ return async function handleAbortIfRequested(msg, chatId, chatConfig, cleanText) {
40
+ if (!isAbortRequest(cleanText)) return false;
41
+
42
+ const threadId = msg.message_thread_id?.toString();
43
+ const sessionKey = getSessionKey(chatId, threadId, chatConfig);
44
+ const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
45
+
46
+ // Mark BEFORE killing: the 'close' event fires almost immediately
47
+ // after interrupt, and the surrounding handleMessage's catch
48
+ // needs to see the flag to skip the generic error-reply.
49
+ if (hadActive) markSessionAborted(sessionKey);
50
+
51
+ // SDK abort: interrupt() + drainQueue(). interrupt() cancels
52
+ // the in-flight turn at SDK level WITHOUT tearing down the
53
+ // Query (cheap to reuse for the user's next message);
54
+ // drainQueue() rejects every queued pending with
55
+ // err.code='INTERRUPTED' so the abort-grace classifier
56
+ // suppresses error replies.
57
+ await pm.interrupt(sessionKey).catch((err) =>
58
+ logger.error?.(`[${botName}] interrupt failed: ${err.message}`));
59
+ pm.drainQueue(sessionKey, 'INTERRUPTED');
60
+
61
+ clearAutosteeredReactions(sessionKey).catch(() => {});
62
+ logEvent('abort-requested', {
63
+ chat_id: chatId, user_id: msg.from?.id || null,
64
+ had_active: hadActive,
65
+ trigger: cleanText.slice(0, 40),
66
+ });
67
+
68
+ // Reply in the same language the user aborted in. Cyrillic-
69
+ // detection is crude but reliable for ru/en.
70
+ const lang = /[а-яё]/i.test(cleanText) ? 'ru' : 'en';
71
+ const strs = {
72
+ en: { stopped: 'Stopped.', nothing: 'Nothing to stop.' },
73
+ ru: { stopped: 'Остановлено.', nothing: 'Нечего останавливать.' },
74
+ }[lang];
75
+ const reply = hadActive ? strs.stopped : strs.nothing;
76
+ try {
77
+ await tg(bot, 'sendMessage', {
78
+ chat_id: chatId, text: reply,
79
+ reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
80
+ ...(threadId && { message_thread_id: threadId }),
81
+ }, { source: 'abort-ack', botName });
82
+ } catch (err) {
83
+ logger.error?.(`[${botName}] abort-ack send failed: ${err.message}`);
84
+ }
85
+ return true;
86
+ };
87
+ }
88
+
89
+ module.exports = { createHandleAbort };