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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ import path from 'node:path';
2
+ import { ConfigError, decodeUtf8, loadConfig, ready } from './config.js';
3
+ import { makePlan } from './channels.js';
4
+ import { NOTIFICATION_MATCHER } from './claude-code.js';
5
+ import { lifecycleHook, interactionHook, attachReply, mapReplies, notificationCoverage } from './reply-hooks.js';
6
+ export const MAX_HOOK_BYTES = 8 * 1024 * 1024;
7
+ export async function readHookInput(input = process.stdin, limit = MAX_HOOK_BYTES) {
8
+ const chunks = [];
9
+ let size = 0;
10
+ const timer = setTimeout(() => input.destroy(new ConfigError('Claude Code hook 输入超时,未发送通知')), 15000);
11
+ timer.unref();
12
+ try {
13
+ for await (const chunk of input) {
14
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
15
+ size += bytes.length;
16
+ if (size > limit)
17
+ throw new ConfigError('Claude Code hook 输入超过大小限制,未截断发送');
18
+ chunks.push(bytes);
19
+ }
20
+ return Buffer.concat(chunks);
21
+ }
22
+ finally {
23
+ clearTimeout(timer);
24
+ }
25
+ }
26
+ function metadata(value, limit) {
27
+ if (typeof value !== 'string')
28
+ return '';
29
+ const clean = value.replace(/[\x00-\x1f\x7f]/g, ' ');
30
+ const chars = Array.from(clean);
31
+ return chars.length > limit ? chars.slice(0, limit).join('') + '…' : clean;
32
+ }
33
+ export function claudeHookMessage(bytes) {
34
+ if (bytes.byteLength > MAX_HOOK_BYTES)
35
+ throw new ConfigError('Claude Code hook 输入超过大小限制,未截断发送');
36
+ let data;
37
+ try {
38
+ data = JSON.parse(decodeUtf8(bytes));
39
+ }
40
+ catch {
41
+ throw new ConfigError('Claude Code hook 输入不是合法 UTF-8 JSON,未发送通知');
42
+ }
43
+ if (!data || typeof data !== 'object' || Array.isArray(data))
44
+ throw new ConfigError('Claude Code hook 输入必须是 JSON 对象');
45
+ if (data.hook_event_name !== 'Stop' && data.hook_event_name !== 'Notification')
46
+ return null;
47
+ const footer = [typeof data.cwd === 'string' ? `项目:${metadata(path.win32.basename(data.cwd), 120)}` : '',
48
+ typeof data.session_id === 'string' ? `会话:${metadata(data.session_id, 120)}` : ''].filter(Boolean).join('\n');
49
+ if (data.hook_event_name === 'Stop') {
50
+ if (data.stop_hook_active !== undefined && typeof data.stop_hook_active !== 'boolean')
51
+ throw new ConfigError('Claude Code Stop 状态字段无效');
52
+ if (data.stop_hook_active === true)
53
+ return null;
54
+ if (data.last_assistant_message !== undefined && typeof data.last_assistant_message !== 'string')
55
+ throw new ConfigError('Claude Code 最终回复字段无效');
56
+ return { title: 'Claude Code 回复完成', level: 'success', footer,
57
+ content: data.last_assistant_message?.trim() ? data.last_assistant_message
58
+ : '本轮回复已结束,但 Claude Code 未提供最终正文。请在终端查看回复;不会读取可能滞后的会话文件来代替正文。' };
59
+ }
60
+ // 空闲提醒在 Stop 之后重复触发;只转发明确需要用户处理的通知。
61
+ if (!NOTIFICATION_MATCHER.split('|').includes(data.notification_type))
62
+ return null;
63
+ if (data.message !== undefined && typeof data.message !== 'string')
64
+ throw new ConfigError('Claude Code 通知正文字段无效');
65
+ return { title: 'Claude Code 需要你处理', level: 'warn', footer,
66
+ content: data.message?.trim() ? data.message : 'Claude Code 正在等待你的确认或输入,请回到终端处理。' };
67
+ }
68
+ export async function dispatchClaudeHook(bytes, configFile, context) {
69
+ const message = claudeHookMessage(bytes);
70
+ const data = JSON.parse(decodeUtf8(bytes));
71
+ const options = { send: context.send, ...context.reply, env: context.env, err: context.err, response: context.response };
72
+ try {
73
+ await lifecycleHook(data, configFile, options);
74
+ await interactionHook(data, configFile, options);
75
+ }
76
+ catch {
77
+ context.err('Claude Code 回复状态或交互处理失败;未自动选择答案/批准权限,请在本机处理。');
78
+ }
79
+ if (!message)
80
+ return;
81
+ let binding = null;
82
+ try {
83
+ binding = await attachReply(message, data, configFile, options);
84
+ }
85
+ catch {
86
+ context.err('远程回复未就绪;仍尝试发送普通通知。');
87
+ }
88
+ const config = await loadConfig(configFile, true, context.env);
89
+ let covered = { telegram: [], feishu: false };
90
+ try {
91
+ covered = await notificationCoverage(data, configFile, config, options);
92
+ }
93
+ catch {
94
+ context.err('无法核验选择题通知覆盖情况;保留普通权限提醒。');
95
+ }
96
+ let channels = 0;
97
+ for (const channel of ['telegram', 'feishu']) {
98
+ if (!ready(config, channel))
99
+ continue;
100
+ channels++;
101
+ try {
102
+ if (channel === 'feishu' && covered.feishu)
103
+ continue;
104
+ const plan = makePlan(channel, message, config);
105
+ if (channel === 'telegram' && covered.telegram.length) {
106
+ plan.items = plan.items.filter(item => !covered.telegram.includes(item.recipient));
107
+ plan.recipients = new Set(plan.items.map(item => item.recipient)).size;
108
+ if (!plan.items.length)
109
+ continue;
110
+ }
111
+ if (binding)
112
+ mapReplies(plan, binding);
113
+ const result = await context.send(plan, config);
114
+ if (!result.ok)
115
+ context.err(`${channel}: Claude Code 通知未完整发送;不自动重发整条消息。`);
116
+ }
117
+ catch {
118
+ context.err(`${channel}: Claude Code 通知发送失败;请检查配置、系统凭据及网络,不自动重发整条消息。`);
119
+ }
120
+ }
121
+ if (!channels)
122
+ throw new ConfigError('Claude Code 通知没有可用渠道,请运行 dev-notify doctor 检查');
123
+ }
package/dist/cli.js CHANGED
@@ -9,18 +9,29 @@ import { deliver, makePlan } from './channels.js';
9
9
  import { wizard, clearCredentials } from './setup.js';
