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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
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
+ // 仅用于通知展示:按 sessionId 读取本地会话注册表名称,不做进程身份核验,失败时返回空。
99
+ export async function findSessionName(session, options = {}) {
100
+ if (!session || !/^[A-Za-z0-9_-]{1,128}$/.test(session))
101
+ return '';
102
+ const directory = path.join(path.dirname(claudeSettingsPath(options.env, options.home ?? homedir())), 'sessions');
103
+ let names;
104
+ try {
105
+ names = await readdir(directory);
106
+ }
107
+ catch {
108
+ return '';
109
+ }
110
+ for (const name of names) {
111
+ if (!/^\d+\.json$/.test(name))
112
+ continue;
113
+ try {
114
+ const bytes = await readFile(path.join(directory, name));
115
+ if (bytes.length > 128 * 1024)
116
+ continue;
117
+ const d = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
118
+ if (d.sessionId === session && typeof d.name === 'string' && d.name.trim())
119
+ return d.name;
120
+ }
121
+ catch { /* 其他损坏或写入中的会话文件不提供名称。 */ }
122
+ }
123
+ return '';
124
+ }
125
+ export async function validateTarget(target, options = {}) {
126
+ const current = await findSession(target.session, options);
127
+ return current !== null && current.pid === target.pid && current.birth === target.birth && current.tty === target.tty;
128
+ }
129
+ 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}
130
+ Add-Type -TypeDefinition @'
131
+ using System;
132
+ using System.Collections.Generic;
133
+ using System.Runtime.InteropServices;
134
+ public static class DevNotifyConsole {
135
+ [StructLayout(LayoutKind.Explicit, Size=20, CharSet=CharSet.Unicode)] public struct Record {
136
+ [FieldOffset(0)] public ushort type;
137
+ [FieldOffset(4)] public int down;
138
+ [FieldOffset(8)] public ushort repeat;
139
+ [FieldOffset(10)] public ushort key;
140
+ [FieldOffset(14)] public char ch;
141
+ }
142
+ [DllImport("kernel32.dll")] static extern bool FreeConsole();
143
+ [DllImport("kernel32.dll")] static extern bool AttachConsole(uint pid);
144
+ [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern IntPtr CreateFileW(string n,uint a,uint s,IntPtr p,uint c,uint f,IntPtr t);
145
+ [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern bool WriteConsoleInputW(IntPtr h,Record[] r,uint n,out uint written);
146
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
147
+ [DllImport("kernel32.dll")] static extern uint GetConsoleProcessList([Out] uint[] ids,uint count);
148
+ public static bool Send(uint pid,string text) {
149
+ FreeConsole(); if(!AttachConsole(pid))return false;
150
+ IntPtr h=new IntPtr(-1);
151
+ try {
152
+ var ids=new uint[1024];uint count=GetConsoleProcessList(ids,(uint)ids.Length);
153
+ if(count==0||count>ids.Length)return false;
154
+ 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;}}
155
+ h=CreateFileW("CONIN$",0xC0000000,3,IntPtr.Zero,3,0,IntPtr.Zero);
156
+ if(h==new IntPtr(-1))return false;
157
+ var records=new List<Record>();
158
+ 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});}
159
+ records.Add(new Record{type=1,down=1,repeat=1,key=13,ch='\\r'});records.Add(new Record{type=1,repeat=1,key=13});
160
+ uint written;var all=records.ToArray();return WriteConsoleInputW(h,all,(uint)all.Length,out written)&&written==all.Length;
161
+ } finally {if(h!=new IntPtr(-1))CloseHandle(h);FreeConsole();}
162
+ }
163
+ }
164
+ '@
165
+ if([DevNotifyConsole]::Send([uint32]$x.pid,[string]$x.text)){exit 0}else{exit 4}`;
166
+ export async function injectReply(target, text, options = {}) {
167
+ const safe = sanitizeReply(text), runner = options.runner ?? runCommand, platform = options.platform ?? process.platform;
168
+ if (!await validateTarget(target, options))
169
+ throw new ConfigError('会话已经关闭、切换,或无法核验身份。请从最新通知回复。');
170
+ if (platform === 'win32') {
171
+ const result = await powershell(WINDOWS_INJECT, { pid: target.pid, birth: target.birth, text: safe }, runner);
172
+ if (result.code !== 0)
173
+ throw new ConfigError('Windows 控制台注入失败,可能只写入了一部分。不会自动重试,请检查终端。');
174
+ return;
175
+ }
176
+ if (platform !== 'darwin')
177
+ throw new ConfigError('远程回复仅支持 Windows 和 macOS。');
178
+ // 先确定 tmux pane,字面文本通过 stdin 缓冲区传入,绝不作为 shell 或按键名称执行。
179
+ let listing = null;
180
+ try {
181
+ listing = await runner('tmux', ['list-panes', '-a', '-F', '#{pane_id} #{pane_tty}']);
182
+ }
183
+ catch { /* Ghostty 回退仅在尚未发送任何输入时使用。 */ }
184
+ 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;
185
+ if (pane) {
186
+ const name = 'dev-notify-' + target.pid + '-' + Date.now();
187
+ const loaded = await runner('tmux', ['load-buffer', '-b', name, '-'], safe);
188
+ if (loaded.code !== 0)
189
+ throw new ConfigError('tmux 输入缓冲区创建失败,这次回复没有提交。');
190
+ try {
191
+ if (!await validateTarget(target, options))
192
+ throw new ConfigError('会话已经变化,这次回复没有提交。');
193
+ if ((await runner('tmux', ['paste-buffer', '-d', '-b', name, '-t', pane])).code !== 0)
194
+ throw new ConfigError('tmux 粘贴失败,可能只送达了一部分。不会自动重试。');
195
+ if ((await runner('tmux', ['send-keys', '-t', pane, 'Enter'])).code !== 0)
196
+ throw new ConfigError('tmux 提交失败,文本可能已经输入。不会自动重试。');
197
+ }
198
+ finally {
199
+ await runner('tmux', ['delete-buffer', '-b', name]).catch(() => { });
200
+ }
201
+ return;
202
+ }
203
+ const escape = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
204
+ const action = 'text:' + safe.replace(/\\/g, '\\\\') + '\\x0d';
205
+ 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`;
206
+ const result = await runner('/usr/bin/osascript', ['-'], script);
207
+ if (result.code !== 0 || result.stdout.trim() !== 'ok')
208
+ throw new ConfigError('Ghostty 没有找到目标窗口,或注入失败。不会改投其他窗口,也不会重复输入。');
209
+ }
@@ -0,0 +1,189 @@
1
+ import { readFile, mkdir, open, rename, unlink } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomBytes, createHash } from 'node:crypto';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { ConfigError, decodeUtf8, loadConfig, ready } from './config.js';
6
+ import { replyHooksReady } from './claude-code.js';
7
+ export const TTL = 24 * 60 * 60 * 1000;
8
+ export const digest = (text) => createHash('sha256').update(text).digest('hex');
9
+ export const botKey = (config) => digest(config.telegram.token);
10
+ export const targetEpoch = (target) => digest(JSON.stringify([target.session, target.pid, target.birth, target.tty]));
11
+ export const policyPath = (config) => config + '.reply.json';
12
+ export const replyDirectory = (config) => config + '.reply-data';
13
+ export async function readJson(file) {
14
+ try {
15
+ const bytes = await readFile(file);
16
+ if (bytes.length > 16 * 1024 * 1024)
17
+ throw new Error();
18
+ return JSON.parse(decodeUtf8(bytes));
19
+ }
20
+ catch (error) {
21
+ if (error.code === 'ENOENT')
22
+ return null;
23
+ throw new ConfigError('无法读取回复配置或状态。为了不覆盖现有数据,远程输入没有启用。');
24
+ }
25
+ }
26
+ export async function atomicJson(file, value) {
27
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
28
+ const temporary = path.join(path.dirname(file), `.reply-${randomBytes(12).toString('hex')}.tmp`);
29
+ const handle = await open(temporary, 'wx', 0o600);
30
+ try {
31
+ try {
32
+ await handle.writeFile(JSON.stringify(value) + '\n', 'utf8');
33
+ await handle.sync();
34
+ }
35
+ finally {
36
+ await handle.close();
37
+ }
38
+ await rename(temporary, file);
39
+ }
40
+ finally {
41
+ await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
42
+ throw error; });
43
+ }
44
+ }
45
+ export async function locked(file, work) {
46
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
47
+ let handle;
48
+ for (let attempt = 0; attempt < 100; attempt++) {
49
+ try {
50
+ handle = await open(file, 'wx', 0o600);
51
+ break;
52
+ }
53
+ catch (error) {
54
+ if (error.code !== 'EEXIST')
55
+ throw new ConfigError('无法获取回复状态写入锁。');
56
+ if (attempt === 99)
57
+ throw new ConfigError('回复状态锁正忙。请确认相关进程已经退出,再处理残留锁。');
58
+ await delay(40);
59
+ }
60
+ }
61
+ try {
62
+ return await work();
63
+ }
64
+ finally {
65
+ await handle?.close();
66
+ await unlink(file);
67
+ }
68
+ }
69
+ export async function accessReplies(file, env = process.env) {
70
+ const policy = await readJson(policyPath(file));
71
+ if (policy === null || policy.enabled === false)
72
+ return null;
73
+ if (policy.version !== 1 || policy.enabled !== true || typeof policy.bot !== 'string' || !/^[a-f0-9]{64}$/.test(policy.bot)
74
+ || typeof policy.generation !== 'string' || !/^[a-f0-9]{32}$/.test(policy.generation)
75
+ || !Array.isArray(policy.users) || !policy.users.length || policy.users.some((id) => typeof id !== 'string' || !/^[1-9][0-9]*$/.test(id))) {
76
+ throw new ConfigError('回复授权配置无效,远程输入已停用。');
77
+ }
78
+ if (!await replyHooksReady(file, { env }))
79
+ throw new ConfigError('回复所需的 hooks 缺失、时序不安全或已禁用。请重新运行 integration claude-code install,并在 /hooks 中确认加载。');
80
+ const config = await loadConfig(file, true, env);
81
+ if (!ready(config, 'telegram') || botKey(config) !== policy.bot)
82
+ throw new ConfigError('Telegram Bot 已变更或不可用,请重新运行 reply setup 授权。');
83
+ const users = policy.users.filter((id) => config.telegram.chatIds.includes(id));
84
+ if (!users.length)
85
+ throw new ConfigError('授权用户不在当前 Telegram 接收名单中,远程输入已停用。');
86
+ const effective = { ...policy, users };
87
+ return { file, config, policy: effective, identity: digest(JSON.stringify([policy.bot, policy.generation, users, policy.permissions === true])) };
88
+ }
89
+ export async function stateTransaction(access, work) {
90
+ const directory = replyDirectory(access.file);
91
+ return locked(path.join(directory, 'state.lock'), async () => {
92
+ const policy = await readJson(policyPath(access.file));
93
+ if (!policy?.enabled || policy.bot !== access.policy.bot || policy.generation !== access.policy.generation
94
+ || (policy.permissions === true) !== (access.policy.permissions === true))
95
+ throw new ConfigError('回复授权已撤销或发生变化,没有写入旧状态。');
96
+ let state = await readJson(path.join(directory, 'state.json'));
97
+ if (!state || state.identity !== access.identity)
98
+ state = { version: 1, identity: access.identity, offset: 0, sessions: {}, entries: {}, pending: {}, requests: {}, calls: {} };
99
+ const record = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
100
+ state.requests ??= {};
101
+ state.calls ??= {};
102
+ if (state.version !== 1 || !Number.isSafeInteger(state.offset) || state.offset < 0
103
+ || ![state.sessions, state.entries, state.pending, state.requests, state.calls].every(record))
104
+ throw new ConfigError('回复状态结构无效,远程输入已停用。');
105
+ const now = Date.now();
106
+ for (const section of [state.sessions, state.entries, state.pending, state.requests, state.calls])
107
+ for (const [key, value] of Object.entries(section)) {
108
+ if (!record(value) || typeof value.updated !== 'number' || !Number.isFinite(value.updated)
109
+ || value.updated > now + 60000 || now - value.updated > TTL)
110
+ delete section[key];
111
+ }
112
+ for (const [key, value] of Object.entries(state.sessions)) {
113
+ if (value.session !== key || !Number.isSafeInteger(value.pid) || value.pid <= 0 || typeof value.birth !== 'string'
114
+ || typeof value.tty !== 'string' || typeof value.working !== 'boolean')
115
+ delete state.sessions[key];
116
+ }
117
+ for (const [key, value] of Object.entries(state.entries)) {
118
+ if (typeof value.session !== 'string' || typeof value.epoch !== 'string')
119
+ delete state.entries[key];
120
+ }
121
+ for (const [key, value] of Object.entries(state.pending)) {
122
+ if (typeof value.epoch !== 'string' || !/^[1-9][0-9]*$/.test(value.chat) || typeof value.id !== 'string'
123
+ || !['queued', 'submitted', 'unknown'].includes(value.phase))
124
+ delete state.pending[key];
125
+ }
126
+ for (const [key, value] of Object.entries(state.requests)) {
127
+ if (value.id !== key || !['question', 'permission'].includes(value.kind) || !record(value.input)
128
+ || !Array.isArray(value.questions) || !record(value.answers) || !Array.isArray(value.selected)
129
+ || !Number.isFinite(value.expires))
130
+ delete state.requests[key];
131
+ }
132
+ const result = await work(state);
133
+ await atomicJson(path.join(directory, 'state.json'), state);
134
+ return result;
135
+ });
136
+ }
137
+ export async function configureReplies(file, prompts, env = process.env) {
138
+ if (!await replyHooksReady(file, { env }))
139
+ throw new ConfigError('请先运行 integration claude-code install,安装绑定此通知配置的完整 hooks,再配置回复。');
140
+ const config = await loadConfig(file, true, env);
141
+ if (!ready(config, 'telegram'))
142
+ throw new ConfigError('回复 Claude 需要先配置 Telegram Bot 和私聊接收人。');
143
+ const available = config.telegram.chatIds.filter(id => /^[1-9][0-9]*$/.test(id));
144
+ if (!available.length)
145
+ throw new ConfigError('远程回复仅支持 Telegram 私聊用户,不支持群聊。');
146
+ const before = await readJson(policyPath(file));
147
+ prompts.out('远程回复等同于在本机 Claude Code 终端输入并提交内容。它可以提交 Claude 命令,但不会自动批准工具权限。请只授权给可信用户。');
148
+ prompts.out('飞书按钮会跳转到 Telegram,不直接接收飞书消息。这个功能需要独占 Bot 的 getUpdates,请不要与旧版桥接或其他机器共用同一个 Bot。');
149
+ prompts.out('Windows 使用系统 PowerShell / 控制台 API;macOS 使用 Ghostty 或 tmux,需要相应的系统权限。');
150
+ prompts.out('目前不支持 VS Code 集成终端、WSL 和 SSH。目标不确定或会话已经切换时,会拒绝输入。');
151
+ prompts.out('通知接收人不会自动获得输入权限。可选择的私聊 chat_id:' + available.join('、'));
152
+ const text = await prompts.text('请输入要授权的私聊 chat_id(逗号分隔;留空取消):');
153
+ if (!text.trim())
154
+ return false;
155
+ const users = [...new Set(text.split(',').map(id => id.trim()))];
156
+ if (users.some(id => !available.includes(id)))
157
+ throw new ConfigError('只能授权当前通知名单中的私聊用户。');
158
+ prompts.out('将授权以下用户远程输入:' + users.join('、'));
159
+ prompts.out('结构化问题会展示真实选项,也支持自定义回答;普通回复会在本轮结束后输入。问题、选项和工具请求参数可能包含敏感信息,会发送到授权私聊和已配置的飞书。');
160
+ const permissions = /^(y|yes)$/i.test(await prompts.text('是否允许这些用户逐次批准或拒绝工具权限?这不会修改永久规则。[y/N] '));
161
+ if (!/^(y|yes)$/i.test(await prompts.text('确认启用远程回复并接受上述权限范围?[y/N] ')))
162
+ return false;
163
+ await locked(policyPath(file) + '.lock', async () => {
164
+ if (JSON.stringify(before) !== JSON.stringify(await readJson(policyPath(file))))
165
+ throw new ConfigError('回复授权已被其他进程修改,请重试。');
166
+ const effective = await loadConfig(file, true, env);
167
+ if (botKey(effective) !== botKey(config) || users.some(id => !effective.telegram.chatIds.includes(id)))
168
+ throw new ConfigError('通知配置在授权期间发生了变化,请重试。');
169
+ await atomicJson(policyPath(file), { version: 1, enabled: true, bot: botKey(config), users, permissions, generation: randomBytes(16).toString('hex') });
170
+ });
171
+ prompts.out('授权已保存。旧消息的回复映射不会保留;请从启用后的任务完成通知进入。');
172
+ return true;
173
+ }
174
+ export async function disableReplies(file, prompts) {
175
+ const before = await readJson(policyPath(file));
176
+ if (!before?.enabled) {
177
+ prompts.out('远程回复尚未启用。');
178
+ return true;
179
+ }
180
+ if (!/^(y|yes)$/i.test(await prompts.text('确认禁用远程回复?通知和凭据都会保留。[y/N] ')))
181
+ return false;
182
+ await locked(policyPath(file) + '.lock', async () => {
183
+ if (JSON.stringify(before) !== JSON.stringify(await readJson(policyPath(file))))
184
+ throw new ConfigError('回复授权发生了变化,请重试。');
185
+ await atomicJson(policyPath(file), { ...before, enabled: false });
186
+ });
187
+ prompts.out('远程回复已禁用。正在进行的操作无法撤回,桥接会在下次检查时退出。');
188
+ return true;
189
+ }
@@ -0,0 +1,19 @@
1
+ import { requestJson } from './channels.js';
2
+ import { ConfigError } from './config.js';
3
+ export function telegramApi(token, request = requestJson) {
4
+ return async (method, payload) => {
5
+ if (!['sendMessage', 'editMessageReplyMarkup', 'answerCallbackQuery', 'getMe', 'getUpdates', 'getWebhookInfo'].includes(method))
6
+ throw new ConfigError('不支持的 Telegram 桥接方法');
7
+ const response = await request(`https://api.telegram.org/bot${token}/${method}`, payload);
8
+ if (response.ok !== true) {
9
+ if (response.error_code === 409)
10
+ throw new ConfigError('Telegram 轮询冲突;检查旧版桥接、其他机器或 webhook,桥接不会抢占或删除 webhook');
11
+ throw new ConfigError('Telegram 桥接请求失败;不输出服务端正文或秘密');
12
+ }
13
+ return response.result;
14
+ };
15
+ }
16
+ export async function replyHint(api, chat, title, content, replyTo, markup) {
17
+ return api('sendMessage', { chat_id: chat, text: title + '\n\n' + content,
18
+ ...(replyTo ? { reply_parameters: { message_id: replyTo } } : {}), ...(markup ? { reply_markup: markup } : {}) });
19
+ }