@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.
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +245 -2
- package/SECURITY.md +76 -0
- package/dist/channels.js +158 -0
- package/dist/claude-code.js +236 -0
- package/dist/claude-hook.js +123 -0
- package/dist/cli.js +278 -0
- package/dist/config.js +141 -0
- package/dist/credentials.js +105 -0
- package/dist/formatting.js +210 -0
- package/dist/prompts.js +96 -0
- package/dist/reply-bridge.js +189 -0
- package/dist/reply-engine.js +199 -0
- package/dist/reply-hooks.js +230 -0
- package/dist/reply-interactions.js +262 -0
- package/dist/reply-outcomes.js +182 -0
- package/dist/reply-platform.js +182 -0
- package/dist/reply-state.js +189 -0
- package/dist/reply-telegram.js +19 -0
- package/dist/setup.js +250 -0
- package/package.json +43 -4
package/dist/config.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveReference } from './credentials.js';
|
|
5
|
+
export class ConfigError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export const example = {
|
|
8
|
+
version: 1,
|
|
9
|
+
telegram: { enabled: true, chat_ids: [], token_env: 'TELEGRAM_BOT_TOKEN' },
|
|
10
|
+
feishu: { enabled: true, webhook_env: 'FEISHU_WEBHOOK', secret_env: 'FEISHU_SECRET' },
|
|
11
|
+
};
|
|
12
|
+
export function appPaths(platform = process.platform, env = process.env, home) {
|
|
13
|
+
const join = platform === 'win32' ? path.win32 : path.posix;
|
|
14
|
+
if (env.DEV_NOTIFY_HOME) {
|
|
15
|
+
if (!join.isAbsolute(env.DEV_NOTIFY_HOME))
|
|
16
|
+
throw new ConfigError('DEV_NOTIFY_HOME 必须是绝对路径');
|
|
17
|
+
return { config: join.join(env.DEV_NOTIFY_HOME, 'config', 'config.json'), data: join.join(env.DEV_NOTIFY_HOME, 'data') };
|
|
18
|
+
}
|
|
19
|
+
home ??= homedir();
|
|
20
|
+
if (platform === 'win32')
|
|
21
|
+
return {
|
|
22
|
+
config: join.join(env.APPDATA || join.join(home, 'AppData', 'Roaming'), 'DevNotify', 'config.json'),
|
|
23
|
+
data: join.join(env.LOCALAPPDATA || join.join(home, 'AppData', 'Local'), 'DevNotify', 'data'),
|
|
24
|
+
};
|
|
25
|
+
if (platform === 'darwin') {
|
|
26
|
+
const root = join.join(home, 'Library', 'Application Support', 'DevNotify');
|
|
27
|
+
return { config: join.join(root, 'config', 'config.json'), data: join.join(root, 'data') };
|
|
28
|
+
}
|
|
29
|
+
return { config: join.join(env.XDG_CONFIG_HOME || join.join(home, '.config'), 'dev-notify', 'config.json'),
|
|
30
|
+
data: join.join(env.XDG_STATE_HOME || join.join(home, '.local', 'state'), 'dev-notify') };
|
|
31
|
+
}
|
|
32
|
+
export function object(value, allowed, name) {
|
|
33
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
34
|
+
throw new ConfigError(`${name} 必须是 JSON 对象`);
|
|
35
|
+
const result = value;
|
|
36
|
+
if (Object.keys(result).some(key => !allowed.includes(key)))
|
|
37
|
+
throw new ConfigError(`${name} 包含不支持的字段;不能直接保存明文密钥`);
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
function enabled(section) {
|
|
41
|
+
if (section.enabled === undefined)
|
|
42
|
+
return true;
|
|
43
|
+
if (typeof section.enabled !== 'boolean')
|
|
44
|
+
throw new ConfigError('enabled 必须是布尔值');
|
|
45
|
+
return section.enabled;
|
|
46
|
+
}
|
|
47
|
+
function secret(section, field, fallback, env, stored) {
|
|
48
|
+
const name = section[field] ?? fallback;
|
|
49
|
+
if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
50
|
+
throw new ConfigError('凭据引用必须是合法的环境变量名称');
|
|
51
|
+
if (Object.hasOwn(env, name))
|
|
52
|
+
return (env[name] ?? '').trim();
|
|
53
|
+
return enabled(section) ? stored().trim() : '';
|
|
54
|
+
}
|
|
55
|
+
export function parseConfig(input, env = process.env, resolver = resolveReference) {
|
|
56
|
+
const data = object(input, ['version', 'telegram', 'feishu', 'credentials'], '配置');
|
|
57
|
+
if ((data.version ?? 1) !== 1)
|
|
58
|
+
throw new ConfigError('不支持的配置版本');
|
|
59
|
+
let reference;
|
|
60
|
+
if (data.credentials !== undefined) {
|
|
61
|
+
const value = object(data.credentials, ['backend', 'id'], 'credentials');
|
|
62
|
+
if (!['system', 'keyring'].includes(String(value.backend)) || typeof value.id !== 'string' || !/^[a-f0-9]{32}$/.test(value.id))
|
|
63
|
+
throw new ConfigError('系统凭据引用无效');
|
|
64
|
+
reference = value;
|
|
65
|
+
}
|
|
66
|
+
let cached;
|
|
67
|
+
const stored = (field) => {
|
|
68
|
+
if (!reference)
|
|
69
|
+
return '';
|
|
70
|
+
cached ??= resolver(reference);
|
|
71
|
+
return cached[field] ?? '';
|
|
72
|
+
};
|
|
73
|
+
const tg = object(data.telegram ?? {}, ['enabled', 'chat_ids', 'token_env'], 'telegram');
|
|
74
|
+
const fs = object(data.feishu ?? {}, ['enabled', 'webhook_env', 'secret_env'], 'feishu');
|
|
75
|
+
const tgEnabled = enabled(tg), fsEnabled = enabled(fs);
|
|
76
|
+
let ids = tg.chat_ids ?? [];
|
|
77
|
+
if (Object.hasOwn(env, 'TELEGRAM_CHAT_ID') || Object.hasOwn(env, 'TELEGRAM_CHAT_IDS')) {
|
|
78
|
+
ids = `${env.TELEGRAM_CHAT_ID ?? ''},${env.TELEGRAM_CHAT_IDS ?? ''}`.split(',').map(v => v.trim()).filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(ids))
|
|
81
|
+
throw new ConfigError('telegram.chat_ids 必须是数组');
|
|
82
|
+
const chatIds = [...new Set(ids.map(value => {
|
|
83
|
+
if (typeof value !== 'string' && !(typeof value === 'number' && Number.isSafeInteger(value)))
|
|
84
|
+
throw new ConfigError('接收人必须是数字 chat_id;大整数请使用字符串');
|
|
85
|
+
const id = String(value).trim();
|
|
86
|
+
if (!/^-?[1-9][0-9]*$/.test(id))
|
|
87
|
+
throw new ConfigError('接收人必须是非零数字 chat_id');
|
|
88
|
+
return id;
|
|
89
|
+
}))];
|
|
90
|
+
const token = secret(tg, 'token_env', 'TELEGRAM_BOT_TOKEN', env, () => stored('telegram_token'));
|
|
91
|
+
if (token && !/^[0-9]+:[A-Za-z0-9_-]+$/.test(token))
|
|
92
|
+
throw new ConfigError('Telegram Token 格式无效');
|
|
93
|
+
const webhook = secret(fs, 'webhook_env', 'FEISHU_WEBHOOK', env, () => stored('feishu_webhook'));
|
|
94
|
+
if (webhook) {
|
|
95
|
+
let valid = false;
|
|
96
|
+
try {
|
|
97
|
+
const url = new URL(webhook);
|
|
98
|
+
valid = url.protocol === 'https:' && url.host === 'open.feishu.cn' && !url.username && !url.password && !url.search && !url.hash && /^\/open-apis\/bot\/v2\/hook\/[^/]+$/.test(url.pathname);
|
|
99
|
+
}
|
|
100
|
+
catch { /* 不输出含秘密的 URL 异常。 */ }
|
|
101
|
+
if (!valid)
|
|
102
|
+
throw new ConfigError('飞书 webhook 必须是 open.feishu.cn 的 HTTPS 自定义机器人地址');
|
|
103
|
+
}
|
|
104
|
+
return { telegram: { enabled: tgEnabled, token, chatIds }, feishu: { enabled: fsEnabled, webhook,
|
|
105
|
+
secret: secret(fs, 'secret_env', 'FEISHU_SECRET', env, () => stored('feishu_secret')) } };
|
|
106
|
+
}
|
|
107
|
+
export function ready(config, channel) {
|
|
108
|
+
return channel === 'telegram' ? config.telegram.enabled && !!config.telegram.token && !!config.telegram.chatIds.length
|
|
109
|
+
: config.feishu.enabled && !!config.feishu.webhook;
|
|
110
|
+
}
|
|
111
|
+
export function decodeUtf8(data) {
|
|
112
|
+
try {
|
|
113
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(data);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new ConfigError('输入必须是有效的 UTF-8 文本');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function loadConfig(file, required = false, env = process.env) {
|
|
120
|
+
let input = {};
|
|
121
|
+
try {
|
|
122
|
+
input = JSON.parse(decodeUtf8(await readFile(file)));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error.code === 'ENOENT' && !required)
|
|
126
|
+
return parseConfig({}, env);
|
|
127
|
+
throw new ConfigError('配置文件缺失、不可读取或不是合法 UTF-8 JSON');
|
|
128
|
+
}
|
|
129
|
+
return parseConfig(input, env);
|
|
130
|
+
}
|
|
131
|
+
export async function createConfig(file) {
|
|
132
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
133
|
+
try {
|
|
134
|
+
await writeFile(file, JSON.stringify(example, null, 2) + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error.code === 'EEXIST')
|
|
138
|
+
throw new ConfigError('配置已存在,未覆盖;Node 版不会修改 Python 原型的配置');
|
|
139
|
+
throw new ConfigError('无法创建配置,请检查路径和权限');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// 只访问本工具的随机引用条目,不枚举系统凭据,不通过子进程传递秘密。
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
export const SERVICE = 'dev-notify.node.credentials.v1';
|
|
4
|
+
export class CredentialError extends Error {
|
|
5
|
+
}
|
|
6
|
+
const fields = ['telegram_token', 'feishu_webhook', 'feishu_secret'];
|
|
7
|
+
export function validateId(id) {
|
|
8
|
+
if (typeof id !== 'string' || !/^[0-9a-f]{32}$/.test(id))
|
|
9
|
+
throw new CredentialError('系统凭据引用无效');
|
|
10
|
+
}
|
|
11
|
+
export function decodeBundle(raw) {
|
|
12
|
+
try {
|
|
13
|
+
const result = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw));
|
|
14
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)
|
|
15
|
+
|| Object.keys(result).some(key => !fields.includes(key))
|
|
16
|
+
|| Object.values(result).some(value => typeof value !== 'string'))
|
|
17
|
+
throw new Error();
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new CredentialError('系统凭据数据无效,请重新运行 setup');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function encodeBundle(values) {
|
|
25
|
+
const encoded = Buffer.from(JSON.stringify(values), 'utf8');
|
|
26
|
+
decodeBundle(encoded);
|
|
27
|
+
// Windows Credential Manager 通用凭据 blob 上限 2560 字节,跨平台采用保守值。
|
|
28
|
+
if (encoded.byteLength > 2400)
|
|
29
|
+
throw new CredentialError('凭据总长度超过 2400 字节,请检查输入;未保存');
|
|
30
|
+
return encoded;
|
|
31
|
+
}
|
|
32
|
+
export class KeyringStore {
|
|
33
|
+
Entry;
|
|
34
|
+
constructor(Entry) {
|
|
35
|
+
this.Entry = Entry;
|
|
36
|
+
}
|
|
37
|
+
entry(id) { validateId(id); return new this.Entry(SERVICE, id); }
|
|
38
|
+
read(id) {
|
|
39
|
+
validateId(id);
|
|
40
|
+
try {
|
|
41
|
+
const raw = this.entry(id).getSecret();
|
|
42
|
+
return raw === null ? null : decodeBundle(Uint8Array.from(raw));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new CredentialError('系统凭据无法读取或内容无效,请解锁系统存储并允许访问,或使用 setup 重配');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
create(id, values) {
|
|
49
|
+
validateId(id);
|
|
50
|
+
const encoded = encodeBundle(values);
|
|
51
|
+
let entry;
|
|
52
|
+
try {
|
|
53
|
+
entry = this.entry(id);
|
|
54
|
+
if (entry.getSecret() !== null)
|
|
55
|
+
throw new CredentialError('随机凭据引用已存在,未覆盖,请重试');
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof CredentialError)
|
|
59
|
+
throw error;
|
|
60
|
+
throw new CredentialError('无法访问系统凭据存储,请检查系统权限');
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
entry.setSecret(encoded);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// 后端可能已写入后才抛错,仅清理此前确认不存在的本次随机条目。
|
|
67
|
+
try {
|
|
68
|
+
entry.deleteCredential();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new CredentialError(`凭据写入状态未知且清理失败,需检查引用 ID:${id}`);
|
|
72
|
+
}
|
|
73
|
+
throw new CredentialError('系统凭据写入失败,未切换配置');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
remove(id) {
|
|
77
|
+
validateId(id);
|
|
78
|
+
try {
|
|
79
|
+
this.entry(id).deleteCredential();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new CredentialError('系统凭据删除失败,请检查存储权限');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const require = createRequire(import.meta.url);
|
|
87
|
+
export function nativeStore(platform = process.platform) {
|
|
88
|
+
if (platform !== 'win32' && platform !== 'darwin')
|
|
89
|
+
throw new CredentialError('系统凭据仅支持 Windows/macOS;其他平台请使用环境变量');
|
|
90
|
+
try {
|
|
91
|
+
const binding = require('@napi-rs/keyring');
|
|
92
|
+
return new KeyringStore(binding.Entry);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new CredentialError('原生凭据组件无法加载,请确认 npm 安装保留 optional 依赖且架构匹配;不会降级为明文');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function resolveReference(reference) {
|
|
99
|
+
if (reference.backend === 'system')
|
|
100
|
+
throw new CredentialError('这是 Python 原型的凭据引用;请用 setup 重新输入并保存为 Node 凭据,或显式提供环境变量。不会自动读取或删除旧凭据');
|
|
101
|
+
const values = nativeStore().read(reference.id);
|
|
102
|
+
if (values === null)
|
|
103
|
+
throw new CredentialError('系统凭据条目缺失,请运行 setup 重新配置');
|
|
104
|
+
return values;
|
|
105
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
export const escapeHtml = (text) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2
|
+
export const partTitle = (title, index, total) => total > 1 ? `${title} (${index + 1}/${total})` : title;
|
|
3
|
+
function inline(text, tags = [], depth = 0) {
|
|
4
|
+
if (depth > 8)
|
|
5
|
+
return [{ text, tags }];
|
|
6
|
+
// 不在生成的 HTML 上执行 Markdown 正则,避免修改链接属性。
|
|
7
|
+
const expression = /`([^`\n]+)`|\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|\*\*(\S(?:.*?\S)?)\*\*|__(\S(?:.*?\S)?)__|~~(\S(?:.*?\S)?)~~|(?<![\w*])\*([^*\n]+)\*(?![\w*])|(?<![\w_])_([^_\n]+)_(?![\w_])/g;
|
|
8
|
+
const spans = [];
|
|
9
|
+
let cursor = 0;
|
|
10
|
+
for (const match of text.matchAll(expression)) {
|
|
11
|
+
if (match.index > cursor)
|
|
12
|
+
spans.push({ text: text.slice(cursor, match.index), tags });
|
|
13
|
+
if (match[1] !== undefined)
|
|
14
|
+
spans.push({ text: match[1], tags: [...tags, 'code'] });
|
|
15
|
+
else if (match[2] !== undefined) {
|
|
16
|
+
// 链接地址同样保留在可见文本中;任何纯文本回退都不会丢失地址。
|
|
17
|
+
spans.push(...inline(match[2], tags, depth + 1), { text: ` (${match[3]})`, tags });
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const content = match[4] ?? match[5] ?? match[6] ?? match[7] ?? match[8];
|
|
21
|
+
const tag = match[4] !== undefined || match[5] !== undefined ? 'b' : match[6] !== undefined ? 's' : 'i';
|
|
22
|
+
spans.push(...inline(content, [...tags, tag], depth + 1));
|
|
23
|
+
}
|
|
24
|
+
cursor = match.index + match[0].length;
|
|
25
|
+
}
|
|
26
|
+
if (cursor < text.length)
|
|
27
|
+
spans.push({ text: text.slice(cursor), tags });
|
|
28
|
+
return spans.length ? spans : [{ text: '', tags }];
|
|
29
|
+
}
|
|
30
|
+
function cells(line) {
|
|
31
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => inline(cell.trim()).map(s => s.text).join(''));
|
|
32
|
+
}
|
|
33
|
+
function separator(line) {
|
|
34
|
+
return line.includes('|') && line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').every(cell => /^:?-{3,}:?$/.test(cell.trim()));
|
|
35
|
+
}
|
|
36
|
+
function width(text) {
|
|
37
|
+
let size = 0;
|
|
38
|
+
for (const ch of text) {
|
|
39
|
+
const code = ch.codePointAt(0);
|
|
40
|
+
size += code >= 0x1100 && (code <= 0x115f || code >= 0x2e80 && code <= 0xa4cf || code >= 0xac00 && code <= 0xd7af || code >= 0xff01 && code <= 0xff60 || code >= 0x1f300) ? 2 : 1;
|
|
41
|
+
}
|
|
42
|
+
return size;
|
|
43
|
+
}
|
|
44
|
+
function table(lines) {
|
|
45
|
+
const rows = lines.filter((_, index) => index !== 1).map(cells);
|
|
46
|
+
const count = Math.max(...rows.map(row => row.length));
|
|
47
|
+
const widths = Array.from({ length: count }, (_, col) => Math.max(3, ...rows.map(row => width(row[col] ?? ''))));
|
|
48
|
+
const output = [];
|
|
49
|
+
rows.forEach((row, index) => {
|
|
50
|
+
output.push(widths.map((size, col) => (row[col] ?? '') + ' '.repeat(size - width(row[col] ?? ''))).join(' | '));
|
|
51
|
+
if (!index)
|
|
52
|
+
output.push(widths.map(size => '-'.repeat(size)).join('-+-'));
|
|
53
|
+
});
|
|
54
|
+
return output.join('\n');
|
|
55
|
+
}
|
|
56
|
+
export function parseMarkdown(content) {
|
|
57
|
+
const lines = content.replace(/\r\n?/g, '\n').replace(/\0/g, '').split('\n');
|
|
58
|
+
const blocks = [];
|
|
59
|
+
let index = 0;
|
|
60
|
+
while (index < lines.length) {
|
|
61
|
+
const line = lines[index];
|
|
62
|
+
const fence = /^\s*(`{3,}|~{3,})(.*)$/.exec(line);
|
|
63
|
+
if (fence) {
|
|
64
|
+
const marker = fence[1][0];
|
|
65
|
+
const closing = new RegExp(`^\\s*${marker === '`' ? '`' : '~'}{${fence[1].length},}\\s*$`);
|
|
66
|
+
const body = [];
|
|
67
|
+
index++;
|
|
68
|
+
while (index < lines.length && !closing.test(lines[index]))
|
|
69
|
+
body.push(lines[index++]);
|
|
70
|
+
if (index < lines.length)
|
|
71
|
+
index++;
|
|
72
|
+
blocks.push({ spans: [{ text: body.join('\n'), tags: [] }], code: true });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (index + 1 < lines.length && line.includes('|') && separator(lines[index + 1])) {
|
|
76
|
+
const rows = [line, lines[index + 1]];
|
|
77
|
+
index += 2;
|
|
78
|
+
while (index < lines.length && lines[index].trim() && lines[index].includes('|'))
|
|
79
|
+
rows.push(lines[index++]);
|
|
80
|
+
blocks.push({ spans: [{ text: table(rows), tags: [] }], code: true });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const heading = /^\s{0,3}#{1,6}\s+(.*)$/.exec(line);
|
|
84
|
+
const quote = /^\s*>\s?(.*)$/.exec(line);
|
|
85
|
+
const list = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
|
86
|
+
let spans;
|
|
87
|
+
if (heading)
|
|
88
|
+
spans = inline(heading[1], ['b']);
|
|
89
|
+
else if (/^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line))
|
|
90
|
+
spans = [{ text: '────────', tags: [] }];
|
|
91
|
+
else if (quote)
|
|
92
|
+
spans = inline(quote[1], ['blockquote']);
|
|
93
|
+
else if (list)
|
|
94
|
+
spans = inline(`${list[1]}• ${list[2].replace(/^\[ \] /, '☐ ').replace(/^\[x\] /i, '☑ ')}`);
|
|
95
|
+
else
|
|
96
|
+
spans = inline(line);
|
|
97
|
+
blocks.push({ spans, code: false });
|
|
98
|
+
index++;
|
|
99
|
+
}
|
|
100
|
+
return blocks;
|
|
101
|
+
}
|
|
102
|
+
export function visibleText(blocks) {
|
|
103
|
+
return blocks.map(block => block.spans.map(span => span.text).join('')).join('\n');
|
|
104
|
+
}
|
|
105
|
+
function cut(text, budget, measure) {
|
|
106
|
+
if (budget < 1)
|
|
107
|
+
throw new Error('分段空间不足');
|
|
108
|
+
let used = 0, end = 0;
|
|
109
|
+
for (const ch of text) {
|
|
110
|
+
const size = measure(ch);
|
|
111
|
+
if (used + size > budget)
|
|
112
|
+
break;
|
|
113
|
+
used += size;
|
|
114
|
+
end += ch.length;
|
|
115
|
+
}
|
|
116
|
+
if (!end && text)
|
|
117
|
+
throw new Error('分段空间不足以容纳一个字符');
|
|
118
|
+
if (end < text.length) {
|
|
119
|
+
for (const boundary of ['\n\n', '\n', ' ']) {
|
|
120
|
+
const offset = text.slice(0, end).lastIndexOf(boundary);
|
|
121
|
+
if (offset >= end / 2) {
|
|
122
|
+
end = offset + boundary.length;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return [text.slice(0, end), text.slice(end)];
|
|
128
|
+
}
|
|
129
|
+
export function telegramParts(blocks, budget) {
|
|
130
|
+
if (budget < 2)
|
|
131
|
+
throw new Error('Telegram 标题或辅助信息过长');
|
|
132
|
+
const output = [];
|
|
133
|
+
let html = '', plain = '';
|
|
134
|
+
const flush = () => { if (plain)
|
|
135
|
+
output.push({ html, plain }); html = ''; plain = ''; };
|
|
136
|
+
blocks.forEach((block, index) => {
|
|
137
|
+
const spans = index ? [{ text: '\n', tags: [] }, ...block.spans] : block.spans;
|
|
138
|
+
for (const span of spans) {
|
|
139
|
+
let rest = span.text;
|
|
140
|
+
while (rest) {
|
|
141
|
+
if (budget - plain.length < [...rest][0].length)
|
|
142
|
+
flush();
|
|
143
|
+
const [piece, remaining] = cut(rest, budget - plain.length, text => text.length);
|
|
144
|
+
// 块间换行不包裹为代码,其余内容每片均使用完整标签。
|
|
145
|
+
const actualTags = block.code && !(index && span === spans[0]) ? ['pre'] : span.tags;
|
|
146
|
+
html += actualTags.map(tag => `<${tag}>`).join('') + escapeHtml(piece) + [...actualTags].reverse().map(tag => `</${tag}>`).join('');
|
|
147
|
+
plain += piece;
|
|
148
|
+
rest = remaining;
|
|
149
|
+
if (rest)
|
|
150
|
+
flush();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
flush();
|
|
155
|
+
return output.length ? output : [{ html: '', plain: '' }];
|
|
156
|
+
}
|
|
157
|
+
export function feishuParts(blocks, budget) {
|
|
158
|
+
if (budget < 128)
|
|
159
|
+
throw new Error('飞书标题或辅助信息过长');
|
|
160
|
+
const output = [];
|
|
161
|
+
let current = '';
|
|
162
|
+
const size = (text) => Buffer.byteLength(text, 'utf8');
|
|
163
|
+
const flush = () => { if (current)
|
|
164
|
+
output.push(current); current = ''; };
|
|
165
|
+
blocks.forEach((block, index) => {
|
|
166
|
+
if (index) {
|
|
167
|
+
if (size(current) + 1 > budget)
|
|
168
|
+
flush();
|
|
169
|
+
current += '\n';
|
|
170
|
+
}
|
|
171
|
+
for (const span of block.spans) {
|
|
172
|
+
let rest = span.text;
|
|
173
|
+
let prefix = '', suffix = '';
|
|
174
|
+
if (block.code) {
|
|
175
|
+
const runs = rest.match(/`+/g) ?? [];
|
|
176
|
+
const marker = '`'.repeat(Math.max(3, ...runs.map(run => run.length + 1)));
|
|
177
|
+
prefix = marker + 'text\n';
|
|
178
|
+
suffix = '\n' + marker;
|
|
179
|
+
// 极端围栏长度下保留字面正文,不无限增大包装。
|
|
180
|
+
if (size(prefix + suffix) > budget / 2) {
|
|
181
|
+
prefix = '';
|
|
182
|
+
suffix = '';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
for (const tag of span.tags) {
|
|
187
|
+
const wrapper = tag === 'b' ? '**' : tag === 'i' ? '*' : tag === 's' ? '~~' : tag === 'code' ? '`' : '';
|
|
188
|
+
if (tag === 'blockquote')
|
|
189
|
+
prefix += '▎ ';
|
|
190
|
+
else {
|
|
191
|
+
prefix += wrapper;
|
|
192
|
+
suffix = wrapper + suffix;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
while (rest) {
|
|
197
|
+
const overhead = size(prefix + suffix);
|
|
198
|
+
if (size(current) + overhead + size([...rest][0]) > budget)
|
|
199
|
+
flush();
|
|
200
|
+
const [piece, remaining] = cut(rest, budget - size(current) - overhead, size);
|
|
201
|
+
current += prefix + piece + suffix;
|
|
202
|
+
rest = remaining;
|
|
203
|
+
if (rest)
|
|
204
|
+
flush();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
flush();
|
|
209
|
+
return output.length ? output : [''];
|
|
210
|
+
}
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { ConfigError } from './config.js';
|
|
4
|
+
export class Cancelled extends Error {
|
|
5
|
+
}
|
|
6
|
+
export function terminalPrompts() {
|
|
7
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
8
|
+
throw new ConfigError('配置向导需要交互终端;自动化请使用 setup --template --yes 和环境变量');
|
|
9
|
+
return {
|
|
10
|
+
out: text => console.log(text),
|
|
11
|
+
text: label => readText(label),
|
|
12
|
+
secret: label => readHidden(label),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export async function readText(label, input = process.stdin, output = process.stdout) {
|
|
16
|
+
const terminal = createInterface({ input, output });
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const cancel = () => controller.abort();
|
|
19
|
+
terminal.once('SIGINT', cancel);
|
|
20
|
+
terminal.once('close', cancel);
|
|
21
|
+
try {
|
|
22
|
+
return (await terminal.question(label, { signal: controller.signal })).trim();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Cancelled('已取消');
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
terminal.off('SIGINT', cancel);
|
|
29
|
+
terminal.off('close', cancel);
|
|
30
|
+
terminal.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function readHidden(label, input = process.stdin, output = process.stdout) {
|
|
34
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== 'function')
|
|
35
|
+
throw new ConfigError('当前终端不支持隐藏输入,拒绝回显密钥');
|
|
36
|
+
const wasRaw = input.isRaw, wasPaused = input.isPaused();
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
let value = '';
|
|
39
|
+
let finished = false;
|
|
40
|
+
const cleanup = () => {
|
|
41
|
+
input.off('keypress', keypress);
|
|
42
|
+
input.off('error', failed);
|
|
43
|
+
input.off('end', cancelled);
|
|
44
|
+
try {
|
|
45
|
+
input.setRawMode(wasRaw);
|
|
46
|
+
}
|
|
47
|
+
catch { /* 终端已关闭时不泄漏输入。 */ }
|
|
48
|
+
if (wasPaused)
|
|
49
|
+
input.pause();
|
|
50
|
+
};
|
|
51
|
+
const finish = (error) => {
|
|
52
|
+
if (finished)
|
|
53
|
+
return;
|
|
54
|
+
finished = true;
|
|
55
|
+
cleanup();
|
|
56
|
+
output.write('\n');
|
|
57
|
+
const result = value.trim();
|
|
58
|
+
value = '';
|
|
59
|
+
error ? reject(error) : resolve(result);
|
|
60
|
+
};
|
|
61
|
+
const failed = () => finish(new ConfigError('隐藏输入失败,未保存凭据'));
|
|
62
|
+
const cancelled = () => finish(new Cancelled('已取消'));
|
|
63
|
+
const keypress = (text, key = {}) => {
|
|
64
|
+
if (key.ctrl && (key.name === 'c' || key.name === 'd'))
|
|
65
|
+
return cancelled();
|
|
66
|
+
if (key.name === 'return' || key.name === 'enter')
|
|
67
|
+
return finish();
|
|
68
|
+
if (key.name === 'backspace' || key.name === 'delete') {
|
|
69
|
+
value = Array.from(value).slice(0, -1).join('');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (key.ctrl && key.name === 'u') {
|
|
73
|
+
value = '';
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (key.ctrl || key.meta || !text || /[\x00-\x1f\x7f]/.test(text))
|
|
77
|
+
return;
|
|
78
|
+
if (value.length + text.length > 8192)
|
|
79
|
+
return finish(new ConfigError('凭据输入过长,未保存'));
|
|
80
|
+
value += text;
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
// 先进入 raw mode 再展示提示,任何失败都不退回明文输入。
|
|
84
|
+
input.setRawMode(true);
|
|
85
|
+
emitKeypressEvents(input);
|
|
86
|
+
input.on('keypress', keypress);
|
|
87
|
+
input.on('error', failed);
|
|
88
|
+
input.on('end', cancelled);
|
|
89
|
+
input.resume();
|
|
90
|
+
output.write(label);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
failed();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|