10
10
  import { terminalPrompts, readText, Cancelled } from './prompts.js';
11
11
  import { CredentialError } from './credentials.js';
12
- const HELP = `dev-notify 0.1.0-alpha.1 — 跨平台开发任务通知
12
+ import { claudeCodeStatus, configureClaudeCode, offerClaudeCode } from './claude-code.js';
13
+ import { dispatchClaudeHook, readHookInput } from './claude-hook.js';
14
+ import { accessReplies, configureReplies, disableReplies } from './reply-state.js';
15
+ import { startBridge, stopBridge, bridgeStatus, runBridge } from './reply-bridge.js';
16
+ const HELP = `dev-notify 0.1.0-alpha.3 — 跨平台开发任务通知
13
17
 
14
18
  命令:
15
19
  send --title 标题 [--content 正文 | --content-file 文件 | --stdin]
16
20
  [--channel all|telegram|feishu] [--level info|success|warn|error]
17
21
  [--footer 辅助信息] [--dry-run]
18
- setup 交互配置渠道并存入系统凭据,保存后可选测试
19
- setup --template [--yes] 仅创建无密钥模板,不覆盖已有配置
22
+ setup 配置渠道及系统凭据,可选测试与 Claude Code 通知
23
+ setup --template [--yes] 仅创建无密钥模板,不覆盖已有配置、不安装 hooks
20
24
  credentials clear 确认后移除当前 Node 凭据引用和条目
21
25
  paths 显示配置路径,不写文件
22
26
  doctor / status 离线检查,不发消息
23
- integration list 显示集成迁移状态
27
+ integration list 列出可用集成
28
+ integration claude-code install / status / remove
29
+ 单独安装、查询或移除用户级 hooks
30
+ reply setup 另行授权可信 Telegram 私聊用户
31
+ reply start / status / stop
32
+ 启动、查询或停止回复桥接
33
+ reply disable 确认后撤销远程回复授权并请求停止桥接
34
+ reply run 前台运行桥接(排错用,不启用系统自启动)
24
35
 
25
36
  全局选项:--config 路径、--help、--version
