@quecue/dev-notify 0.1.0-alpha.1 → 0.1.0-alpha.3

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.
@@ -0,0 +1,199 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { ConfigError } from './config.js';
3
+ import { digest, targetEpoch, stateTransaction, TTL } from './reply-state.js';
4
+ import { handleInteractionCallback, handleInteractionText, showInteraction } from './reply-interactions.js';
5
+ import { injectReply, sanitizeReply, validateTarget } from './reply-platform.js';
6
+ import { replyHint } from './reply-telegram.js';
7
+ export const sessionEpoch = targetEpoch;
8
+ export const textHash = (text) => digest(text.replace(/\s+/g, ' ').trim());
9
+ function privateUser(message, sender) {
10
+ const chat = message?.chat;
11
+ if (chat?.type !== 'private' || !Number.isSafeInteger(chat.id) || chat.id <= 0
12
+ || !Number.isSafeInteger(sender?.id) || sender.id !== chat.id || sender.is_bot === true)
13
+ return null;
14
+ return String(chat.id);
15
+ }
16
+ export async function resolveEntry(access, key, context) {
17
+ const resolved = await stateTransaction(access, state => {
18
+ const entry = state.entries[key], target = entry && state.sessions[entry.session];
19
+ return entry && target && (target.tracked === true || !!entry.request) && entry.epoch === sessionEpoch(target) && Date.now() - entry.updated < TTL ? { entry, target } : null;
20
+ });
21
+ if (!resolved || !await (context.validate ?? (target => validateTarget(target)))(resolved.target))
22
+ return null;
23
+ return resolved;
24
+ }
25
+ async function prompt(access, chat, messageId, key, context) {
26
+ const resolved = await resolveEntry(access, key, context);
27
+ if (!resolved) {
28
+ await replyHint(context.api, chat, '⚠️ 无法回复', '该会话已关闭、切换或通知过期,请从最新通知进入。', messageId);
29
+ return;
30
+ }
31
+ if (resolved.entry.request) {
32
+ await showInteraction(access, resolved.entry.request, chat, context.api);
33
+ return;
34
+ }
35
+ const sent = await replyHint(context.api, chat, '💬 回复 Claude', '请回复此消息输入内容。该输入将提交到对应本机会话。\n会话:' + (resolved.target.title || resolved.target.session), messageId, { force_reply: true, selective: true });
36
+ if (Number.isSafeInteger(sent?.message_id))
37
+ await stateTransaction(access, state => { state.entries[`tg:${chat}:${sent.message_id}`] = resolved.entry; });
38
+ }
39
+ export async function processUpdate(access, update, context) {
40
+ try {
41
+ await processAuthorizedUpdate(access, update, context);
42
+ }
43
+ catch (error) {
44
+ const message = update?.callback_query?.message ?? update?.message;
45
+ const sender = update?.callback_query?.from ?? update?.message?.from;
46
+ const chat = privateUser(message, sender);
47
+ const current = await context.fresh().catch(() => null);
48
+ if (chat && current?.identity === access.identity && current.policy.users.includes(chat)) {
49
+ if (typeof update?.callback_query?.id === 'string')
50
+ await context.api('answerCallbackQuery', {
51
+ callback_query_id: update.callback_query.id, text: '处理失败或结果未知,请检查终端;不会自动重复提交',
52
+ }).catch(() => { });
53
+ await replyHint(context.api, chat, '⚠️ 本次提交结果无法确认', '处理发生异常,无法确认是否已登记或采用。请检查终端及后续结果反馈;不会自动重复提交。').catch(() => { });
54
+ }
55
+ throw error;
56
+ }
57
+ }
58
+ async function processAuthorizedUpdate(access, update, context) {
59
+ if (!Number.isSafeInteger(update?.update_id) || update.update_id < 0)
60
+ return;
61
+ const current = await context.fresh();
62
+ if (!current || current.identity !== access.identity)
63
+ return;
64
+ // 先提交 offset;强杀可能丢失本条,但不能重放已授权的终端输入。
65
+ const claimed = await stateTransaction(access, state => {
66
+ if (update.update_id < state.offset)
67
+ return false;
68
+ state.offset = update.update_id + 1;
69
+ return true;
70
+ });
71
+ if (!claimed)
72
+ return;
73
+ if (update.callback_query) {
74
+ const callback = update.callback_query, chat = privateUser(callback.message, callback.from);
75
+ if (!chat || !current.policy.users.includes(chat))
76
+ return;
77
+ const value = callback.data;
78
+ if (typeof value === 'string' && value.startsWith('dn:q:')) {
79
+ const id = value.split(':')[2];
80
+ if (!/^[a-f0-9]{32}$/.test(id) || !await resolveEntry(access, 'token:' + id, context)) {
81
+ await context.api('answerCallbackQuery', { callback_query_id: callback.id, text: '会话已关闭或交互已失效' }).catch(() => { });
82
+ await replyHint(context.api, chat, '⚠️ 本次提交未生效', '会话已关闭、切换或交互已失效;未回传答案。').catch(() => { });
83
+ return;
84
+ }
85
+ if (await handleInteractionCallback(access, callback, chat, context.api))
86
+ return;
87
+ }
88
+ if (typeof value !== 'string' || !/^dn:r:[a-f0-9]{32}$/.test(value) || typeof callback.id !== 'string')
89
+ return;
90
+ await context.api('answerCallbackQuery', { callback_query_id: callback.id, text: '请在回复框输入内容' });
91
+ await prompt(access, chat, callback.message.message_id, 'token:' + value.slice(5), context);
92
+ return;
93
+ }
94
+ const message = update.message, chat = privateUser(message, message?.from);
95
+ if (!chat || typeof message.text !== 'string' || !Number.isSafeInteger(message.message_id))
96
+ return;
97
+ const text = message.text.trim();
98
+ if (text === '/chatid') {
99
+ await replyHint(context.api, chat, 'Telegram chat_id', chat, message.message_id);
100
+ return;
101
+ }
102
+ if (!current.policy.users.includes(chat))
103
+ return;
104
+ if (typeof message.date === 'number' && Date.now() / 1000 - message.date > 300) {
105
+ await replyHint(context.api, chat, '⚠️ 本次提交未生效', '这条输入消息已超过有效时间,未回传;请查看最新问题或终端状态。');
106
+ return;
107
+ }
108
+ if (/^\/start\s+[a-f0-9]{32}$/.test(text)) {
109
+ await prompt(access, chat, message.message_id, 'token:' + text.split(/\s+/)[1], context);
110
+ return;
111
+ }
112
+ const replyTo = message.reply_to_message?.message_id;
113
+ if (!Number.isSafeInteger(replyTo)) {
114
+ await replyHint(context.api, chat, '💬 回复方式', '请直接回复任务完成通知,或点击通知里的「回复 Claude」按钮。', message.message_id);
115
+ return;
116
+ }
117
+ const resolved = await resolveEntry(access, `tg:${chat}:${replyTo}`, context);
118
+ if (!resolved) {
119
+ await replyHint(context.api, chat, '⚠️ 无法回复', '通知已过期或对应会话已关闭、切换,请从最新通知回复。', message.message_id);
120
+ return;
121
+ }
122
+ if (resolved.entry.request) {
123
+ await handleInteractionText(access, resolved.entry.request, chat, message, context.api);
124
+ return;
125
+ }
126
+ let safe;
127
+ try {
128
+ safe = sanitizeReply(message.text);
129
+ }
130
+ catch (error) {
131
+ await replyHint(context.api, chat, '❌ 未提交回复', error instanceof ConfigError ? error.message : '输入无效', message.message_id);
132
+ return;
133
+ }
134
+ const id = randomBytes(16).toString('hex');
135
+ const queued = await stateTransaction(access, state => {
136
+ const target = state.sessions[resolved.target.session];
137
+ if (!target || sessionEpoch(target) !== resolved.entry.epoch)
138
+ throw new ConfigError('会话已变化,未提交');
139
+ if (state.pending[target.session] && Date.now() - state.pending[target.session].updated < 5 * 60 * 1000)
140
+ return null;
141
+ state.pending[target.session] = { hash: textHash(safe), text: safe, epoch: resolved.entry.epoch, id, updated: Date.now(), queued: target.working,
142
+ phase: 'queued', chat, message: message.message_id };
143
+ return target.working;
144
+ });
145
+ if (queued === null) {
146
+ await replyHint(context.api, chat, '⏳ 未提交新回复', '该会话已有未确认回复,请等待收到确认或检查本机终端后再试。', message.message_id);
147
+ return;
148
+ }
149
+ if (queued)
150
+ await replyHint(context.api, chat, '⏳ 回复已排队', 'Claude 正在处理当前任务;本地队列将在本轮结束后尝试提交,不会把文本输入到正在等待权限的弹窗。', message.message_id);
151
+ else
152
+ await drainReplies(access, context);
153
+ }
154
+ export async function drainReplies(access, context) {
155
+ const queued = await stateTransaction(access, state => Object.entries(state.pending)
156
+ .filter(([session, value]) => value.phase === 'queued' && (!state.sessions[session]?.working || Date.now() - value.updated > 5 * 60 * 1000))
157
+ .map(([session, pending]) => ({ target: state.sessions[session], pending })));
158
+ for (const item of queued) {
159
+ const { target, pending } = item;
160
+ if (!target || !pending.text)
161
+ continue;
162
+ try {
163
+ const current = await context.fresh();
164
+ if (!current || current.identity !== access.identity || !current.policy.users.includes(pending.chat))
165
+ return;
166
+ if (Date.now() - pending.updated > 5 * 60 * 1000)
167
+ throw new ConfigError('排队回复已超过 5 分钟,未提交;请检查本机后重新回复');
168
+ if (!target.tracked || pending.epoch !== sessionEpoch(target) || !await (context.validate ?? (value => validateTarget(value)))(target))
169
+ throw new ConfigError('会话已关闭或切换,未提交');
170
+ const text = pending.text;
171
+ const claimed = await stateTransaction(access, state => {
172
+ const actual = state.pending[target.session], active = state.sessions[target.session];
173
+ if (!actual || actual.id !== pending.id || actual.phase !== 'queued' || active?.working || sessionEpoch(active) !== sessionEpoch(target))
174
+ return false;
175
+ actual.phase = 'submitted';
176
+ delete actual.text;
177
+ return true;
178
+ });
179
+ if (!claimed)
180
+ continue;
181
+ // 输入前再次核验授权,撤销后不发送。原生调用一旦开始,不能撤回或保证无竞态。
182
+ const final = await context.fresh();
183
+ if (!final || final.identity !== access.identity)
184
+ return;
185
+ await (context.inject ?? ((value, content) => injectReply(value, content)))(target, text);
186
+ await replyHint(context.api, pending.chat, '✅ 已转发给 Claude', '终端输入已提交,等待 UserPromptSubmit 确认;这不等于 Claude 已开始处理。', pending.message);
187
+ }
188
+ catch (error) {
189
+ await stateTransaction(access, state => {
190
+ const value = state.pending[target.session];
191
+ if (value?.id === pending.id) {
192
+ value.phase = 'unknown';
193
+ delete value.text;
194
+ }
195
+ });
196
+ await replyHint(context.api, pending.chat, '⚠️ 转发未确认', error instanceof ConfigError ? error.message : '提交失败或结果未知,请检查本机终端;不会自动重复提交。', pending.message).catch(() => { });
197
+ }
198
+ }
199
+ }
@@ -0,0 +1,230 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { ConfigError } from './config.js';
3
+ import { accessReplies, botKey, digest, stateTransaction, targetEpoch } from './reply-state.js';
4
+ import { findSession } from './reply-platform.js';
5
+ import { textHash } from './reply-engine.js';
6
+ import { telegramApi, replyHint } from './reply-telegram.js';
7
+ import { bridgeStatus, startBridge } from './reply-bridge.js';
8
+ import { createInteraction, showInteraction, waitInteraction } from './reply-interactions.js';
9
+ import { makePlan, deliver } from './channels.js';
10
+ import { completeToolCall, endSessionInteractions, finishInteraction, matchingCall, registerToolCall } from './reply-outcomes.js';
11
+ const apiFor = (access, options) => (options.api ?? (value => telegramApi(value.config.telegram.token)))(access);
12
+ async function getTarget(data, options) {
13
+ return (options.session ?? (id => findSession(id, { env: options.env })))(data.session_id);
14
+ }
15
+ export async function lifecycleHook(data, file, options = {}) {
16
+ const event = data.hook_event_name;
17
+ if (!['SessionStart', 'UserPromptSubmit', 'SessionEnd', 'Stop', 'Notification'].includes(event) || typeof data.session_id !== 'string')
18
+ return;
19
+ const access = await accessReplies(file, options.env);
20
+ if (!access)
21
+ return;
22
+ if (event === 'Stop' && data.stop_hook_active === true)
23
+ return;
24
+ const target = event === 'SessionEnd' ? null : await getTarget(data, options);
25
+ const receipt = await stateTransaction(access, state => {
26
+ const previous = state.sessions[data.session_id];
27
+ if (event === 'SessionEnd' || !target) {
28
+ delete state.sessions[data.session_id];
29
+ delete state.pending[data.session_id];
30
+ endSessionInteractions(state, data.session_id, '会话已结束或无法核验');
31
+ return null;
32
+ }
33
+ // 同进程 / 同 TTY 已切换会话时,旧映射不再允许输入。
34
+ for (const [id, other] of Object.entries(state.sessions)) {
35
+ if (id !== target.session && (other.pid === target.pid || (target.tty && other.tty === target.tty))) {
36
+ endSessionInteractions(state, id, '会话已切换');
37
+ delete state.sessions[id];
38
+ delete state.pending[id];
39
+ }
40
+ }
41
+ const pending = state.pending[target.session];
42
+ const match = event === 'UserPromptSubmit' && typeof data.prompt === 'string' && pending?.phase === 'submitted'
43
+ && pending.epoch === targetEpoch(target) && pending.hash === textHash(data.prompt) && Date.now() - pending.updated < 5 * 60 * 1000;
44
+ if (match || (pending && pending.epoch !== targetEpoch(target)))
45
+ delete state.pending[target.session];
46
+ const working = event !== 'Stop';
47
+ const tracked = event === 'UserPromptSubmit' || (event !== 'SessionStart' && previous?.tracked === true && targetEpoch(previous) === targetEpoch(target));
48
+ const latestCall = event !== 'SessionStart' && event !== 'UserPromptSubmit' && previous && targetEpoch(previous) === targetEpoch(target)
49
+ ? previous.latestCall : undefined;
50
+ state.sessions[target.session] = { ...target, working, tracked, latestCall, updated: Date.now() };
51
+ // 本地用户提交新任务时,尚未注入的排队回复不能抢在该任务前输入。
52
+ if (event === 'UserPromptSubmit' && !match && pending?.phase === 'queued')
53
+ delete state.pending[target.session];
54
+ if (event === 'UserPromptSubmit' || event === 'SessionStart' || event === 'Stop') {
55
+ endSessionInteractions(state, target.session, event === 'Stop' ? '本轮已结束' : '本机已开始新任务或新会话');
56
+ }
57
+ return match ? pending : null;
58
+ });
59
+ if (receipt) {
60
+ await replyHint(apiFor(access, options), receipt.chat, '✅ Claude 已收到回复', 'UserPromptSubmit 已匹配本次远程输入;这仅确认收到,不代表任务已经完成。', receipt.message).catch(() => options.err?.('Telegram: 收到确认发送失败;不会重放输入。'));
61
+ if (access.config.feishu.enabled && access.config.feishu.webhook) {
62
+ try {
63
+ const result = await (options.send ?? deliver)(makePlan('feishu', { title: 'Claude 已收到回复', level: 'info',
64
+ content: 'UserPromptSubmit 已匹配远程输入;仅确认收到,不代表任务完成。为减少扩散,不转发回复原文。',
65
+ footer: '会话:' + data.session_id }, access.config), access.config);
66
+ if (!result.ok)
67
+ options.err?.('feishu: 收到确认未完整发送;不会重放输入。');
68
+ }
69
+ catch {
70
+ options.err?.('feishu: 收到确认发送失败;不会重放输入。');
71
+ }
72
+ }
73
+ }
74
+ }
75
+ const feishuTarget = (config) => digest(JSON.stringify([config.feishu.webhook, config.feishu.secret]));
76
+ export async function notificationCoverage(data, file, config, options = {}) {
77
+ const none = { telegram: [], feishu: false };
78
+ if (data.hook_event_name !== 'Notification' || data.notification_type !== 'permission_prompt' || typeof data.session_id !== 'string')
79
+ return none;
80
+ const access = await accessReplies(file, options.env);
81
+ if (!access || botKey(config) !== access.policy.bot)
82
+ return none;
83
+ return stateTransaction(access, state => {
84
+ const target = state.sessions[data.session_id];
85
+ const call = target?.latestCall && state.calls[target.latestCall];
86
+ if (!target || !call || call.session !== target.session || call.epoch !== targetEpoch(target) || call.tool !== 'AskUserQuestion'
87
+ || typeof data.tool_use_id === 'string' && data.tool_use_id !== call.id
88
+ || typeof data.tool_name === 'string' && data.tool_name !== call.tool
89
+ || typeof data.prompt_id === 'string' && data.prompt_id !== call.prompt)
90
+ return none;
91
+ const request = call.request && state.requests[call.request];
92
+ if (!request || request.kind !== 'question' || request.session !== target.session || request.epoch !== call.epoch || request.expires <= Date.now())
93
+ return none;
94
+ // Notification 通常没有调用 ID;仅覆盖当前最后一项调用,存在其他并行调用时保守保留提醒。
95
+ if (Object.entries(state.calls).some(([key, other]) => key !== target.latestCall && other.session === target.session && other.epoch === call.epoch && !other.ended))
96
+ return none;
97
+ return { telegram: (request.notified?.telegram ?? []).filter(user => access.policy.users.includes(user)),
98
+ feishu: request.notified?.feishu === feishuTarget(config) };
99
+ });
100
+ }
101
+ export async function attachReply(message, data, file, options = {}) {
102
+ const access = await accessReplies(file, options.env);
103
+ if (!access || data.hook_event_name !== 'Stop')
104
+ return null;
105
+ const target = await getTarget(data, options);
106
+ if (!target)
107
+ return null;
108
+ const saved = await stateTransaction(access, state => state.sessions[target.session]);
109
+ if (!saved || saved.working || !saved.tracked || targetEpoch(saved) !== targetEpoch(target))
110
+ return null;
111
+ const ready = options.bridge ? await options.bridge(file, options.env ?? process.env)
112
+ : (await bridgeStatus(file)).ready || await startBridge(file, options.env);
113
+ if (!ready) {
114
+ options.err?.('远程回复桥接未就绪,本次通知不展示回复按钮;可运行 reply status 检查。');
115
+ return null;
116
+ }
117
+ const api = apiFor(access, options), bot = await api('getMe', {});
118
+ if (typeof bot?.username !== 'string' || !/^[A-Za-z0-9_]{5,64}$/.test(bot.username))
119
+ throw new ConfigError('Telegram Bot 用户名无效,未启用回复按钮');
120
+ const token = randomBytes(16).toString('hex');
121
+ await stateTransaction(access, state => { state.entries['token:' + token] = { session: target.session, epoch: targetEpoch(target), updated: Date.now() }; });
122
+ message.reply = { token, url: `https://t.me/${bot.username}?start=${token}`, users: access.policy.users };
123
+ return { access, token };
124
+ }
125
+ export function mapReplies(plan, binding) {
126
+ if (plan.channel !== 'telegram')
127
+ return;
128
+ plan.onSent = async (item, response) => {
129
+ if (!binding.access.policy.users.includes(item.recipient))
130
+ return;
131
+ if (!Number.isSafeInteger(response.result?.message_id))
132
+ throw new ConfigError('通知未返回可映射的 Telegram 消息 ID');
133
+ await stateTransaction(binding.access, state => {
134
+ const entry = state.entries['token:' + binding.token];
135
+ if (entry)
136
+ state.entries[`tg:${item.recipient}:${response.result.message_id}`] = entry;
137
+ });
138
+ };
139
+ }
140
+ export async function interactionHook(data, file, options = {}) {
141
+ if (!['PreToolUse', 'PermissionRequest', 'PostToolUse', 'PostToolUseFailure', 'PermissionDenied'].includes(data.hook_event_name))
142
+ return;
143
+ const access = await accessReplies(file, options.env);
144
+ if (!access)
145
+ return;
146
+ if (['PostToolUse', 'PostToolUseFailure', 'PermissionDenied'].includes(data.hook_event_name)) {
147
+ await completeToolCall(access, data);
148
+ return;
149
+ }
150
+ if (!['PreToolUse', 'PermissionRequest'].includes(data.hook_event_name))
151
+ return;
152
+ const kind = data.tool_name === 'AskUserQuestion' ? 'question' : access.policy.permissions === true ? 'permission' : null;
153
+ if (typeof data.session_id !== 'string' || data.hook_event_name === 'PermissionRequest' && !kind)
154
+ return;
155
+ const cached = data.hook_event_name === 'PreToolUse'
156
+ ? await stateTransaction(access, state => state.sessions[data.session_id]) : null;
157
+ // 登记阶段复用生命周期元数据;真正开启远程交互时仍重新核验进程身份。
158
+ const target = cached ?? await getTarget(data, options);
159
+ if (!target) {
160
+ options.err?.('无法核验会话,结构化交互保留本机处理。');
161
+ return;
162
+ }
163
+ if (data.hook_event_name === 'PreToolUse') {
164
+ // 只登记调用身份,不发网络请求、不等待远端,原生窗口可以正常出现。
165
+ await registerToolCall(access, target, data);
166
+ return;
167
+ }
168
+ if (!kind)
169
+ return;
170
+ const call = await stateTransaction(access, state => matchingCall(state, target, data));
171
+ if (!call) {
172
+ options.err?.('无法唯一关联工具调用,远程交互未启用;本机窗口保持正常。');
173
+ return;
174
+ }
175
+ const ready = options.bridge ? await options.bridge(file, options.env ?? process.env)
176
+ : (await bridgeStatus(file)).ready || await startBridge(file, options.env);
177
+ if (!ready) {
178
+ options.err?.('回复桥接未就绪,问题保留本机处理。');
179
+ return;
180
+ }
181
+ const api = apiFor(access, options);
182
+ const request = await createInteraction(access, target, kind, kind === 'question' ? data.tool_input : { tool_name: data.tool_name, tool_input: data.tool_input }, call);
183
+ const controller = new AbortController();
184
+ const cancel = () => controller.abort();
185
+ process.on('SIGTERM', cancel);
186
+ process.on('SIGINT', cancel);
187
+ try {
188
+ for (const chat of access.policy.users)
189
+ await showInteraction(access, request.id, chat, api);
190
+ if (access.config.feishu.enabled && access.config.feishu.webhook) {
191
+ const bot = await api('getMe', {});
192
+ if (typeof bot?.username === 'string' && /^[A-Za-z0-9_]{5,64}$/.test(bot.username)) {
193
+ const result = await (options.send ?? deliver)(makePlan('feishu', { title: 'Claude Code 需要你处理', level: 'warn', footer: '',
194
+ content: kind === 'question' ? request.questions.map(q => q.question + '\n' + q.options.map(o => o.label + ':' + o.description).join('\n')).join('\n\n') : '工具权限请求:' + data.tool_name,
195
+ reply: { token: request.id, url: `https://t.me/${bot.username}?start=${request.id}`, users: access.policy.users } }, access.config), access.config);
196
+ if (result.ok)
197
+ await stateTransaction(access, state => {
198
+ const current = state.requests[request.id];
199
+ if (current) {
200
+ current.notified ??= {};
201
+ current.notified.feishu = feishuTarget(access.config);
202
+ }
203
+ });
204
+ }
205
+ }
206
+ const response = await (options.wait ?? waitInteraction)(access, request, () => accessReplies(file, options.env), { signal: controller.signal });
207
+ if (response && !controller.signal.aborted)
208
+ options.response?.(response);
209
+ else
210
+ options.err?.('远程交互已结束或未提交;不会默认作答,本机操作不受影响。');
211
+ }
212
+ catch (error) {
213
+ await stateTransaction(access, state => {
214
+ const current = state.requests[request.id];
215
+ if (current)
216
+ finishInteraction(current, { kind: current.claimed ? 'unknown' : 'not-applied', text: '远程交互处理失败;请检查本机,不会自动重复提交。' });
217
+ }).catch(() => { });
218
+ throw error;
219
+ }
220
+ finally {
221
+ process.off('SIGTERM', cancel);
222
+ process.off('SIGINT', cancel);
223
+ if (controller.signal.aborted)
224
+ await stateTransaction(access, state => {
225
+ const current = state.requests[request.id];
226
+ if (current)
227
+ finishInteraction(current, { kind: current.claimed ? 'unknown' : 'not-applied', text: '交互等待已取消,远程答案未确认生效。' });
228
+ }).catch(() => { });
229
+ }
230
+ }