@quecue/dev-notify 0.0.0-stage → 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,262 @@
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仅支持本次允许、拒绝或自定义拒绝说明;官方未提供完整本地按钮列表。不会创建永久权限规则。';
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
+ export async function showInteraction(access, id, chat, api) {
83
+ const value = await stateTransaction(access, state => state.requests[id]);
84
+ if (!active(value)) {
85
+ await replyHint(api, chat, '⚠️ 交互已失效', '请从最新通知进入,或回到本机处理。');
86
+ return;
87
+ }
88
+ // 使用通知分段器发送较长选项,所有选项正文保留,只有最后一段附选择按钮。
89
+ const { makePlan, deliver } = await import('./channels.js');
90
+ const { config } = access;
91
+ const plan = makePlan('telegram', { title: 'Claude Code 需要你处理', content: interactionContent(value), footer: '', level: 'warn' }, { ...config, telegram: { ...config.telegram, chatIds: [chat] } });
92
+ plan.items[plan.items.length - 1].payload.reply_markup = interactionMarkup(value);
93
+ plan.onSent = async (item, response) => {
94
+ const message = response.result?.message_id;
95
+ if (!Number.isSafeInteger(message))
96
+ throw new ConfigError('交互通知未返回消息 ID');
97
+ await stateTransaction(access, state => {
98
+ const current = state.requests[id];
99
+ if (current) {
100
+ state.entries[`tg:${chat}:${message}`] = { session: value.session, epoch: value.epoch, request: id, question: value.index, updated: Date.now() };
101
+ // 发送期间可能由本机先答;仍登记消息,随后移除过期按钮。
102
+ (current.messages ??= []).push({ chat, message });
103
+ }
104
+ });
105
+ };
106
+ // 完整分段及按钮投递成功后才覆盖通用提醒;重发失败时恢复该接收人的兜底提醒。
107
+ await stateTransaction(access, state => {
108
+ const current = state.requests[id];
109
+ if (current?.notified?.telegram)
110
+ current.notified.telegram = current.notified.telegram.filter(user => user !== chat);
111
+ });
112
+ // API 封装由测试或真实桥接注入,不另建网络连接。
113
+ const result = await deliver(plan, config, async (_url, payload) => ({ ok: true, result: await api('sendMessage', payload) }));
114
+ if (!result.ok)
115
+ throw new ConfigError('交互通知未完整发送,请回到本机处理');
116
+ await stateTransaction(access, state => {
117
+ const current = state.requests[id];
118
+ if (!current)
119
+ return;
120
+ current.notified ??= {};
121
+ const users = current.notified.telegram ??= [];
122
+ if (!users.includes(chat))
123
+ users.push(chat);
124
+ });
125
+ }
126
+ function answer(value, text) {
127
+ const q = value.questions[value.index];
128
+ Object.defineProperty(value.answers, q.question, { value: text, enumerable: true, writable: true, configurable: true });
129
+ value.selected = [];
130
+ value.index++;
131
+ if (value.index === value.questions.length)
132
+ value.response = {
133
+ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow', updatedInput: { ...value.input, questions: value.input.questions, answers: { ...value.answers } } } },
134
+ };
135
+ }
136
+ export async function handleInteractionCallback(access, callback, chat, api) {
137
+ if (typeof callback.data !== 'string' || !callback.data.startsWith('dn:q:'))
138
+ return false;
139
+ const match = callback.data.match(/^dn:q:([a-f0-9]{32}):(\d):([adcls]|o[0-3])$/);
140
+ if (!match || typeof callback.id !== 'string')
141
+ return true;
142
+ const [, id, index, operation] = match;
143
+ const outcome = await stateTransaction(access, state => {
144
+ const value = state.requests[id], target = value && state.sessions[value.session];
145
+ if (!active(value) || value.index !== Number(index) || !target || targetEpoch(target) !== value.epoch)
146
+ return 'expired';
147
+ if (operation === 'l') {
148
+ finishInteraction(value, { kind: 'not-applied', text: '已关闭远程答题,本机仍可正常处理。' });
149
+ return 'local';
150
+ }
151
+ const question = value.questions[value.index]?.question;
152
+ const previousIndex = value.index;
153
+ if (value.kind === 'permission') {
154
+ if (access.policy.permissions !== true)
155
+ return 'expired';
156
+ if (operation === 'a' || operation === 'd') {
157
+ value.response = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: operation === 'a' ? 'allow' : 'deny' } } };
158
+ }
159
+ else if (operation !== 'c')
160
+ return 'expired';
161
+ }
162
+ else {
163
+ const q = value.questions[value.index];
164
+ if (operation.startsWith('o')) {
165
+ const selected = Number(operation.slice(1));
166
+ if (!q.options[selected])
167
+ return 'expired';
168
+ if (q.multiSelect)
169
+ value.selected = value.selected.includes(selected) ? value.selected.filter(i => i !== selected) : [...value.selected, selected].sort();
170
+ else
171
+ answer(value, q.options[selected].label);
172
+ }
173
+ else if (operation === 's' && q.multiSelect && value.selected.length)
174
+ answer(value, value.selected.map(i => q.options[i].label).join(','));
175
+ else if (operation !== 'c')
176
+ return 'expired';
177
+ }
178
+ if (value.index !== previousIndex || value.response)
179
+ (value.receipts ??= []).push({ id: callback.id, chat,
180
+ message: callback.message.message_id, question, answer: value.kind === 'question' ? value.answers[question] : operation === 'a' ? 'allow' : 'deny' });
181
+ return operation === 'c' ? 'custom' : value.response ? 'done' : value.index !== previousIndex ? 'answered' : 'next';
182
+ });
183
+ await api('answerCallbackQuery', { callback_query_id: callback.id,
184
+ text: outcome === 'expired' ? '未提交:按钮已失效或已有答案待确认' : outcome === 'local' ? '已关闭远程答题,本机不受影响' : outcome === 'done' || outcome === 'answered' ? '答案已登记,尚未确认生效' : '已更新' }).catch(() => { });
185
+ if (outcome === 'done' || outcome === 'answered')
186
+ await replyHint(api, chat, '⏳ 答案已登记,等待确认', (outcome === 'answered' ? '仍需完成其余问题,之后才回传整份答案。' : '已进入回传流程,尚不代表 Claude 采用。')
187
+ + '\n稍后反馈生效、未生效或结果未知;若未收到最终反馈,请检查终端。\n请求:' + id.slice(0, 8)).catch(() => { });
188
+ if (outcome === 'expired')
189
+ await replyHint(api, chat, '⚠️ 本次提交未生效', '此操作未提交:问题已处理、过期、题目已切换或已有答案等待确认。\n请求:' + id.slice(0, 8)).catch(() => { });
190
+ if (outcome === 'custom') {
191
+ const value = await stateTransaction(access, state => state.requests[id]);
192
+ if (!active(value))
193
+ return true;
194
+ const sent = await replyHint(api, chat, '✍️ 自定义输入', value.kind === 'permission' ? '请回复此消息填写拒绝说明;此输入不会批准权限。' : '请回复此消息输入这个问题的完整答案。', callback.message.message_id, { force_reply: true, selective: true });
195
+ if (Number.isSafeInteger(sent?.message_id))
196
+ await stateTransaction(access, state => {
197
+ state.entries[`tg:${chat}:${sent.message_id}`] = { session: value.session, epoch: value.epoch, request: id, question: value.index, updated: Date.now() };
198
+ });
199
+ }
200
+ else if (outcome === 'next' || outcome === 'answered')
201
+ await showInteraction(access, id, chat, api);
202
+ return true;
203
+ }
204
+ export async function handleInteractionText(access, id, chat, message, api) {
205
+ const text = message.text;
206
+ if (!text.trim() || Array.from(text).length > 4000) {
207
+ await replyHint(api, chat, '❌ 未提交答案', '请输入不超过 4000 字符的答案;不会截断。', message.message_id);
208
+ return;
209
+ }
210
+ const outcome = await stateTransaction(access, state => {
211
+ const value = state.requests[id], target = value && state.sessions[value.session];
212
+ const entry = state.entries[`tg:${chat}:${message.reply_to_message.message_id}`];
213
+ if (!active(value) || !target || targetEpoch(target) !== value.epoch || entry?.question !== value.index)
214
+ return 'expired';
215
+ const question = value.questions[value.index]?.question;
216
+ if (value.kind === 'permission') {
217
+ if (access.policy.permissions !== true)
218
+ return 'expired';
219
+ value.response = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny', message: text } } };
220
+ }
221
+ else
222
+ answer(value, text);
223
+ (value.receipts ??= []).push({ id: `text:${chat}:${message.message_id}`, chat, message: message.message_id,
224
+ question, answer: value.kind === 'question' ? text : 'deny', custom: true });
225
+ return value.response ? 'done' : 'next';
226
+ });
227
+ await replyHint(api, chat, outcome === 'expired' ? '⚠️ 本次提交未生效' : '⏳ 答案已登记,等待确认', (outcome === 'done' ? '等待 Claude 确认,稍后反馈最终结果;若未收到反馈请检查终端。'
228
+ : outcome === 'next' ? '仍需完成其余问题,之后才回传整份答案;尚未生效。'
229
+ : '此操作未提交:对应问题已结束、过期、已切换或已有答案等待确认。') + '\n请求:' + id.slice(0, 8));
230
+ if (outcome === 'next')
231
+ await showInteraction(access, id, chat, api);
232
+ }
233
+ export async function waitInteraction(access, value, fresh, options = {}) {
234
+ const end = Math.min(value.expires, Date.now() + (options.timeout ?? INTERACTION_MS));
235
+ while (Date.now() < end && !options.signal?.aborted) {
236
+ const current = await fresh();
237
+ if (!current || current.identity !== access.identity || (value.kind === 'permission' && current.policy.permissions !== true))
238
+ return null;
239
+ const response = await stateTransaction(access, state => {
240
+ const request = state.requests[value.id], target = state.sessions[value.session];
241
+ if (!request || request.result || !target || targetEpoch(target) !== value.epoch || (request.call && (!state.calls[request.call] || state.calls[request.call].ended)))
242
+ return { ended: true };
243
+ if (!request.response || request.claimed || options.signal?.aborted)
244
+ return null;
245
+ request.claimed = true;
246
+ // 回传后等待独立的完成事件确认;不能在这里删除请求或报告成功。
247
+ request.expires = Date.now() + INTERACTION_MS;
248
+ return { response: request.response };
249
+ });
250
+ if (response?.ended)
251
+ return null;
252
+ if (response?.response)
253
+ return response.response;
254
+ await (options.sleep ?? delay)(200);
255
+ }
256
+ await stateTransaction(access, state => {
257
+ const request = state.requests[value.id];
258
+ if (request && !request.result)
259
+ finishInteraction(request, { kind: request.claimed ? 'unknown' : 'not-applied', text: options.signal?.aborted ? '交互等待已取消;远程答案未确认生效。' : '远程交互等待超时;未确认生效,本机仍可继续处理。' });
260
+ });
261
+ return null;
262
+ }
@@ -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
+ }
@@ -0,0 +1,182 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { ConfigError } from './config.js';
6
+ import { claudeSettingsPath } from './claude-code.js';
7
+ export const runCommand = (command, args, input = '') => new Promise((resolve, reject) => {
8
+ const child = spawn(command, args, { windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
9
+ let stdout = '', overflow = false;
10
+ const timer = setTimeout(() => { child.kill(); reject(new ConfigError('系统会话操作超时;输入可能已部分送达,不会自动重试')); }, 15000);
11
+ const fail = () => { clearTimeout(timer); reject(new ConfigError('系统会话操作失败;未输出底层命令或输入')); };
12
+ child.on('error', fail);
13
+ child.stdin.on('error', () => { });
14
+ child.stdout.on('data', data => { if (stdout.length + data.length > 1024 * 1024) {
15
+ overflow = true;
16
+ child.kill();
17
+ }
18
+ else
19
+ stdout += data.toString('utf8'); });
20
+ child.stderr.resume();
21
+ child.once('close', code => { clearTimeout(timer); overflow ? fail() : resolve({ code: code ?? 1, stdout }); });
22
+ child.stdin.end(input, 'utf8');
23
+ });
24
+ function powershell(script, input, runner) {
25
+ const executable = path.win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
26
+ return runner(executable, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], JSON.stringify(input));
27
+ }
28
+ const READ_PROCESS = `$ErrorActionPreference='Stop'; [Console]::InputEncoding=[Text.UTF8Encoding]::new($false); $x=[Console]::In.ReadToEnd()|ConvertFrom-Json; $p=Get-Process -Id $x.pid; if($p.ProcessName -ne 'claude'){exit 3}; @{birth=$p.StartTime.ToUniversalTime().Ticks.ToString()}|ConvertTo-Json -Compress`;
29
+ export function sanitizeReply(text) {
30
+ if (typeof text !== 'string' || !text.trim())
31
+ throw new ConfigError('回复内容为空');
32
+ if (Array.from(text).length > 4000)
33
+ throw new ConfigError('回复超过 4000 字符,未提交;不会截断或拆成多次任务');
34
+ const value = text.replace(/\r\n|[\r\n\u2028\u2029]/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').trim();
35
+ if (!value)
36
+ throw new ConfigError('回复清理后为空');
37
+ return value;
38
+ }
39
+ export async function processIdentity(pid, platform = process.platform, runner = runCommand) {
40
+ if (!Number.isSafeInteger(pid) || pid <= 0)
41
+ return null;
42
+ try {
43
+ if (platform === 'win32') {
44
+ const result = await powershell(READ_PROCESS, { pid }, runner);
45
+ if (result.code !== 0)
46
+ return null;
47
+ const d = JSON.parse(result.stdout);
48
+ return typeof d.birth === 'string' && /^\d+$/.test(d.birth) ? { birth: d.birth, tty: '' } : null;
49
+ }
50
+ if (platform === 'darwin') {
51
+ const result = await runner('/bin/ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'tty=', '-o', 'comm=']);
52
+ const match = result.stdout.trim().match(/^(\S+\s+\S+\s+\d+\s+\d+:\d+:\d+\s+\d+)\s+(\S+)\s+(.+)$/);
53
+ if (result.code !== 0 || !match || path.posix.basename(match[3]) !== 'claude' || !/^ttys\d+$/.test(match[2]))
54
+ return null;
55
+ const groups = await runner('/bin/ps', ['-p', String(pid), '-o', 'pgid=', '-o', 'tpgid=']);
56
+ const group = groups.stdout.trim().match(/^(\d+)\s+(\d+)$/);
57
+ if (groups.code !== 0 || !group || group[1] !== group[2])
58
+ return null;
59
+ return { birth: match[1].replace(/\s+/g, ' '), tty: '/dev/' + match[2] };
60
+ }
61
+ }
62
+ catch { /* 目标不确定时拒绝,不向任意前台终端回退。 */ }
63
+ return null;
64
+ }
65
+ export async function findSession(session, options = {}) {
66
+ if (!session || !/^[A-Za-z0-9_-]{1,128}$/.test(session))
67
+ return null;
68
+ const directory = path.join(path.dirname(claudeSettingsPath(options.env, options.home ?? homedir())), 'sessions');
69
+ let names;
70
+ try {
71
+ names = await readdir(directory);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ const matches = [];
77
+ for (const name of names) {
78
+ if (!/^\d+\.json$/.test(name))
79
+ continue;
80
+ try {
81
+ const bytes = await readFile(path.join(directory, name));
82
+ if (bytes.length > 128 * 1024)
83
+ continue;
84
+ const d = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
85
+ if (d.sessionId === session && d.pid === Number(name.slice(0, -5)))
86
+ matches.push(d);
87
+ }
88
+ catch { /* 其他损坏或写入中的会话文件不作为目标。 */ }
89
+ }
90
+ if (matches.length !== 1)
91
+ return null;
92
+ const d = matches[0], identity = await processIdentity(d.pid, options.platform, options.runner);
93
+ if (!identity)
94
+ return null;
95
+ return { session, pid: d.pid, ...identity, title: typeof d.name === 'string' ? Array.from(d.name).slice(0, 60).join('') : '',
96
+ cwd: typeof d.cwd === 'string' ? d.cwd : '', updated: Date.now(), working: false };
97
+ }
98
+ export async function validateTarget(target, options = {}) {
99
+ const current = await findSession(target.session, options);
100
+ return current !== null && current.pid === target.pid && current.birth === target.birth && current.tty === target.tty;
101
+ }
102
+ const WINDOWS_INJECT = `$ErrorActionPreference='Stop';[Console]::InputEncoding=[Text.UTF8Encoding]::new($false);$x=[Console]::In.ReadToEnd()|ConvertFrom-Json;$p=Get-Process -Id $x.pid;if($p.ProcessName -ne 'claude' -or $p.StartTime.ToUniversalTime().Ticks.ToString() -ne $x.birth){exit 3}
103
+ Add-Type -TypeDefinition @'
104
+ using System;
105
+ using System.Collections.Generic;
106
+ using System.Runtime.InteropServices;
107
+ public static class DevNotifyConsole {
108
+ [StructLayout(LayoutKind.Explicit, Size=20, CharSet=CharSet.Unicode)] public struct Record {
109
+ [FieldOffset(0)] public ushort type;
110
+ [FieldOffset(4)] public int down;
111
+ [FieldOffset(8)] public ushort repeat;
112
+ [FieldOffset(10)] public ushort key;
113
+ [FieldOffset(14)] public char ch;
114
+ }
115
+ [DllImport("kernel32.dll")] static extern bool FreeConsole();
116
+ [DllImport("kernel32.dll")] static extern bool AttachConsole(uint pid);
117
+ [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern IntPtr CreateFileW(string n,uint a,uint s,IntPtr p,uint c,uint f,IntPtr t);
118
+ [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern bool WriteConsoleInputW(IntPtr h,Record[] r,uint n,out uint written);
119
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
120
+ [DllImport("kernel32.dll")] static extern uint GetConsoleProcessList([Out] uint[] ids,uint count);
121
+ public static bool Send(uint pid,string text) {
122
+ FreeConsole(); if(!AttachConsole(pid))return false;
123
+ IntPtr h=new IntPtr(-1);
124
+ try {
125
+ var ids=new uint[1024];uint count=GetConsoleProcessList(ids,(uint)ids.Length);
126
+ if(count==0||count>ids.Length)return false;
127
+ foreach(uint id in ids){if(id==0||id==pid)continue;try{if(System.Diagnostics.Process.GetProcessById((int)id).ProcessName=="claude")return false;}catch{return false;}}
128
+ h=CreateFileW("CONIN$",0xC0000000,3,IntPtr.Zero,3,0,IntPtr.Zero);
129
+ if(h==new IntPtr(-1))return false;
130
+ var records=new List<Record>();
131
+ foreach(char c in text){records.Add(new Record{type=1,down=1,repeat=1,ch=c});records.Add(new Record{type=1,repeat=1});}
132
+ records.Add(new Record{type=1,down=1,repeat=1,key=13,ch='\\r'});records.Add(new Record{type=1,repeat=1,key=13});
133
+ uint written;var all=records.ToArray();return WriteConsoleInputW(h,all,(uint)all.Length,out written)&&written==all.Length;
134
+ } finally {if(h!=new IntPtr(-1))CloseHandle(h);FreeConsole();}
135
+ }
136
+ }
137
+ '@
138
+ if([DevNotifyConsole]::Send([uint32]$x.pid,[string]$x.text)){exit 0}else{exit 4}`;
139
+ export async function injectReply(target, text, options = {}) {
140
+ const safe = sanitizeReply(text), runner = options.runner ?? runCommand, platform = options.platform ?? process.platform;
141
+ if (!await validateTarget(target, options))
142
+ throw new ConfigError('会话已关闭、切换或无法核验,请从最新通知回复');
143
+ if (platform === 'win32') {
144
+ const result = await powershell(WINDOWS_INJECT, { pid: target.pid, birth: target.birth, text: safe }, runner);
145
+ if (result.code !== 0)
146
+ throw new ConfigError('Windows 控制台注入失败或部分写入;不会自动重试,请检查终端');
147
+ return;
148
+ }
149
+ if (platform !== 'darwin')
150
+ throw new ConfigError('远程回复仅支持 Windows 与 macOS');
151
+ // 先确定 tmux pane,字面文本通过 stdin 缓冲区传入,绝不作为 shell 或按键名称执行。
152
+ let listing = null;
153
+ try {
154
+ listing = await runner('tmux', ['list-panes', '-a', '-F', '#{pane_id} #{pane_tty}']);
155
+ }
156
+ catch { /* Ghostty 回退仅在尚未发送任何输入时使用。 */ }
157
+ const pane = listing?.code === 0 ? listing.stdout.split('\n').map(line => line.trim().split(/\s+/)).find(parts => parts[1] === target.tty && /^%\d+$/.test(parts[0]))?.[0] : undefined;
158
+ if (pane) {
159
+ const name = 'dev-notify-' + target.pid + '-' + Date.now();
160
+ const loaded = await runner('tmux', ['load-buffer', '-b', name, '-'], safe);
161
+ if (loaded.code !== 0)
162
+ throw new ConfigError('tmux 输入缓冲区创建失败,未提交');
163
+ try {
164
+ if (!await validateTarget(target, options))
165
+ throw new ConfigError('会话已变化,未提交');
166
+ if ((await runner('tmux', ['paste-buffer', '-d', '-b', name, '-t', pane])).code !== 0)
167
+ throw new ConfigError('tmux 粘贴失败,可能部分送达,不重试');
168
+ if ((await runner('tmux', ['send-keys', '-t', pane, 'Enter'])).code !== 0)
169
+ throw new ConfigError('tmux 提交失败,文本可能已输入,不重试');
170
+ }
171
+ finally {
172
+ await runner('tmux', ['delete-buffer', '-b', name]).catch(() => { });
173
+ }
174
+ return;
175
+ }
176
+ const escape = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
177
+ const action = 'text:' + safe.replace(/\\/g, '\\\\') + '\\x0d';
178
+ const script = `tell application "Ghostty"\nrepeat with w in windows\nrepeat with t in terminals of w\nif tty of t is "${escape(target.tty)}" then\nperform action "${escape(action)}" on t\nreturn "ok"\nend if\nend repeat\nend repeat\nreturn "notfound"\nend tell`;
179
+ const result = await runner('/usr/bin/osascript', ['-'], script);
180
+ if (result.code !== 0 || result.stdout.trim() !== 'ok')
181
+ throw new ConfigError('Ghostty 未找到目标或注入失败;不会回退到任意窗口或重复输入');
182
+ }