26
37
  系统凭据使用 Windows Credential Manager / macOS Keychain;不调用 Python,不写入明文密钥。`;
@@ -30,6 +41,7 @@ const defaults = {
30
41
  err: text => console.error(text),
31
42
  stdin: async () => { const chunks = []; for await (const chunk of process.stdin)
32
43
  chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks); },
44
+ hookStdin: () => readHookInput(),
33
45
  confirm: async () => {
34
46
  if (!process.stdin.isTTY)
35
47
  throw new ConfigError('非交互环境请使用 setup --template --yes');
@@ -39,6 +51,7 @@ const defaults = {
39
51
  };
40
52
  export async function main(argv = process.argv.slice(2), overrides = {}) {
41
53
  const context = { ...defaults, ...overrides };
54
+ const isHook = argv[0] === 'integration' && argv[1] === 'claude-code' && argv[2] === 'hook';
42
55
  try {
43
56
  let parsed;
44
57
  try {
@@ -58,15 +71,15 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
58
71
  return 0;
59
72
  }
60
73
  if (values.version) {
61
- context.out('dev-notify 0.1.0-alpha.1');
74
+ context.out('dev-notify 0.1.0-alpha.3');
62
75
  return 0;
63
76
  }
64
- const [command, action] = positionals;
77
+ const [command, action, integrationAction] = positionals;
65
78
  const allowed = {
66
79
  send: ['title', 'content', 'content-file', 'stdin', 'channel', 'level', 'footer', 'dry-run'],
67
- setup: ['template', 'yes'], paths: [], doctor: [], status: [], integration: [], credentials: [],
80
+ setup: ['template', 'yes'], paths: [], doctor: [], status: [], integration: [], credentials: [], reply: [],
68
81
  };
69
- if (!command || !Object.hasOwn(allowed, command) || positionals.length > (['integration', 'credentials'].includes(command) ? 2 : 1)
82
+ if (!command || !Object.hasOwn(allowed, command) || positionals.length > (command === 'integration' ? 3 : command === 'credentials' || command === 'reply' ? 2 : 1)
70
83
  || Object.keys(values).some(key => key !== 'config' && !allowed[command].includes(key)))
71
84
  throw new ConfigError('命令或选项组合无效,请使用 --help');
72
85
  const paths = appPaths(process.platform, context.env);
@@ -76,10 +89,69 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
76
89
  return 0;
77
90
  }
78
91
  if (command === 'integration') {
79
- if (action !== 'list')
80
- throw new ConfigError('当前只支持 integration list');
81
- context.out('claude-code: 尚未迁入 Node 版,未安装 hooks 或启用远程回复');
82
- return 0;
92
+ if (action === 'list' && !integrationAction) {
93
+ context.out('claude-code: 可选通知集成、Telegram 回复与结构化问题交互;通知 hooks 与 reply setup 输入授权分开,默认不开启远程输入。');
94
+ return 0;
95
+ }
96
+ if (action !== 'claude-code' || !['install', 'status', 'remove', 'hook'].includes(integrationAction))
97
+ throw new ConfigError('请使用 integration list 或 integration claude-code install / status / remove');
98
+ if (integrationAction === 'hook') {
99
+ await dispatchClaudeHook(await context.hookStdin(), file, { ...context, response: value => context.out(JSON.stringify(value)) });
100
+ return 0;
101
+ }
102
+ if (integrationAction === 'status') {
103
+ const status = await claudeCodeStatus({ env: context.env });
104
+ context.out(`Claude Code 用户级设置:${status.file}`);
105
+ context.out(`本工具 hooks:Stop ${status.stop} 个,Notification ${status.notification} 个,会话生命周期 ${status.lifecycle}/3 个,交互及结果确认 ${status.interaction}/5 个;其他处理器 ${status.others} 个。`);
106
+ context.out(status.disabled ? '当前文件设置了 disableAllHooks;本工具不会解除禁用。'
107
+ : '仅检查用户级文件;实际是否加载或受项目、托管策略限制,请在 Claude Code /hooks 中确认。');
108
+ return status.stop === 1 && status.notification === 1 && status.lifecycle === 3 && status.interaction === 5 && !status.disabled ? 0 : 1;
109
+ }
110
+ const prompts = context.prompts ?? terminalPrompts();
111
+ if (integrationAction === 'install') {
112
+ const config = await loadConfig(file, true, context.env);
113
+ if (!ready(config, 'telegram') && !ready(config, 'feishu'))
114
+ throw new ConfigError('请先运行 setup 配置可用通知渠道,再安装 Claude Code hooks');
115
+ }
116
+ return await configureClaudeCode(file, integrationAction, prompts, { env: context.env }) ? 0 : 130;
117
+ }
118
+ if (command === 'reply') {
119
+ if (!['setup', 'start', 'status', 'stop', 'disable', 'run'].includes(action))
120
+ throw new ConfigError('请使用 reply setup / start / status / stop / disable / run');
121
+ if (action === 'setup') {
122
+ const saved = await configureReplies(file, context.prompts ?? terminalPrompts(), context.env);
123
+ if (saved)
124
+ context.out('授权已保存;请确认已安装本版 hooks,再运行 reply start。新授权不会恢复旧消息映射。');
125
+ return saved ? 0 : 130;
126
+ }
127
+ if (action === 'disable') {
128
+ if (!await disableReplies(file, context.prompts ?? terminalPrompts()))
129
+ return 130;
130
+ const stopped = await stopBridge(file);
131
+ context.out(stopped ? '桥接已停止或未运行。' : '已撤销授权并请求停止;正在进行中的调用不能撤回。');
132
+ return 0;
133
+ }
134
+ if (action === 'stop') {
135
+ const stopped = await stopBridge(file);
136
+ context.out(stopped ? '桥接已停止或未运行;授权仍保留,下一次事件可再次启动。彻底关闭请用 reply disable。'
137
+ : '停止请求已登记;桥接尚未退出,未强杀进程,请用 reply status 检查。');
138
+ return stopped ? 0 : 1;
139
+ }
140
+ if (action === 'status') {
141
+ const access = await accessReplies(file, context.env), status = await bridgeStatus(file);
142
+ context.out(access ? `远程回复:已另行授权 ${access.policy.users.length} 个私聊用户;逐次工具权限${access.policy.permissions ? '已启用' : '未启用'}。` : '远程回复:未启用。');
143
+ context.out(status.ready ? '桥接:已就绪。' : status.running ? '桥接:进程存在但未就绪。' : '桥接:未运行。');
144
+ if (status.error)
145
+ context.err(status.error);
146
+ return access && status.ready ? 0 : 1;
147
+ }
148
+ if (action === 'run') {
149
+ await runBridge(file, { env: context.env, log: context.err });
150
+ return 0;
151
+ }
152
+ const ready = await startBridge(file, context.env);
153
+ context.out(ready ? '回复桥接已就绪;请从新任务通知进入。' : '桥接尚未就绪;请用 reply status / reply run 查看安全错误,不要同时运行旧 Python 桥接。');
154
+ return ready ? 0 : 1;
83
155
  }
84
156
  if (command === 'credentials') {
85
157
  if (action !== 'clear')
@@ -90,8 +162,9 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
90
162
  if (!values.template) {
91
163
  if (values.yes)
92
164
  throw new ConfigError('--yes 只允许与 --template 同时使用,不能跳过密钥保存确认');
93
- return await wizard(file, {
94
- prompts: context.prompts, env: context.env,
165
+ const prompts = context.prompts ?? terminalPrompts();
166
+ const saved = await wizard(file, {
167
+ prompts, store: context.store, env: context.env,
95
168
  testSend: async (config) => {
96
169
  let count = 0, ok = true;
97
170
  for (const channel of ['telegram', 'feishu']) {
@@ -105,7 +178,26 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
105
178
  }
106
179
  return count > 0 && ok;
107
180
  },
108
- }) ? 0 : 130;
181
+ });
182
+ if (!saved)
183
+ return 130;
184
+ try {
185
+ await offerClaudeCode(file, prompts, { env: context.env });
186
+ const config = await loadConfig(file, true, context.env);
187
+ if (ready(config, 'telegram') && /^(y|yes)$/i.test(await prompts.text('是否另行配置可信私聊用户回复 Claude?默认关闭。[y/N] '))) {
188
+ await configureReplies(file, prompts, context.env);
189
+ context.out('回复授权流程结束;确认 hooks 已安装后可运行 reply start,或由下一次支持的会话事件启动。');
190
+ }
191
+ }
192
+ catch (error) {
193
+ if (error instanceof Cancelled) {
194
+ context.out('渠道配置已保存;已取消后续 Claude Code 集成/回复配置。');
195
+ return 0;
196
+ }
197
+ context.err('渠道配置已保存,但 Claude Code hook 或回复配置未完成;请运行 integration claude-code status / reply status 检查后重试。');
198
+ return 1;
199
+ }
200
+ return 0;
109
201
  }
110
202
  context.out(`将创建无密钥模板: ${file};不会联网或修改 hooks`);
111
203
  if (!values.yes && !await context.confirm()) {
@@ -174,10 +266,11 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
174
266
  catch (error) {
175
267
  if (error instanceof Cancelled) {
176
268
  context.out('已取消,未继续执行。');
177
- return 130;
269
+ return isHook ? 0 : 130;
178
270
  }
179
271
  context.err(error instanceof ConfigError || error instanceof CredentialError ? error.message : '操作失败,请检查文件、权限或输入;不输出可能含秘密的底层异常');
180
- return 2;
272
+ // Stop hook 的退出码 2 会要求 Claude 继续;通知失败只能报告,不能阻塞或触发重试循环。
273
+ return isHook ? 0 : 2;
181
274
  }
182
275
  }
183
276
  if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
@@ -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
+ }