@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.
- package/CHANGELOG.md +55 -0
- package/README.md +138 -34
- package/SECURITY.md +52 -21
- package/dist/channels.js +17 -11
- package/dist/claude-code.js +235 -0
- package/dist/claude-hook.js +136 -0
- package/dist/cli.js +153 -45
- package/dist/config.js +16 -16
- package/dist/credentials.js +13 -13
- package/dist/formatting.js +4 -4
- package/dist/prompts.js +6 -6
- package/dist/reply-bridge.js +189 -0
- package/dist/reply-engine.js +205 -0
- package/dist/reply-hooks.js +230 -0
- package/dist/reply-interactions.js +269 -0
- package/dist/reply-outcomes.js +182 -0
- package/dist/reply-platform.js +209 -0
- package/dist/reply-state.js +189 -0
- package/dist/reply-telegram.js +19 -0
- package/dist/setup.js +74 -43
- package/package.json +10 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdir, open, unlink, realpath } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { appPaths, ConfigError } from './config.js';
|
|
8
|
+
import { accessReplies, atomicJson, readJson, replyDirectory, stateTransaction } from './reply-state.js';
|
|
9
|
+
import { telegramApi } from './reply-telegram.js';
|
|
10
|
+
import { drainReplies, processUpdate } from './reply-engine.js';
|
|
11
|
+
import { drainInteractionFeedback } from './reply-outcomes.js';
|
|
12
|
+
const leasePath = (file) => path.join(replyDirectory(file), 'bridge.json');
|
|
13
|
+
const controlPath = (file) => path.join(replyDirectory(file), 'stop.json');
|
|
14
|
+
function alive(pid) {
|
|
15
|
+
if (!Number.isSafeInteger(pid) || Number(pid) <= 0)
|
|
16
|
+
return false;
|
|
17
|
+
try {
|
|
18
|
+
process.kill(Number(pid), 0);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function bridgeStatus(file) {
|
|
26
|
+
const lease = await readJson(leasePath(file));
|
|
27
|
+
const running = !!lease && alive(lease.pid) && Date.now() - lease.updated < 30000;
|
|
28
|
+
return { running, ready: !!lease?.ready && running, error: lease?.error };
|
|
29
|
+
}
|
|
30
|
+
export async function stopBridge(file) {
|
|
31
|
+
const lease = await readJson(leasePath(file));
|
|
32
|
+
if (!lease || !alive(lease.pid))
|
|
33
|
+
return true;
|
|
34
|
+
// 不按可复用 PID 杀进程;只有持有本次 owner 的桥接响应此控制文件。
|
|
35
|
+
await atomicJson(controlPath(file), { owner: lease.owner });
|
|
36
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
37
|
+
await delay(250);
|
|
38
|
+
const current = await readJson(leasePath(file));
|
|
39
|
+
if (!current || current.owner !== lease.owner || !alive(current.pid))
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
export async function startBridge(file, env = process.env) {
|
|
45
|
+
const access = await accessReplies(file, env);
|
|
46
|
+
if (!access)
|
|
47
|
+
throw new ConfigError('远程回复尚未授权,请先运行 reply setup。');
|
|
48
|
+
const existing = await bridgeStatus(file);
|
|
49
|
+
if (existing.running)
|
|
50
|
+
return existing.ready;
|
|
51
|
+
const node = await realpath(process.execPath), cli = await realpath(fileURLToPath(new URL('./cli.js', import.meta.url)));
|
|
52
|
+
// 无 shell、无命令行秘密、无系统开机自启;worker 自己持有排他锁。
|
|
53
|
+
const child = spawn(node, [cli, 'reply', 'run', '--config', path.resolve(file)], {
|
|
54
|
+
detached: true, windowsHide: true, shell: false, stdio: 'ignore', env: { ...process.env, ...env },
|
|
55
|
+
});
|
|
56
|
+
let failed = false;
|
|
57
|
+
child.once('error', () => { failed = true; });
|
|
58
|
+
child.unref();
|
|
59
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
60
|
+
await delay(150);
|
|
61
|
+
if (failed)
|
|
62
|
+
return false;
|
|
63
|
+
const status = await bridgeStatus(file);
|
|
64
|
+
if (status.ready)
|
|
65
|
+
return true;
|
|
66
|
+
if (status.error)
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
export async function runBridge(file, options = {}) {
|
|
72
|
+
const env = options.env ?? process.env;
|
|
73
|
+
let access = await accessReplies(file, env);
|
|
74
|
+
if (!access)
|
|
75
|
+
throw new ConfigError('远程回复尚未授权。');
|
|
76
|
+
const directory = replyDirectory(file);
|
|
77
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
78
|
+
const botDirectory = path.join(appPaths(process.platform, env).data, 'reply-bots');
|
|
79
|
+
await mkdir(botDirectory, { recursive: true, mode: 0o700 });
|
|
80
|
+
const lockPath = path.join(botDirectory, access.policy.bot + '.lock');
|
|
81
|
+
let lock;
|
|
82
|
+
try {
|
|
83
|
+
lock = await open(lockPath, 'wx', 0o600);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new ConfigError('当前用户已经有同一个 Bot 的桥接在运行,或残留了锁文件。请确认进程退出后,再处理 reply-bots 中对应的锁。');
|
|
87
|
+
}
|
|
88
|
+
const owner = randomBytes(16).toString('hex'), lease = { pid: process.pid, owner, ready: false, updated: Date.now() };
|
|
89
|
+
let stopping = false;
|
|
90
|
+
const stop = () => { stopping = true; };
|
|
91
|
+
process.on('SIGTERM', stop);
|
|
92
|
+
process.on('SIGINT', stop);
|
|
93
|
+
const fresh = async () => await isStopped() ? null : accessReplies(file, env);
|
|
94
|
+
const log = options.log ?? (() => { });
|
|
95
|
+
let lastActivity = Date.now();
|
|
96
|
+
const isStopped = async () => stopping || options.stopped?.() === true || (await readJson(controlPath(file)))?.owner === owner;
|
|
97
|
+
try {
|
|
98
|
+
await lock.writeFile(JSON.stringify({ pid: process.pid, owner }));
|
|
99
|
+
await atomicJson(leasePath(file), lease);
|
|
100
|
+
let api = (options.api ?? (value => telegramApi(value.config.telegram.token)))(access);
|
|
101
|
+
const webhook = await api('getWebhookInfo', {});
|
|
102
|
+
if (webhook?.url)
|
|
103
|
+
throw new ConfigError('这个 Bot 已经设置了 webhook,不能同时使用 getUpdates。请先移除 webhook;本工具不会自动删除。');
|
|
104
|
+
await api('getMe', {});
|
|
105
|
+
const currentState = await stateTransaction(access, state => ({ offset: state.offset, empty: Object.keys(state.entries).length === 0 }));
|
|
106
|
+
if (!currentState.offset && currentState.empty) {
|
|
107
|
+
const old = await api('getUpdates', { offset: -1, timeout: 0, allowed_updates: ['message', 'callback_query'] });
|
|
108
|
+
if (Array.isArray(old) && old.length)
|
|
109
|
+
await stateTransaction(access, state => {
|
|
110
|
+
const id = old[old.length - 1]?.update_id;
|
|
111
|
+
if (Number.isSafeInteger(id))
|
|
112
|
+
state.offset = Math.max(state.offset, id + 1);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
lease.ready = true;
|
|
116
|
+
lease.updated = Date.now();
|
|
117
|
+
await atomicJson(leasePath(file), lease);
|
|
118
|
+
let iteration = 0, failures = 0;
|
|
119
|
+
while (!await isStopped() && iteration++ < (options.maxIterations ?? Infinity)) {
|
|
120
|
+
const current = await fresh();
|
|
121
|
+
if (!current)
|
|
122
|
+
break;
|
|
123
|
+
if (current.identity !== access.identity) {
|
|
124
|
+
// 修改授权或 Bot 必须重启并重新核验,丢弃旧长轮询结果。
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
access = current;
|
|
128
|
+
api = (options.api ?? (value => telegramApi(value.config.telegram.token)))(access);
|
|
129
|
+
const context = { api, fresh, validate: options.validate, inject: options.inject };
|
|
130
|
+
await drainReplies(access, context);
|
|
131
|
+
await drainInteractionFeedback(access, api, fresh, log);
|
|
132
|
+
if (await isStopped())
|
|
133
|
+
break;
|
|
134
|
+
const offset = await stateTransaction(access, state => state.offset);
|
|
135
|
+
let updates;
|
|
136
|
+
try {
|
|
137
|
+
updates = await api('getUpdates', { offset, timeout: 2, allowed_updates: ['message', 'callback_query'] });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (error instanceof ConfigError && /冲突/.test(error.message))
|
|
141
|
+
throw error;
|
|
142
|
+
log('Telegram 轮询失败;日志中没有记录密钥或服务端正文。');
|
|
143
|
+
if (++failures >= 5)
|
|
144
|
+
throw new ConfigError('Telegram 连续轮询失败 5 次,桥接已经退出。请检查网络、Token 或 API 冲突后重新启动。');
|
|
145
|
+
lease.updated = Date.now();
|
|
146
|
+
await atomicJson(leasePath(file), lease);
|
|
147
|
+
await (options.sleep ?? delay)(3000);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
failures = 0;
|
|
151
|
+
const after = await fresh();
|
|
152
|
+
if (!after || after.identity !== access.identity || await isStopped())
|
|
153
|
+
break;
|
|
154
|
+
if (!Array.isArray(updates))
|
|
155
|
+
throw new ConfigError('Telegram 更新格式无效。');
|
|
156
|
+
for (const update of updates) {
|
|
157
|
+
if (await isStopped())
|
|
158
|
+
break;
|
|
159
|
+
try {
|
|
160
|
+
await processUpdate(access, update, context);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
log('回复处理失败。这条更新不会重放;请检查终端,或从最新通知重试。');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (updates.length)
|
|
167
|
+
lastActivity = Date.now();
|
|
168
|
+
lease.updated = Date.now();
|
|
169
|
+
await atomicJson(leasePath(file), lease);
|
|
170
|
+
if (Date.now() - lastActivity > 24 * 60 * 60 * 1000)
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
lease.ready = false;
|
|
176
|
+
lease.error = error instanceof ConfigError ? error.message : '桥接启动或运行失败;为避免泄露密钥,没有显示底层错误。';
|
|
177
|
+
await atomicJson(leasePath(file), lease).catch(() => { });
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
process.off('SIGTERM', stop);
|
|
182
|
+
process.off('SIGINT', stop);
|
|
183
|
+
const current = await readJson(leasePath(file)).catch(() => null);
|
|
184
|
+
if (current?.owner === owner && !lease.error)
|
|
185
|
+
await unlink(leasePath(file)).catch(() => { });
|
|
186
|
+
await lock.close();
|
|
187
|
+
await unlink(lockPath);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
// autoReply:Telegram 内联按钮沿用 force_reply,点击后自动进入回复状态。
|
|
26
|
+
// 飞书跳转使用普通提示 + remove_keyboard:清除客户端缓存的旧强制回复目标,但不拉起新的回复框。
|
|
27
|
+
async function prompt(access, chat, messageId, key, context, autoReply = true) {
|
|
28
|
+
const resolved = await resolveEntry(access, key, context);
|
|
29
|
+
if (!resolved) {
|
|
30
|
+
await replyHint(context.api, chat, '⚠️ 无法回复', '这个会话已经关闭、切换,或这条通知已过期。请从最新通知进入。', messageId);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (resolved.entry.request) {
|
|
34
|
+
await showInteraction(access, resolved.entry.request, chat, context.api);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const session = resolved.target.title || resolved.target.session;
|
|
38
|
+
const sent = autoReply
|
|
39
|
+
? await replyHint(context.api, chat, '💬 回复 Claude', '请回复此消息输入内容。该输入将提交到对应本机会话。\n会话:' + session, messageId, { force_reply: true, selective: true })
|
|
40
|
+
: await replyHint(context.api, chat, '💬 回复 Claude', '请长按这条消息并选择「回复」,然后输入内容。该输入将提交到对应本机会话。\n会话:' + session, messageId, { remove_keyboard: true });
|
|
41
|
+
if (Number.isSafeInteger(sent?.message_id))
|
|
42
|
+
await stateTransaction(access, state => { state.entries[`tg:${chat}:${sent.message_id}`] = resolved.entry; });
|
|
43
|
+
}
|
|
44
|
+
export async function processUpdate(access, update, context) {
|
|
45
|
+
try {
|
|
46
|
+
await processAuthorizedUpdate(access, update, context);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const message = update?.callback_query?.message ?? update?.message;
|
|
50
|
+
const sender = update?.callback_query?.from ?? update?.message?.from;
|
|
51
|
+
const chat = privateUser(message, sender);
|
|
52
|
+
const current = await context.fresh().catch(() => null);
|
|
53
|
+
if (chat && current?.identity === access.identity && current.policy.users.includes(chat)) {
|
|
54
|
+
if (typeof update?.callback_query?.id === 'string')
|
|
55
|
+
await context.api('answerCallbackQuery', {
|
|
56
|
+
callback_query_id: update.callback_query.id, text: '处理没有完成,请检查终端;不会自动重复提交。',
|
|
57
|
+
}).catch(() => { });
|
|
58
|
+
await replyHint(context.api, chat, '⚠️ 本次提交没有完成', '处理时出现了异常,这次提交可能已经登记,也可能没有生效。请检查终端和后续结果;不会自动重复提交。').catch(() => { });
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function processAuthorizedUpdate(access, update, context) {
|
|
64
|
+
if (!Number.isSafeInteger(update?.update_id) || update.update_id < 0)
|
|
65
|
+
return;
|
|
66
|
+
const current = await context.fresh();
|
|
67
|
+
if (!current || current.identity !== access.identity)
|
|
68
|
+
return;
|
|
69
|
+
// 先提交 offset;强杀可能丢失本条,但不能重放已授权的终端输入。
|
|
70
|
+
const claimed = await stateTransaction(access, state => {
|
|
71
|
+
if (update.update_id < state.offset)
|
|
72
|
+
return false;
|
|
73
|
+
state.offset = update.update_id + 1;
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
if (!claimed)
|
|
77
|
+
return;
|
|
78
|
+
if (update.callback_query) {
|
|
79
|
+
const callback = update.callback_query, chat = privateUser(callback.message, callback.from);
|
|
80
|
+
if (!chat || !current.policy.users.includes(chat))
|
|
81
|
+
return;
|
|
82
|
+
const value = callback.data;
|
|
83
|
+
if (typeof value === 'string' && value.startsWith('dn:q:')) {
|
|
84
|
+
const id = value.split(':')[2];
|
|
85
|
+
if (!/^[a-f0-9]{32}$/.test(id) || !await resolveEntry(access, 'token:' + id, context)) {
|
|
86
|
+
await context.api('answerCallbackQuery', { callback_query_id: callback.id, text: '会话已关闭,或这次交互已经失效。' }).catch(() => { });
|
|
87
|
+
await replyHint(context.api, chat, '⚠️ 这次提交没有生效', '会话已经关闭、切换,或交互已经失效,答案没有回传。').catch(() => { });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (await handleInteractionCallback(access, callback, chat, context.api))
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (typeof value !== 'string' || !/^dn:r:[a-f0-9]{32}$/.test(value) || typeof callback.id !== 'string')
|
|
94
|
+
return;
|
|
95
|
+
await context.api('answerCallbackQuery', { callback_query_id: callback.id, text: '请在回复框输入内容' });
|
|
96
|
+
await prompt(access, chat, callback.message.message_id, 'token:' + value.slice(5), context);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const message = update.message, chat = privateUser(message, message?.from);
|
|
100
|
+
if (!chat || typeof message.text !== 'string' || !Number.isSafeInteger(message.message_id))
|
|
101
|
+
return;
|
|
102
|
+
const text = message.text.trim();
|
|
103
|
+
if (text === '/chatid') {
|
|
104
|
+
await replyHint(context.api, chat, 'Telegram chat_id', chat, message.message_id);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!current.policy.users.includes(chat))
|
|
108
|
+
return;
|
|
109
|
+
if (typeof message.date === 'number' && Date.now() / 1000 - message.date > 300) {
|
|
110
|
+
await replyHint(context.api, chat, '⚠️ 本次提交没有生效', '这条消息已经超过有效时间,答案没有回传。请查看最新问题或终端状态。');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (/^\/start\s+[a-f0-9]{32}$/.test(text)) {
|
|
114
|
+
// 飞书跳转:只发普通提示并清除旧键盘状态,不自动进入回复。
|
|
115
|
+
await prompt(access, chat, message.message_id, 'token:' + text.split(/\s+/)[1], context, false);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const replyTo = message.reply_to_message?.message_id;
|
|
119
|
+
if (!Number.isSafeInteger(replyTo)) {
|
|
120
|
+
await replyHint(context.api, chat, '💬 回复方式', '请直接回复任务完成通知,或点击通知里的「回复 Claude」按钮。', message.message_id);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const resolved = await resolveEntry(access, `tg:${chat}:${replyTo}`, context);
|
|
124
|
+
if (!resolved) {
|
|
125
|
+
await replyHint(context.api, chat, '⚠️ 无法回复', '通知已过期,或对应会话已经关闭、切换。请从最新通知回复。', message.message_id);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (resolved.entry.request) {
|
|
129
|
+
await handleInteractionText(access, resolved.entry.request, chat, message, context.api);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let safe;
|
|
133
|
+
try {
|
|
134
|
+
safe = sanitizeReply(message.text);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
await replyHint(context.api, chat, '❌ 回复没有提交', error instanceof ConfigError ? error.message : '输入无效。', message.message_id);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const id = randomBytes(16).toString('hex');
|
|
141
|
+
const queued = await stateTransaction(access, state => {
|
|
142
|
+
const target = state.sessions[resolved.target.session];
|
|
143
|
+
if (!target || sessionEpoch(target) !== resolved.entry.epoch)
|
|
144
|
+
throw new ConfigError('会话已经变化,这次回复没有提交。');
|
|
145
|
+
if (state.pending[target.session] && Date.now() - state.pending[target.session].updated < 5 * 60 * 1000)
|
|
146
|
+
return null;
|
|
147
|
+
state.pending[target.session] = { hash: textHash(safe), text: safe, epoch: resolved.entry.epoch, id, updated: Date.now(), queued: target.working,
|
|
148
|
+
phase: 'queued', chat, message: message.message_id };
|
|
149
|
+
return target.working;
|
|
150
|
+
});
|
|
151
|
+
if (queued === null) {
|
|
152
|
+
await replyHint(context.api, chat, '⏳ 新回复没有提交', '这个会话已有一条待确认的回复。请先等待确认,或检查本机终端后再试。', message.message_id);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (queued)
|
|
156
|
+
await replyHint(context.api, chat, '⏳ 回复已排队', 'Claude 正在处理当前任务;本地队列将在本轮结束后尝试提交,不会把文本输入到正在等待权限的弹窗。', message.message_id);
|
|
157
|
+
else
|
|
158
|
+
await drainReplies(access, context);
|
|
159
|
+
}
|
|
160
|
+
export async function drainReplies(access, context) {
|
|
161
|
+
const queued = await stateTransaction(access, state => Object.entries(state.pending)
|
|
162
|
+
.filter(([session, value]) => value.phase === 'queued' && (!state.sessions[session]?.working || Date.now() - value.updated > 5 * 60 * 1000))
|
|
163
|
+
.map(([session, pending]) => ({ target: state.sessions[session], pending })));
|
|
164
|
+
for (const item of queued) {
|
|
165
|
+
const { target, pending } = item;
|
|
166
|
+
if (!target || !pending.text)
|
|
167
|
+
continue;
|
|
168
|
+
try {
|
|
169
|
+
const current = await context.fresh();
|
|
170
|
+
if (!current || current.identity !== access.identity || !current.policy.users.includes(pending.chat))
|
|
171
|
+
return;
|
|
172
|
+
if (Date.now() - pending.updated > 5 * 60 * 1000)
|
|
173
|
+
throw new ConfigError('排队的回复已超过 5 分钟,没有提交。请检查本机后重新回复。');
|
|
174
|
+
if (!target.tracked || pending.epoch !== sessionEpoch(target) || !await (context.validate ?? (value => validateTarget(value)))(target))
|
|
175
|
+
throw new ConfigError('会话已经关闭或切换,这次回复没有提交。');
|
|
176
|
+
const text = pending.text;
|
|
177
|
+
const claimed = await stateTransaction(access, state => {
|
|
178
|
+
const actual = state.pending[target.session], active = state.sessions[target.session];
|
|
179
|
+
if (!actual || actual.id !== pending.id || actual.phase !== 'queued' || active?.working || sessionEpoch(active) !== sessionEpoch(target))
|
|
180
|
+
return false;
|
|
181
|
+
actual.phase = 'submitted';
|
|
182
|
+
delete actual.text;
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
if (!claimed)
|
|
186
|
+
continue;
|
|
187
|
+
// 输入前再次核验授权,撤销后不发送。原生调用一旦开始,不能撤回或保证无竞态。
|
|
188
|
+
const final = await context.fresh();
|
|
189
|
+
if (!final || final.identity !== access.identity)
|
|
190
|
+
return;
|
|
191
|
+
await (context.inject ?? ((value, content) => injectReply(value, content)))(target, text);
|
|
192
|
+
await replyHint(context.api, pending.chat, '✅ 已转发给 Claude', '终端输入已提交,正在等待 UserPromptSubmit 确认。', pending.message);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
await stateTransaction(access, state => {
|
|
196
|
+
const value = state.pending[target.session];
|
|
197
|
+
if (value?.id === pending.id) {
|
|
198
|
+
value.phase = 'unknown';
|
|
199
|
+
delete value.text;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
await replyHint(context.api, pending.chat, '⚠️ 转发状态待确认', error instanceof ConfigError ? error.message : '提交没有完成,请检查本机终端;不会自动重复提交。', pending.message).catch(() => { });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -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, questionsContent, 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 已收到回复', 'Claude 已收到你提交的回复,任务完成后会再发通知。', 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: 'Claude 已收到你提交的回复,任务完成后会再发通知。为减少扩散,这里不转发回复原文。',
|
|
65
|
+
footer: '会话:' + data.session_id }, access.config), access.config);
|
|
66
|
+
if (!result.ok)
|
|
67
|
+
options.err?.('飞书:收到确认没有完整发送;已提交的输入不会重放。');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
options.err?.('飞书:收到确认发送失败;已提交的输入不会重放。');
|
|
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' ? questionsContent(request.questions) : '工具权限请求:' + 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
|
+
}
|