@quecue/dev-notify 0.1.0-alpha.1 → 0.1.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.
@@ -0,0 +1,269 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { ConfigError } from './config.js';
4
+ import { stateTransaction, targetEpoch } from './reply-state.js';
5
+ import { replyHint } from './reply-telegram.js';
6
+ import { finishInteraction } from './reply-outcomes.js';
7
+ export const INTERACTION_MS = 5 * 60 * 1000;
8
+ export function questionsFrom(input) {
9
+ if (!Array.isArray(input.questions) || input.questions.length < 1 || input.questions.length > 4)
10
+ throw new ConfigError('问题结构无效,请在本机回答。');
11
+ const questions = input.questions;
12
+ const seen = new Set();
13
+ for (const q of questions) {
14
+ if (!q || typeof q.question !== 'string' || !q.question.trim() || seen.has(q.question)
15
+ || typeof q.header !== 'string' || (q.multiSelect !== undefined && typeof q.multiSelect !== 'boolean') || !Array.isArray(q.options)
16
+ || q.options.length < 2 || q.options.length > 4 || q.options.some(o => !o || typeof o.label !== 'string' || !o.label.trim()
17
+ || typeof o.description !== 'string') || new Set(q.options.map(o => o.label)).size !== q.options.length) {
18
+ throw new ConfigError('问题结构无效,请在本机回答。');
19
+ }
20
+ seen.add(q.question);
21
+ }
22
+ return structuredClone(questions);
23
+ }
24
+ export async function createInteraction(access, target, kind, input, call) {
25
+ if (kind === 'permission' && access.policy.permissions !== true)
26
+ throw new ConfigError('未授权远程处理工具权限。');
27
+ const questions = kind === 'question' ? questionsFrom(input) : [];
28
+ const value = { id: randomBytes(16).toString('hex'), session: target.session, epoch: targetEpoch(target),
29
+ updated: Date.now(), expires: Date.now() + INTERACTION_MS, kind, input: structuredClone(input), questions,
30
+ answers: {}, selected: [], index: 0, call, receipts: [], messages: [] };
31
+ await stateTransaction(access, state => {
32
+ const current = state.sessions[target.session];
33
+ if (!current || targetEpoch(current) !== value.epoch)
34
+ throw new ConfigError('会话目标发生了变化。');
35
+ if (call) {
36
+ const bound = state.calls[call];
37
+ if (!bound || bound.ended || bound.request || bound.epoch !== value.epoch)
38
+ throw new ConfigError('调用已经结束或已绑定,没有创建远程问题。');
39
+ bound.request = value.id;
40
+ }
41
+ // 不同 tool_use_id 可并行;无绑定的单元级调用仍只保留一个活动交互。
42
+ if (!call)
43
+ for (const old of Object.values(state.requests))
44
+ if (old.session === target.session && !old.result) {
45
+ finishInteraction(old, { kind: old.claimed ? 'unknown' : 'not-applied', text: '问题已切换,旧请求不再接受答案。' });
46
+ }
47
+ state.requests[value.id] = value;
48
+ state.entries['token:' + value.id] = { session: target.session, epoch: value.epoch, request: value.id, updated: Date.now() };
49
+ });
50
+ return value;
51
+ }
52
+ function active(value) {
53
+ return !!value && !value.result && !value.response && !value.claimed && value.expires > Date.now();
54
+ }
55
+ export function interactionMarkup(value) {
56
+ const prefix = `dn:q:${value.id}:${value.index}:`;
57
+ const buttons = [];
58
+ if (value.kind === 'permission') {
59
+ buttons.push([{ text: '✅ 本次允许', callback_data: prefix + 'a' }, { text: '⛔ 拒绝', callback_data: prefix + 'd' }]);
60
+ buttons.push([{ text: '✍️ 自定义拒绝说明', callback_data: prefix + 'c' }]);
61
+ }
62
+ else {
63
+ const q = value.questions[value.index];
64
+ q.options.forEach((option, index) => buttons.push([{ text: (value.selected.includes(index) ? '☑ ' : '') + Array.from(option.label).slice(0, 60).join(''), callback_data: prefix + 'o' + index }]));
65
+ if (q.multiSelect)
66
+ buttons.push([{ text: '提交所选项', callback_data: prefix + 's' }]);
67
+ buttons.push([{ text: '✍️ 自定义输入', callback_data: prefix + 'c' }]);
68
+ }
69
+ buttons.push([{ text: '关闭远程答题(本机仍可操作)', callback_data: prefix + 'l' }]);
70
+ return { inline_keyboard: buttons };
71
+ }
72
+ export function interactionContent(value) {
73
+ if (value.kind === 'permission')
74
+ return '工具:' + String(value.input.tool_name || '未知') + '\n\n' + JSON.stringify(value.input.tool_input ?? {}, null, 2)
75
+ + '\n\n这里处理本次的允许、拒绝或自定义拒绝说明。Claude 没有提供完整的本地按钮列表,因此这里也不会创建永久权限规则。';
76
+ const q = value.questions[value.index];
77
+ return `问题 ${value.index + 1}/${value.questions.length}:${q.question}\n\n`
78
+ + q.options.map((o, i) => `${i + 1}. ${o.label}\n${o.description}${o.preview ? '\n' + o.preview : ''}`).join('\n\n')
79
+ + (q.multiSelect ? '\n\n可多选,选好后点击「提交所选项」;也可自定义输入。' : '\n\n点击选项直接回答,或选择自定义输入。')
80
+ + '\n本机原生窗口也可以同时作答;Claude 先收到的完整答案生效。远程窗口有效期为 5 分钟,不会自动选择。';
81
+ }
82
+ // 飞书卡片没有回调按钮,只展示全部题目和跳转入口;逐题编号并空行分隔,避免题目与选项粘连。
83
+ export function questionsContent(questions) {
84
+ return questions.map((q, index) => `问题 ${index + 1}/${questions.length}:${q.question}\n\n`
85
+ + q.options.map((o, i) => `${i + 1}. ${o.label}\n${o.description}${o.preview ? '\n' + o.preview : ''}`).join('\n\n')
86
+ + (q.multiSelect ? '\n\n可多选:进入 Telegram 后逐项选择,再点击「提交所选项」;也可自定义输入。'
87
+ : '\n\n进入 Telegram 后点击选项直接回答,或选择自定义输入。')).join('\n\n────────\n\n');
88
+ }
89
+ export async function showInteraction(access, id, chat, api) {
90
+ const value = await stateTransaction(access, state => state.requests[id]);
91
+ if (!active(value)) {
92
+ await replyHint(api, chat, '⚠️ 交互已失效', '请从最新通知进入,或回到本机处理。');
93
+ return;
94
+ }
95
+ // 使用通知分段器发送较长选项,所有选项正文保留,只有最后一段附选择按钮。
96
+ const { makePlan, deliver } = await import('./channels.js');
97
+ const { config } = access;
98
+ const plan = makePlan('telegram', { title: 'Claude Code 需要你处理', content: interactionContent(value), footer: '', level: 'warn' }, { ...config, telegram: { ...config.telegram, chatIds: [chat] } });
99
+ plan.items[plan.items.length - 1].payload.reply_markup = interactionMarkup(value);
100
+ plan.onSent = async (item, response) => {
101
+ const message = response.result?.message_id;
102
+ if (!Number.isSafeInteger(message))
103
+ throw new ConfigError('交互通知没有返回消息 ID。');
104
+ await stateTransaction(access, state => {
105
+ const current = state.requests[id];
106
+ if (current) {
107
+ state.entries[`tg:${chat}:${message}`] = { session: value.session, epoch: value.epoch, request: id, question: value.index, updated: Date.now() };
108
+ // 发送期间可能由本机先答;仍登记消息,随后移除过期按钮。
109
+ (current.messages ??= []).push({ chat, message });
110
+ }
111
+ });
112
+ };
113
+ // 完整分段及按钮投递成功后才覆盖通用提醒;重发失败时恢复该接收人的兜底提醒。
114
+ await stateTransaction(access, state => {
115
+ const current = state.requests[id];
116
+ if (current?.notified?.telegram)
117
+ current.notified.telegram = current.notified.telegram.filter(user => user !== chat);
118
+ });
119
+ // API 封装由测试或真实桥接注入,不另建网络连接。
120
+ const result = await deliver(plan, config, async (_url, payload) => ({ ok: true, result: await api('sendMessage', payload) }));
121
+ if (!result.ok)
122
+ throw new ConfigError('交互通知没有完整发送,请回到本机处理。');
123
+ await stateTransaction(access, state => {
124
+ const current = state.requests[id];
125
+ if (!current)
126
+ return;
127
+ current.notified ??= {};
128
+ const users = current.notified.telegram ??= [];
129
+ if (!users.includes(chat))
130
+ users.push(chat);
131
+ });
132
+ }
133
+ function answer(value, text) {
134
+ const q = value.questions[value.index];
135
+ Object.defineProperty(value.answers, q.question, { value: text, enumerable: true, writable: true, configurable: true });
136
+ value.selected = [];
137
+ value.index++;
138
+ if (value.index === value.questions.length)
139
+ value.response = {
140
+ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow', updatedInput: { ...value.input, questions: value.input.questions, answers: { ...value.answers } } } },
141
+ };
142
+ }
143
+ export async function handleInteractionCallback(access, callback, chat, api) {
144
+ if (typeof callback.data !== 'string' || !callback.data.startsWith('dn:q:'))
145
+ return false;
146
+ const match = callback.data.match(/^dn:q:([a-f0-9]{32}):(\d):([adcls]|o[0-3])$/);
147
+ if (!match || typeof callback.id !== 'string')
148
+ return true;
149
+ const [, id, index, operation] = match;
150
+ const outcome = await stateTransaction(access, state => {
151
+ const value = state.requests[id], target = value && state.sessions[value.session];
152
+ if (!active(value) || value.index !== Number(index) || !target || targetEpoch(target) !== value.epoch)
153
+ return 'expired';
154
+ if (operation === 'l') {
155
+ finishInteraction(value, { kind: 'not-applied', text: '已关闭远程答题,本机仍可正常处理。' });
156
+ return 'local';
157
+ }
158
+ const question = value.questions[value.index]?.question;
159
+ const previousIndex = value.index;
160
+ if (value.kind === 'permission') {
161
+ if (access.policy.permissions !== true)
162
+ return 'expired';
163
+ if (operation === 'a' || operation === 'd') {
164
+ value.response = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: operation === 'a' ? 'allow' : 'deny' } } };
165
+ }
166
+ else if (operation !== 'c')
167
+ return 'expired';
168
+ }
169
+ else {
170
+ const q = value.questions[value.index];
171
+ if (operation.startsWith('o')) {
172
+ const selected = Number(operation.slice(1));
173
+ if (!q.options[selected])
174
+ return 'expired';
175
+ if (q.multiSelect)
176
+ value.selected = value.selected.includes(selected) ? value.selected.filter(i => i !== selected) : [...value.selected, selected].sort();
177
+ else
178
+ answer(value, q.options[selected].label);
179
+ }
180
+ else if (operation === 's' && q.multiSelect && value.selected.length)
181
+ answer(value, value.selected.map(i => q.options[i].label).join(','));
182
+ else if (operation !== 'c')
183
+ return 'expired';
184
+ }
185
+ if (value.index !== previousIndex || value.response)
186
+ (value.receipts ??= []).push({ id: callback.id, chat,
187
+ message: callback.message.message_id, question, answer: value.kind === 'question' ? value.answers[question] : operation === 'a' ? 'allow' : 'deny' });
188
+ return operation === 'c' ? 'custom' : value.response ? 'done' : value.index !== previousIndex ? 'answered' : 'next';
189
+ });
190
+ await api('answerCallbackQuery', { callback_query_id: callback.id,
191
+ text: outcome === 'expired' ? '没有提交:按钮已失效,或已有答案等待确认。' : outcome === 'local' ? '已关闭远程答题,本机不受影响。' : outcome === 'done' || outcome === 'answered' ? '答案已登记,尚未确认生效。' : '已更新。' }).catch(() => { });
192
+ if (outcome === 'done' || outcome === 'answered')
193
+ await replyHint(api, chat, '⏳ 答案已登记,等待确认', (outcome === 'answered' ? '仍需完成其余问题,之后才回传整份答案。' : '已进入回传流程,Claude 是否采用会在稍后反馈。')
194
+ + '\n稍后会反馈结果;如果一直没有收到,请检查终端。\n请求:' + id.slice(0, 8)).catch(() => { });
195
+ if (outcome === 'expired')
196
+ await replyHint(api, chat, '⚠️ 本次提交未生效', '这次操作没有提交:问题已处理、过期、题目已切换,或已有答案等待确认。\n请求:' + id.slice(0, 8)).catch(() => { });
197
+ if (outcome === 'custom') {
198
+ const value = await stateTransaction(access, state => state.requests[id]);
199
+ if (!active(value))
200
+ return true;
201
+ const sent = await replyHint(api, chat, '✍️ 自定义输入', value.kind === 'permission' ? '请回复这条消息,填写拒绝说明。这段说明不会批准权限。' : '请回复这条消息,输入这个问题的完整答案。', callback.message.message_id, { force_reply: true, selective: true });
202
+ if (Number.isSafeInteger(sent?.message_id))
203
+ await stateTransaction(access, state => {
204
+ state.entries[`tg:${chat}:${sent.message_id}`] = { session: value.session, epoch: value.epoch, request: id, question: value.index, updated: Date.now() };
205
+ });
206
+ }
207
+ else if (outcome === 'next' || outcome === 'answered')
208
+ await showInteraction(access, id, chat, api);
209
+ return true;
210
+ }
211
+ export async function handleInteractionText(access, id, chat, message, api) {
212
+ const text = message.text;
213
+ if (!text.trim() || Array.from(text).length > 4000) {
214
+ await replyHint(api, chat, '❌ 答案没有提交', '请输入不超过 4000 字符的答案;内容不会被截断。', message.message_id);
215
+ return;
216
+ }
217
+ const outcome = await stateTransaction(access, state => {
218
+ const value = state.requests[id], target = value && state.sessions[value.session];
219
+ const entry = state.entries[`tg:${chat}:${message.reply_to_message.message_id}`];
220
+ if (!active(value) || !target || targetEpoch(target) !== value.epoch || entry?.question !== value.index)
221
+ return 'expired';
222
+ const question = value.questions[value.index]?.question;
223
+ if (value.kind === 'permission') {
224
+ if (access.policy.permissions !== true)
225
+ return 'expired';
226
+ value.response = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny', message: text } } };
227
+ }
228
+ else
229
+ answer(value, text);
230
+ (value.receipts ??= []).push({ id: `text:${chat}:${message.message_id}`, chat, message: message.message_id,
231
+ question, answer: value.kind === 'question' ? text : 'deny', custom: true });
232
+ return value.response ? 'done' : 'next';
233
+ });
234
+ await replyHint(api, chat, outcome === 'expired' ? '⚠️ 本次提交未生效' : '⏳ 答案已登记,等待确认', (outcome === 'done' ? '等待 Claude 确认,稍后会反馈最终结果;如果一直没有收到,请检查终端。'
235
+ : outcome === 'next' ? '仍需完成其余问题,之后才会回传整份答案;目前还没有生效。'
236
+ : '这次操作没有提交:对应问题已经结束、过期、切换,或已有答案等待确认。') + '\n请求:' + id.slice(0, 8));
237
+ if (outcome === 'next')
238
+ await showInteraction(access, id, chat, api);
239
+ }
240
+ export async function waitInteraction(access, value, fresh, options = {}) {
241
+ const end = Math.min(value.expires, Date.now() + (options.timeout ?? INTERACTION_MS));
242
+ while (Date.now() < end && !options.signal?.aborted) {
243
+ const current = await fresh();
244
+ if (!current || current.identity !== access.identity || (value.kind === 'permission' && current.policy.permissions !== true))
245
+ return null;
246
+ const response = await stateTransaction(access, state => {
247
+ const request = state.requests[value.id], target = state.sessions[value.session];
248
+ if (!request || request.result || !target || targetEpoch(target) !== value.epoch || (request.call && (!state.calls[request.call] || state.calls[request.call].ended)))
249
+ return { ended: true };
250
+ if (!request.response || request.claimed || options.signal?.aborted)
251
+ return null;
252
+ request.claimed = true;
253
+ // 回传后等待独立的完成事件确认;不能在这里删除请求或报告成功。
254
+ request.expires = Date.now() + INTERACTION_MS;
255
+ return { response: request.response };
256
+ });
257
+ if (response?.ended)
258
+ return null;
259
+ if (response?.response)
260
+ return response.response;
261
+ await (options.sleep ?? delay)(200);
262
+ }
263
+ await stateTransaction(access, state => {
264
+ const request = state.requests[value.id];
265
+ if (request && !request.result)
266
+ finishInteraction(request, { kind: request.claimed ? 'unknown' : 'not-applied', text: options.signal?.aborted ? '交互等待已取消,远程答案是否生效还没有确认。' : '远程交互等待超时,尚未确认生效。你仍可在本机继续处理。' });
267
+ });
268
+ return null;
269
+ }
@@ -0,0 +1,182 @@
1
+ import { digest, stateTransaction, targetEpoch } from './reply-state.js';
2
+ import { replyHint } from './reply-telegram.js';
3
+ // PermissionRequest 没有 tool_use_id:只接受唯一且尚未结束的同输入调用,歧义时保留本机处理。
4
+ function canonical(value) {
5
+ if (Array.isArray(value))
6
+ return value.map(canonical);
7
+ if (value && typeof value === 'object')
8
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
9
+ return value;
10
+ }
11
+ export const inputFingerprint = (value) => digest(JSON.stringify(canonical(value)) ?? 'null');
12
+ export const callKey = (session, id) => digest(JSON.stringify([session, id]));
13
+ export function finishInteraction(value, result) {
14
+ if (value.result)
15
+ return;
16
+ value.result = result;
17
+ for (const receipt of value.receipts ?? [])
18
+ receipt.result ??= result;
19
+ // 留下短期结果和消息 ID,用于反馈及旧按钮;不保留已结束的问题、参数和答案正文。
20
+ value.input = {};
21
+ value.questions = [];
22
+ value.answers = {};
23
+ value.selected = [];
24
+ delete value.response;
25
+ for (const receipt of value.receipts ?? []) {
26
+ receipt.answer = '';
27
+ delete receipt.question;
28
+ }
29
+ }
30
+ export function endSessionInteractions(state, session, reason) {
31
+ for (const call of Object.values(state.calls))
32
+ if (call.session === session)
33
+ call.ended = true;
34
+ for (const value of Object.values(state.requests))
35
+ if (value.session === session && !value.result)
36
+ finishInteraction(value, {
37
+ kind: value.claimed ? 'unknown' : 'not-applied',
38
+ text: value.claimed ? reason + ';还没有收到本次答案的生效确认,请检查终端。' : reason + ';这次远程答案没有回传。',
39
+ });
40
+ }
41
+ export async function registerToolCall(access, target, data) {
42
+ if (typeof data.tool_use_id !== 'string' || !data.tool_use_id || typeof data.tool_name !== 'string' || !data.tool_input)
43
+ return;
44
+ const key = callKey(target.session, data.tool_use_id);
45
+ await stateTransaction(access, state => {
46
+ if (state.calls[key])
47
+ return;
48
+ const previous = state.sessions[target.session];
49
+ state.sessions[target.session] = { ...target, working: true, latestCall: key, tracked: previous?.tracked === true && targetEpoch(previous) === targetEpoch(target) };
50
+ state.calls[key] = { id: data.tool_use_id, session: target.session, epoch: targetEpoch(target), tool: data.tool_name,
51
+ fingerprint: inputFingerprint(data.tool_input), prompt: typeof data.prompt_id === 'string' ? data.prompt_id : undefined, updated: Date.now() };
52
+ // 正常工具不一定有权限请求,限制关联索引大小。丢失索引只会回到本机,不会猜目标。
53
+ const old = Object.entries(state.calls).filter(([, call]) => !call.request).sort((a, b) => a[1].updated - b[1].updated);
54
+ for (const [id] of old.slice(0, Math.max(0, old.length - 256)))
55
+ delete state.calls[id];
56
+ });
57
+ }
58
+ export function matchingCall(state, target, data) {
59
+ const fingerprint = inputFingerprint(data.tool_input);
60
+ const matches = Object.entries(state.calls).filter(([, call]) => call.session === target.session && call.epoch === targetEpoch(target)
61
+ && call.tool === data.tool_name && call.fingerprint === fingerprint
62
+ && (typeof data.prompt_id !== 'string' || call.prompt === data.prompt_id));
63
+ // 包括已结束的同内容记录,防止迟到的 PermissionRequest 误绑到重复的新题。
64
+ if (matches.length !== 1 || matches[0][1].ended || matches[0][1].request || Date.now() - matches[0][1].updated > 300000)
65
+ return;
66
+ return matches[0][0];
67
+ }
68
+ function sameAnswer(actual, expected, multi) {
69
+ if (!multi)
70
+ return actual === expected;
71
+ const parts = (value) => [...(Array.isArray(value) ? value : value.split(',')).map(s => s.trim())].sort();
72
+ return (typeof actual === 'string' || Array.isArray(actual) && actual.every(s => typeof s === 'string'))
73
+ && JSON.stringify(parts(actual)) === JSON.stringify(parts(expected));
74
+ }
75
+ export async function completeToolCall(access, data) {
76
+ if (typeof data.session_id !== 'string' || typeof data.tool_use_id !== 'string')
77
+ return;
78
+ await stateTransaction(access, state => {
79
+ const call = state.calls[callKey(data.session_id, data.tool_use_id)];
80
+ if (!call || call.tool !== data.tool_name)
81
+ return;
82
+ call.ended = true;
83
+ const value = call.request && state.requests[call.request];
84
+ if (!value || value.result)
85
+ return;
86
+ if (data.hook_event_name === 'PostToolUse' && value.kind === 'question') {
87
+ const output = data.tool_response;
88
+ const answers = output?.answers;
89
+ const valid = answers && typeof answers === 'object' && !Array.isArray(answers);
90
+ const answered = !output?.afkTimeoutMs && output?.followUp !== true && !(typeof output?.response === 'string' && output.response.trim());
91
+ for (const receipt of value.receipts ?? []) {
92
+ const q = value.questions.find(q => q.question === receipt.question);
93
+ const exists = valid && answered && receipt.question !== undefined && Object.hasOwn(answers, receipt.question)
94
+ && !output.annotations?.[receipt.question]?.unanswered;
95
+ receipt.result = !value.claimed ? { kind: 'not-applied', text: '本机或其他端已经先处理了,这次 Telegram 答案没有提交。' }
96
+ : !valid ? { kind: 'unknown', text: 'Claude 已经结束了这个问题,但没有返回可核实的结果。请检查终端。' }
97
+ : exists && sameAnswer(answers[receipt.question], receipt.answer, q?.multiSelect === true && receipt.custom !== true && q.options.every(o => !o.label.includes(',')))
98
+ ? { kind: 'applied', text: 'Claude 已确认采用了与你提交内容一致的答案。如果本机也提交了相同内容,这次结果无法说明是哪一方生效。' }
99
+ : { kind: 'not-applied', text: 'Claude 已经结束问题,但没有采用你提交的答案;本机或其他端可能已经先处理。' };
100
+ }
101
+ finishInteraction(value, { kind: 'not-applied', text: '问题已处理,旧按钮已失效。' });
102
+ }
103
+ else if (data.hook_event_name === 'PostToolUse' && value.kind === 'permission') {
104
+ const allow = value.claimed && value.receipts?.some(r => r.answer === 'allow');
105
+ finishInteraction(value, allow
106
+ ? { kind: 'applied', text: '工具已获准并执行完成。如果本机同时也批准了,完成事件本身无法说明是哪一方放行。' }
107
+ : { kind: 'not-applied', text: '工具已经获准执行,这次远程决定没有阻止执行。' });
108
+ }
109
+ else
110
+ finishInteraction(value, { kind: value.claimed ? 'unknown' : 'not-applied', text: value.claimed
111
+ ? '工具执行失败、被拒绝或取消。这个事件无法说明远程决定是否生效,请检查终端。'
112
+ : '请求失败、被拒绝或取消,这次远程决定没有回传。' });
113
+ });
114
+ }
115
+ export async function drainInteractionFeedback(access, api, fresh, log = () => { }) {
116
+ const current = await fresh();
117
+ if (!current || current.identity !== access.identity)
118
+ return;
119
+ const work = await stateTransaction(access, state => {
120
+ for (const value of Object.values(state.requests)) {
121
+ const target = state.sessions[value.session];
122
+ if (!value.result && (!target || targetEpoch(target) !== value.epoch || value.expires <= Date.now()))
123
+ finishInteraction(value, {
124
+ kind: value.claimed ? 'unknown' : 'not-applied', text: value.claimed
125
+ ? '等待生效确认超时,或会话已经切换。结果暂时未知,请检查终端。'
126
+ : '交互已过期或会话已结束;远程答案没有回传,你仍可在本机界面继续处理。',
127
+ });
128
+ }
129
+ return Object.values(state.requests).filter(value => value.result || value.receipts?.some(r => r.result));
130
+ });
131
+ for (const value of work) {
132
+ for (const receipt of value.receipts ?? []) {
133
+ if (!receipt.result || receipt.delivery)
134
+ continue;
135
+ const authorized = await fresh();
136
+ if (!authorized || authorized.identity !== access.identity || !authorized.policy.users.includes(receipt.chat))
137
+ return;
138
+ const claimed = await stateTransaction(access, state => {
139
+ const actual = state.requests[value.id]?.receipts?.find(r => r.id === receipt.id);
140
+ if (!actual?.result || actual.delivery)
141
+ return false;
142
+ actual.delivery = 'sending';
143
+ return true;
144
+ });
145
+ if (!claimed)
146
+ continue;
147
+ let delivery = 'sent';
148
+ try {
149
+ const title = receipt.result.kind === 'applied' ? '✅ 答案内容已生效' : receipt.result.kind === 'not-applied' ? '⚠️ 本次提交未生效' : '⚠️ 提交结果暂时无法确认';
150
+ await replyHint(api, receipt.chat, title, receipt.result.text + '\n请求:' + value.id.slice(0, 8));
151
+ }
152
+ catch {
153
+ delivery = 'unknown';
154
+ log('Telegram 结果反馈没有发送成功。不会自动重发,已经采用的答案不受影响。');
155
+ }
156
+ await stateTransaction(access, state => {
157
+ const actual = state.requests[value.id]?.receipts?.find(r => r.id === receipt.id);
158
+ if (actual)
159
+ actual.delivery = delivery;
160
+ });
161
+ }
162
+ if (!value.result)
163
+ continue;
164
+ for (const message of value.messages ?? []) {
165
+ if (message.cleared)
166
+ continue;
167
+ const authorized = await fresh();
168
+ if (!authorized || authorized.identity !== access.identity || !authorized.policy.users.includes(message.chat))
169
+ return;
170
+ // 最多尝试一次移除按钮;编辑失败时旧回调仍由服务端状态拒绝。
171
+ await stateTransaction(access, state => {
172
+ const actual = state.requests[value.id]?.messages?.find(m => m.chat === message.chat && m.message === message.message);
173
+ if (actual)
174
+ actual.cleared = true;
175
+ });
176
+ const label = value.result.kind === 'unknown' ? '已关闭 · 结果待核实' : value.result.text.includes('过期') ? '已过期 · 不再接受答案' : '已处理 · 不再接受答案';
177
+ await api('editMessageReplyMarkup', { chat_id: message.chat, message_id: message.message,
178
+ reply_markup: { inline_keyboard: [[{ text: label, callback_data: `dn:q:${value.id}:0:l` }]] } })
179
+ .catch(() => log('Telegram 旧按钮没有移除成功。服务端仍会拒绝这个请求。'));
180
+ }
181
+ }
182
+ }