polygram 0.8.0 → 0.9.0-rc.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/lib/{agent-loader.js → agents/loader.js} +6 -8
- package/lib/{approvals.js → approvals/store.js} +28 -5
- package/lib/{approval-ui.js → approvals/ui.js} +1 -17
- package/lib/config.js +121 -0
- package/lib/{error-classify.js → error/classify.js} +25 -34
- package/lib/handlers/abort.js +89 -0
- package/lib/handlers/approvals.js +361 -0
- package/lib/handlers/autosteer.js +94 -0
- package/lib/handlers/config-callback.js +118 -0
- package/lib/handlers/config-ui.js +104 -0
- package/lib/handlers/dispatcher.js +263 -0
- package/lib/handlers/download.js +182 -0
- package/lib/handlers/extract-attachments.js +97 -0
- package/lib/handlers/ipc-send.js +80 -0
- package/lib/handlers/poll.js +140 -0
- package/lib/handlers/record-inbound.js +88 -0
- package/lib/handlers/slash-commands.js +319 -0
- package/lib/handlers/voice.js +107 -0
- package/lib/pm-interface.js +27 -29
- package/lib/sdk/build-options.js +177 -0
- package/lib/sdk/callbacks.js +213 -0
- package/lib/{process-manager-sdk.js → sdk/process-manager.js} +19 -31
- package/lib/{telegram.js → telegram/api.js} +2 -2
- package/lib/{telegram-prompt.js → telegram/display-hint.js} +0 -14
- package/lib/{stream-reply.js → telegram/streamer.js} +4 -4
- package/package.json +2 -3
- package/polygram.js +347 -2581
- package/scripts/doctor.js +1 -1
- package/scripts/ipc-smoke.js +1 -10
- package/bin/approval-hook.js +0 -113
- package/lib/approval-waiters.js +0 -201
- package/lib/pm-router.js +0 -201
- package/lib/process-manager.js +0 -806
- /package/lib/{auto-resume.js → db/auto-resume.js} +0 -0
- /package/lib/{inbox.js → db/inbox.js} +0 -0
- /package/lib/{pairings.js → db/pairings.js} +0 -0
- /package/lib/{replay-window.js → db/replay-window.js} +0 -0
- /package/lib/{sent-cache.js → db/sent-cache.js} +0 -0
- /package/lib/{sessions.js → db/sessions.js} +0 -0
- /package/lib/{net-errors.js → error/net.js} +0 -0
- /package/lib/{ipc-client.js → ipc/client.js} +0 -0
- /package/lib/{ipc-file-validator.js → ipc/file-validator.js} +0 -0
- /package/lib/{ipc-server.js → ipc/server.js} +0 -0
- /package/lib/{telegram-chunk.js → telegram/chunk.js} +0 -0
- /package/lib/{deliver.js → telegram/deliver.js} +0 -0
- /package/lib/{telegram-format.js → telegram/format.js} +0 -0
- /package/lib/{parse-response.js → telegram/parse.js} +0 -0
- /package/lib/{status-reactions.js → telegram/reactions.js} +0 -0
- /package/lib/{typing-indicator.js → telegram/typing.js} +0 -0
- /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.
|
|
4
|
+
"version": "0.9.0-rc.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 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
|
|
3
|
-
* v4 plan §6.5.5).
|
|
2
|
+
* Per-chat agent loader.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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('
|
|
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,
|
|
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 =
|
|
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
|
-
* -
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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.
|
|
108
|
-
//
|
|
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
|
|
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
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
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,
|
|
128
|
+
userMessage: null,
|
|
138
129
|
isTransient: false,
|
|
139
130
|
autoRecover: null,
|
|
140
131
|
},
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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 };
|