@quecue/dev-notify 0.1.2 → 0.1.4
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.en.md +83 -0
- package/CHANGELOG.md +15 -0
- package/README.en.md +275 -0
- package/README.md +11 -4
- package/SECURITY.en.md +78 -0
- package/SECURITY.md +5 -3
- package/dist/channels.js +12 -11
- package/dist/claude-code.js +30 -30
- package/dist/claude-hook.js +23 -23
- package/dist/cli.js +106 -81
- package/dist/config.js +37 -20
- package/dist/credentials.js +14 -13
- package/dist/formatting.js +6 -4
- package/dist/i18n.js +960 -0
- package/dist/maintenance.js +17 -16
- package/dist/prompts.js +7 -6
- package/dist/reply-bridge.js +11 -10
- package/dist/reply-engine.js +20 -19
- package/dist/reply-hooks.js +21 -20
- package/dist/reply-interactions.js +36 -35
- package/dist/reply-outcomes.js +46 -23
- package/dist/reply-platform.js +14 -13
- package/dist/reply-state.js +31 -30
- package/dist/reply-telegram.js +4 -3
- package/dist/setup.js +69 -51
- package/dist/update.js +39 -31
- package/package.json +4 -1
package/SECURITY.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# 安全边界(Node/npm)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
简体中文 | [English](SECURITY.en.md)
|
|
4
|
+
|
|
5
|
+
当前源码为 `0.1.3`,npm 包名为 `@quecue/dev-notify`;发布版本以 npm registry 为准,源码更新不等同于已发布。本项目尚未完成安全审计或双平台实机验收。
|
|
4
6
|
|
|
5
7
|
## 凭据与权限
|
|
6
8
|
|
|
@@ -38,7 +40,7 @@
|
|
|
38
40
|
- 生成 `command` + `args` 的无 shell exec 形式,路径不经过 shell 展开;固定安装时使用 Node/CLI/配置的绝对路径,命令参数不含秘密。需要支持该形式与 `async` 的新版 Claude Code;切换 Node 或移动安装目录后,请重新安装 hooks。
|
|
39
41
|
- 保留其他设置和 hooks,重复安装会去重更新,移除时只匹配本工具标记及参数结构。旧版、项目或插件 hooks 不会被自动删除,可能造成重复发送。`status` 只反映本地状态,不涉及全局策略或实际事件投递验收;不会解除禁用、降低权限或绕过托管限制。
|
|
40
42
|
- 写入使用专用排他锁、前后快照及同目录临时文件原子替换;损坏的 JSON 会被拒绝。外部编辑器不遵守这个锁,因此不会宣称无条件消除竞态;强杀可能留下锁或临时文件,程序不会清理不属于本次创建的锁。
|
|
41
|
-
- Notification 异步,Stop 与会话生命周期同步,状态登记后再发送,可能延迟本地返回。PreToolUse 只登记调用身份,不发送网络通知,也不等待答案;PermissionRequest 与普通交互终端原生窗口并行等待,最多
|
|
43
|
+
- Notification 异步,Stop 与会话生命周期同步,状态登记后再发送,可能延迟本地返回。PreToolUse 只登记调用身份,不发送网络通知,也不等待答案;PermissionRequest 与普通交互终端原生窗口并行等待,最多 10 分钟,不会默认选择。新增 PostToolUse、PostToolUseFailure、PermissionDenied 处理结果,完整安装共 10 类事件。当前核对的是 Claude Code 2.1.316 实现;旧版及特殊宿主的并行路径可能不同。
|
|
42
44
|
- 没有持久通知发送队列,不会自动重发整条消息,也不提供跨进程的恰好一次保证。继续中的 Stop 和重复空闲提醒会被忽略;内部出错时只输出安全摘要并退出 0,不会使用退出码 2 让 Claude 继续。只有明确授权的结构化交互才会返回对应的官方控制 JSON。
|
|
43
45
|
- 移除 hooks、清理通知凭据和卸载 npm 是三项不同操作。卸载前请先单独移除 hooks;卸载脚本不会擅自修改用户设置。
|
|
44
46
|
|
|
@@ -53,7 +55,7 @@
|
|
|
53
55
|
- AskUserQuestion 使用原始 questions + answers,通过 PermissionRequest decision.updatedInput 回传;保留本机原生界面,单选、多选、逐题自定义都不经过终端注入。由 Claude 仲裁先到的完整答案。每个按钮带请求/题目序号,过期或旧题不会回答新题;超时或撤销时不会默认作答。
|
|
54
56
|
- PreToolUse 的 tool_use_id 与输入摘要用于关联;PermissionRequest 没有该 ID,只接受唯一匹配,遇到歧义、重复同内容、缺失或已结束时会拒绝远程处理。完成事件按 tool_use_id 关联,不会依靠通知顺序猜测目标。
|
|
55
57
|
- 通用 permission_prompt 去重只用于通知投递,不授予输入或批准权限:仅当前会话最后一项调用是选择题、没有其他未结束调用、结构化通知完整成功时,才会按接收人覆盖;飞书另绑定当前 webhook/签名配置的摘要。已结束题目可在原交互有效期内覆盖迟到提醒,新输入及后续工具调用不会继承。Notification 缺少精确调用身份时,以该保守会话范围判断,不会声称跨并发或跨轮次精确去重;状态异常、投递失败或关联不明确时,会保留通用提醒。
|
|
56
|
-
- Telegram 的「已登记」反馈只说明答案已经记录,生效情况会在后续确认。PostToolUse 返回的实际答案用于内容确认;两端同时提交相同答案时,无法证明来源。拒绝/失败、取消、缺少结果或回传后
|
|
58
|
+
- Telegram 的「已登记」反馈只说明答案已经记录,生效情况会在后续确认。PostToolUse 返回的实际答案用于内容确认;两端同时提交相同答案时,无法证明来源。拒绝/失败、取消、缺少结果或回传后 10 分钟确认超时,无法证明实际决定时,会明确报告结果未知,不会重放答案。结果收据在发送前登记,网络失败或强杀可能导致未送达;不会无限重发,也不提供恰好一次保证。撤销授权后不再发送。旧选项会替换为已处理/已超时/已失效状态;编辑失败时仍会拒绝旧请求。
|
|
57
59
|
- 问题结束后清除请求输入和答案正文,短期保留消息 ID、结果收据及输入摘要;没有收到结果时,保留到确认期限,实际清理由活跃桥接或生命周期事件触发。
|
|
58
60
|
- 工具权限必须额外 opt-in;每次点击才输出 PermissionRequest 的一次 allow/deny。自定义文本只作为拒绝 message,任意文本都不会变成批准。不会持久写入 updatedPermissions,也不会更改 bypass/权限模式;Claude 的 deny 和托管策略仍然生效。permission_suggestions 不是完整的 UI 列表,因此不会伪造所有本地按钮。MCP 表单/URL 等暂不支持远程应答。
|
|
59
61
|
- 问题、选项、工具参数、回复正文和答案可能包含敏感信息;启用后,问题和相应请求会发送到授权私聊及已配置飞书,无法自动脱敏。排队文本、请求输入、待回传答案及会话路径会持久写入专属本地状态;0600/0700 无法替代 Windows ACL,也无法防御同一用户的进程。逻辑 TTL 不会在断电后自动擦除文件。
|
package/dist/channels.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
2
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { t } from './i18n.js';
|
|
3
4
|
import { escapeHtml, feishuParts, parseMarkdown, partTitle, telegramParts } from './formatting.js';
|
|
4
5
|
export class TransportError extends Error {
|
|
5
6
|
}
|
|
@@ -11,7 +12,7 @@ export async function requestJson(url, payload, fetcher = fetch) {
|
|
|
11
12
|
const response = await fetcher(url, { method: 'POST', body: JSON.stringify(payload),
|
|
12
13
|
headers: { 'Content-Type': 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(10000) });
|
|
13
14
|
if (!response.body)
|
|
14
|
-
throw new TransportError('
|
|
15
|
+
throw new TransportError(t('channels.noResponse'));
|
|
15
16
|
const reader = response.body.getReader();
|
|
16
17
|
const chunks = [];
|
|
17
18
|
let size = 0;
|
|
@@ -23,7 +24,7 @@ export async function requestJson(url, payload, fetcher = fetch) {
|
|
|
23
24
|
size += value.byteLength;
|
|
24
25
|
if (size > 1024 * 1024) {
|
|
25
26
|
await reader.cancel();
|
|
26
|
-
throw new TransportError('
|
|
27
|
+
throw new TransportError(t('channels.responseTooLarge'));
|
|
27
28
|
}
|
|
28
29
|
chunks.push(value);
|
|
29
30
|
}
|
|
@@ -33,13 +34,13 @@ export async function requestJson(url, payload, fetcher = fetch) {
|
|
|
33
34
|
}
|
|
34
35
|
const result = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
|
|
35
36
|
if (!result || typeof result !== 'object' || Array.isArray(result))
|
|
36
|
-
throw new TransportError('
|
|
37
|
+
throw new TransportError(t('channels.notJsonObject'));
|
|
37
38
|
return result;
|
|
38
39
|
}
|
|
39
40
|
catch (error) {
|
|
40
41
|
if (error instanceof TransportError)
|
|
41
42
|
throw error;
|
|
42
|
-
throw new TransportError('
|
|
43
|
+
throw new TransportError(t('channels.network'));
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
function text(title, content, footer, icon, html) {
|
|
@@ -57,13 +58,13 @@ function card(message, title, content) {
|
|
|
57
58
|
if (message.footer)
|
|
58
59
|
elements.push({ tag: 'hr' }, { tag: 'note', elements: [{ tag: 'plain_text', content: message.footer }] });
|
|
59
60
|
if (message.reply?.url)
|
|
60
|
-
elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: '
|
|
61
|
+
elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: t('channels.feishuReplyButton') }, url: message.reply.url, type: 'primary' }] });
|
|
61
62
|
return { msg_type: 'interactive', card: { config: { wide_screen_mode: true },
|
|
62
63
|
header: { template: colors[message.level], title: { tag: 'plain_text', content: title } }, elements } };
|
|
63
64
|
}
|
|
64
65
|
export function makePlan(channel, message, config) {
|
|
65
66
|
if (!message.title.trim())
|
|
66
|
-
throw new Error('
|
|
67
|
+
throw new Error(t('channels.emptyTitle'));
|
|
67
68
|
const blocks = parseMarkdown(message.content);
|
|
68
69
|
if (channel === 'telegram') {
|
|
69
70
|
const icon = icons[message.level];
|
|
@@ -76,10 +77,10 @@ export function makePlan(channel, message, config) {
|
|
|
76
77
|
const title = partTitle(message.title, index, chunks.length);
|
|
77
78
|
const plain = text(title, chunk.plain, message.footer, icon, false);
|
|
78
79
|
if (plain.length > 4096)
|
|
79
|
-
throw new Error('
|
|
80
|
+
throw new Error(t('channels.telegramTooLong'));
|
|
80
81
|
items.push({ recipient: chatId, plain, payload: { chat_id: chatId,
|
|
81
82
|
text: text(title, chunk.html, message.footer, icon, true), parse_mode: 'HTML', disable_web_page_preview: true,
|
|
82
|
-
...(message.reply?.users.includes(chatId) ? { reply_markup: { inline_keyboard: [[{ text: '
|
|
83
|
+
...(message.reply?.users.includes(chatId) ? { reply_markup: { inline_keyboard: [[{ text: t('channels.telegramReplyButton'), callback_data: 'dn:r:' + message.reply.token }]] } } : {}) } });
|
|
83
84
|
});
|
|
84
85
|
return { channel, items, recipients: config.telegram.chatIds.length };
|
|
85
86
|
}
|
|
@@ -88,7 +89,7 @@ export function makePlan(channel, message, config) {
|
|
|
88
89
|
const items = chunks.map((chunk, index) => {
|
|
89
90
|
const payload = card(message, partTitle(message.title, index, chunks.length), chunk);
|
|
90
91
|
if (bytes(payload) + 128 > 28000)
|
|
91
|
-
throw new Error('
|
|
92
|
+
throw new Error(t('channels.feishuTooLong'));
|
|
92
93
|
return { recipient: 'feishu', payload };
|
|
93
94
|
});
|
|
94
95
|
return { channel, items, recipients: 1 };
|
|
@@ -144,12 +145,12 @@ export async function deliver(plan, config, request = requestJson, sleep = delay
|
|
|
144
145
|
}
|
|
145
146
|
else {
|
|
146
147
|
failed.add(item.recipient);
|
|
147
|
-
error = '
|
|
148
|
+
error = t('channels.rejected');
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
151
|
catch (cause) {
|
|
151
152
|
failed.add(item.recipient);
|
|
152
|
-
error = cause instanceof TransportError ? cause.message : '
|
|
153
|
+
error = cause instanceof TransportError ? cause.message : t('channels.sendFailed');
|
|
153
154
|
}
|
|
154
155
|
if (!telegram && !failed.has(item.recipient) && index + 1 < plan.items.length)
|
|
155
156
|
await sleep(250);
|
package/dist/claude-code.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
6
|
import { ConfigError, decodeUtf8 } from './config.js';
|
|
7
|
+
import { t } from './i18n.js';
|
|
7
8
|
export const HOOK_MARKER = 'dev-notify:claude-code:v1';
|
|
8
9
|
export const NOTIFICATION_MATCHER = 'permission_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input';
|
|
9
10
|
export const EVENTS = ['Stop', 'Notification', 'SessionStart', 'UserPromptSubmit', 'SessionEnd', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PostToolUseFailure', 'PermissionDenied'];
|
|
@@ -13,7 +14,7 @@ export function claudeSettingsPath(env = process.env, home = homedir(), platform
|
|
|
13
14
|
const join = platform === 'win32' ? path.win32 : path.posix;
|
|
14
15
|
const root = env.CLAUDE_CONFIG_DIR || join.join(home, '.claude');
|
|
15
16
|
if (!join.isAbsolute(root))
|
|
16
|
-
throw new ConfigError('
|
|
17
|
+
throw new ConfigError(t('claude.settingsAbsolute'));
|
|
17
18
|
return join.join(root, 'settings.json');
|
|
18
19
|
}
|
|
19
20
|
function settingsFile(options) {
|
|
@@ -23,7 +24,7 @@ async function snapshot(file) {
|
|
|
23
24
|
try {
|
|
24
25
|
const bytes = await readFile(file);
|
|
25
26
|
if (bytes.length > 1024 * 1024)
|
|
26
|
-
throw new ConfigError('
|
|
27
|
+
throw new ConfigError(t('claude.settingsTooLarge'));
|
|
27
28
|
return bytes;
|
|
28
29
|
}
|
|
29
30
|
catch (error) {
|
|
@@ -31,7 +32,7 @@ async function snapshot(file) {
|
|
|
31
32
|
return null;
|
|
32
33
|
if (error instanceof ConfigError)
|
|
33
34
|
throw error;
|
|
34
|
-
throw new ConfigError('
|
|
35
|
+
throw new ConfigError(t('claude.settingsReadFailed'));
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
function parseSettings(before) {
|
|
@@ -40,17 +41,17 @@ function parseSettings(before) {
|
|
|
40
41
|
data = before === null ? {} : JSON.parse(decodeUtf8(before));
|
|
41
42
|
}
|
|
42
43
|
catch {
|
|
43
|
-
throw new ConfigError('
|
|
44
|
+
throw new ConfigError(t('claude.settingsInvalidJson'));
|
|
44
45
|
}
|
|
45
46
|
if (!record(data) || (data.hooks !== undefined && !record(data.hooks)))
|
|
46
|
-
throw new ConfigError('
|
|
47
|
+
throw new ConfigError(t('claude.settingsInvalidShape'));
|
|
47
48
|
for (const event of EVENTS) {
|
|
48
49
|
const groups = data.hooks?.[event];
|
|
49
50
|
if (groups === undefined)
|
|
50
51
|
continue;
|
|
51
52
|
if (!Array.isArray(groups) || groups.some(group => !record(group) || !Array.isArray(group.hooks)
|
|
52
53
|
|| group.hooks.some((hook) => !record(hook))))
|
|
53
|
-
throw new ConfigError('
|
|
54
|
+
throw new ConfigError(t('claude.hooksInvalidShape'));
|
|
54
55
|
}
|
|
55
56
|
return data;
|
|
56
57
|
}
|
|
@@ -117,7 +118,7 @@ function merged(data, config, runtime) {
|
|
|
117
118
|
if (config !== null) {
|
|
118
119
|
const handler = { type: 'command', command: runtime.node,
|
|
119
120
|
args: [runtime.cli, 'integration', 'claude-code', 'hook', '--config', config],
|
|
120
|
-
...(event === 'Notification' ? { async: true } : { timeout: event === 'PermissionRequest' ?
|
|
121
|
+
...(event === 'Notification' ? { async: true } : { timeout: event === 'PermissionRequest' ? 720 : event === 'PreToolUse' ? 10 : 120 }), statusMessage: HOOK_MARKER };
|
|
121
122
|
const matcher = event === 'Notification' ? NOTIFICATION_MATCHER : null;
|
|
122
123
|
groups.push(matcher ? { matcher, hooks: [handler] } : { hooks: [handler] });
|
|
123
124
|
}
|
|
@@ -152,14 +153,14 @@ async function commit(file, before, target, prompts, replace = rename) {
|
|
|
152
153
|
lock = await open(lockFile, 'wx', 0o600);
|
|
153
154
|
}
|
|
154
155
|
catch {
|
|
155
|
-
throw new ConfigError('
|
|
156
|
+
throw new ConfigError(t('claude.lockUnavailable'));
|
|
156
157
|
}
|
|
157
158
|
const temporary = path.join(path.dirname(file), `.dev-notify-${randomBytes(16).toString('hex')}.tmp`);
|
|
158
159
|
let created = false;
|
|
159
160
|
try {
|
|
160
161
|
await lock.close();
|
|
161
162
|
if (!same(before, await snapshot(file)))
|
|
162
|
-
throw new ConfigError('
|
|
163
|
+
throw new ConfigError(t('claude.changedDuringConfirm'));
|
|
163
164
|
const stream = await open(temporary, 'wx', 0o600);
|
|
164
165
|
created = true;
|
|
165
166
|
try {
|
|
@@ -170,7 +171,7 @@ async function commit(file, before, target, prompts, replace = rename) {
|
|
|
170
171
|
await stream.close();
|
|
171
172
|
}
|
|
172
173
|
if (!same(before, await snapshot(file)))
|
|
173
|
-
throw new ConfigError('
|
|
174
|
+
throw new ConfigError(t('claude.changedDuringSave'));
|
|
174
175
|
await replace(temporary, file);
|
|
175
176
|
}
|
|
176
177
|
finally {
|
|
@@ -179,14 +180,14 @@ async function commit(file, before, target, prompts, replace = rename) {
|
|
|
179
180
|
await remove(temporary);
|
|
180
181
|
}
|
|
181
182
|
catch {
|
|
182
|
-
prompts.out('
|
|
183
|
+
prompts.out(t('claude.tempCleanupFailed'));
|
|
183
184
|
}
|
|
184
185
|
}
|
|
185
186
|
try {
|
|
186
187
|
await remove(lockFile);
|
|
187
188
|
}
|
|
188
189
|
catch {
|
|
189
|
-
prompts.out('
|
|
190
|
+
prompts.out(t('claude.lockCleanupFailed'));
|
|
190
191
|
}
|
|
191
192
|
}
|
|
192
193
|
}
|
|
@@ -194,8 +195,8 @@ export async function configureClaudeCode(config, action, prompts, options = {})
|
|
|
194
195
|
const file = settingsFile(options), before = await snapshot(file), data = parseSettings(before);
|
|
195
196
|
const status = describe(file, data);
|
|
196
197
|
if (action === 'remove' && status.stop + status.notification + status.lifecycle + status.interaction === 0) {
|
|
197
|
-
prompts.out(
|
|
198
|
-
prompts.out('
|
|
198
|
+
prompts.out(t('claude.settingsPath', { file }));
|
|
199
|
+
prompts.out(t('claude.noHooks'));
|
|
199
200
|
return true;
|
|
200
201
|
}
|
|
201
202
|
const runtime = options.runtime ?? { node: process.execPath, cli: fileURLToPath(new URL('./cli.js', import.meta.url)) };
|
|
@@ -203,35 +204,34 @@ export async function configureClaudeCode(config, action, prompts, options = {})
|
|
|
203
204
|
// setup 快速路径已在汇总确认前展示路径和发送范围,避免保存后重复整段说明。
|
|
204
205
|
const quiet = options.confirm === false;
|
|
205
206
|
if (!quiet)
|
|
206
|
-
prompts.out(
|
|
207
|
+
prompts.out(t('claude.settingsPath', { file }));
|
|
207
208
|
if (JSON.stringify(data) === JSON.stringify(target)) {
|
|
208
|
-
prompts.out(action === 'install' ? '
|
|
209
|
+
prompts.out(action === 'install' ? t('claude.alreadyInstalled') : t('claude.noHooks'));
|
|
209
210
|
return true;
|
|
210
211
|
}
|
|
211
212
|
if (action === 'install') {
|
|
212
213
|
if (!quiet) {
|
|
213
|
-
prompts.out(
|
|
214
|
-
prompts.out('
|
|
215
|
-
prompts.out('
|
|
216
|
-
prompts.out('
|
|
217
|
-
prompts.out('
|
|
218
|
-
prompts.out('
|
|
214
|
+
prompts.out(t('claude.configPath', { config: path.resolve(config) }));
|
|
215
|
+
prompts.out(t('claude.installScope'));
|
|
216
|
+
prompts.out(t('claude.installSensitive'));
|
|
217
|
+
prompts.out(t('claude.installLifecycle'));
|
|
218
|
+
prompts.out(t('claude.installReads'));
|
|
219
|
+
prompts.out(t('claude.installTiming'));
|
|
219
220
|
}
|
|
220
221
|
if (status.others)
|
|
221
|
-
prompts.out(
|
|
222
|
-
prompts.out('
|
|
222
|
+
prompts.out(t('claude.otherHandlers', { count: status.others }));
|
|
223
|
+
prompts.out(t('claude.projectHooks'));
|
|
223
224
|
if (status.disabled)
|
|
224
|
-
prompts.out('
|
|
225
|
+
prompts.out(t('claude.disabledWarning'));
|
|
225
226
|
}
|
|
226
227
|
else if (!quiet)
|
|
227
|
-
prompts.out('
|
|
228
|
+
prompts.out(t('claude.removeScope'));
|
|
228
229
|
if (options.confirm !== false
|
|
229
|
-
&& !yes(await prompts.text(action === 'install' ? '
|
|
230
|
-
prompts.out('
|
|
230
|
+
&& !yes(await prompts.text(action === 'install' ? t('claude.confirmInstall') : t('claude.confirmRemove')))) {
|
|
231
|
+
prompts.out(t('claude.cancelled'));
|
|
231
232
|
return false;
|
|
232
233
|
}
|
|
233
234
|
await commit(file, before, target, prompts, options.replace);
|
|
234
|
-
prompts.out(action === 'install' ? '
|
|
235
|
-
: '本工具的 Claude Code hooks 已移除。请在 /hooks 中检查,必要时重启会话。');
|
|
235
|
+
prompts.out(action === 'install' ? t('claude.installed') : t('claude.removed'));
|
|
236
236
|
return true;
|
|
237
237
|
}
|
package/dist/claude-hook.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import { ConfigError, decodeUtf8, loadConfig, ready } from './config.js';
|
|
2
2
|
import { makePlan } from './channels.js';
|
|
3
3
|
import { NOTIFICATION_MATCHER } from './claude-code.js';
|
|
4
|
+
import { t } from './i18n.js';
|
|
4
5
|
import { findSessionName } from './reply-platform.js';
|
|
5
6
|
import { lifecycleHook, interactionHook, attachReply, mapReplies, notificationCoverage } from './reply-hooks.js';
|
|
6
7
|
export const MAX_HOOK_BYTES = 8 * 1024 * 1024;
|
|
7
8
|
export async function readHookInput(input = process.stdin, limit = MAX_HOOK_BYTES) {
|
|
8
9
|
const chunks = [];
|
|
9
10
|
let size = 0;
|
|
10
|
-
const timer = setTimeout(() => input.destroy(new ConfigError('
|
|
11
|
+
const timer = setTimeout(() => input.destroy(new ConfigError(t('hook.timeout'))), 15000);
|
|
11
12
|
timer.unref();
|
|
12
13
|
try {
|
|
13
14
|
for await (const chunk of input) {
|
|
14
15
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
15
16
|
size += bytes.length;
|
|
16
17
|
if (size > limit)
|
|
17
|
-
throw new ConfigError('
|
|
18
|
+
throw new ConfigError(t('hook.tooLarge'));
|
|
18
19
|
chunks.push(bytes);
|
|
19
20
|
}
|
|
20
21
|
return Buffer.concat(chunks);
|
|
@@ -31,44 +32,43 @@ function metadata(value, limit) {
|
|
|
31
32
|
return chars.length > limit ? chars.slice(0, limit).join('') + '…' : clean;
|
|
32
33
|
}
|
|
33
34
|
function footer(data, sessionName) {
|
|
34
|
-
const project = typeof data.cwd === 'string' && data.cwd.trim() ?
|
|
35
|
-
const topic = typeof sessionName === 'string' && sessionName.trim() ?
|
|
36
|
-
const session = typeof data.session_id === 'string' && data.session_id.trim() ?
|
|
35
|
+
const project = typeof data.cwd === 'string' && data.cwd.trim() ? t('hook.footerProject', { value: metadata(data.cwd, 300) }) : '';
|
|
36
|
+
const topic = typeof sessionName === 'string' && sessionName.trim() ? t('hook.footerTopic', { value: metadata(sessionName.trim(), 120) }) : '';
|
|
37
|
+
const session = typeof data.session_id === 'string' && data.session_id.trim() ? t('hook.footerSession', { value: metadata(data.session_id, 120) }) : '';
|
|
37
38
|
return [project, topic, session].filter(Boolean).join('\n');
|
|
38
39
|
}
|
|
39
40
|
export function claudeHookMessage(bytes, sessionName = '') {
|
|
40
41
|
if (bytes.byteLength > MAX_HOOK_BYTES)
|
|
41
|
-
throw new ConfigError('
|
|
42
|
+
throw new ConfigError(t('hook.tooLarge'));
|
|
42
43
|
let data;
|
|
43
44
|
try {
|
|
44
45
|
data = JSON.parse(decodeUtf8(bytes));
|
|
45
46
|
}
|
|
46
47
|
catch {
|
|
47
|
-
throw new ConfigError('
|
|
48
|
+
throw new ConfigError(t('hook.invalidJson'));
|
|
48
49
|
}
|
|
49
50
|
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
50
|
-
throw new ConfigError('
|
|
51
|
+
throw new ConfigError(t('hook.notObject'));
|
|
51
52
|
if (data.hook_event_name !== 'Stop' && data.hook_event_name !== 'Notification')
|
|
52
53
|
return null;
|
|
53
54
|
const details = footer(data, sessionName);
|
|
54
55
|
if (data.hook_event_name === 'Stop') {
|
|
55
56
|
if (data.stop_hook_active !== undefined && typeof data.stop_hook_active !== 'boolean')
|
|
56
|
-
throw new ConfigError('
|
|
57
|
+
throw new ConfigError(t('hook.invalidStopState'));
|
|
57
58
|
if (data.stop_hook_active === true)
|
|
58
59
|
return null;
|
|
59
60
|
if (data.last_assistant_message !== undefined && typeof data.last_assistant_message !== 'string')
|
|
60
|
-
throw new ConfigError('
|
|
61
|
-
return { title: '
|
|
62
|
-
content: data.last_assistant_message?.trim() ? data.last_assistant_message
|
|
63
|
-
: '本轮回复已结束,Claude Code 没有提供最终正文。请回到终端查看;这里不会读取会话文件来补正文,以免显示过期内容。' };
|
|
61
|
+
throw new ConfigError(t('hook.invalidReply'));
|
|
62
|
+
return { title: t('hook.replyTitle'), level: 'success', footer: details,
|
|
63
|
+
content: data.last_assistant_message?.trim() ? data.last_assistant_message : t('hook.replyFallback') };
|
|
64
64
|
}
|
|
65
65
|
// 空闲提醒在 Stop 之后重复触发;只转发明确需要用户处理的通知。
|
|
66
66
|
if (!NOTIFICATION_MATCHER.split('|').includes(data.notification_type))
|
|
67
67
|
return null;
|
|
68
68
|
if (data.message !== undefined && typeof data.message !== 'string')
|
|
69
|
-
throw new ConfigError('
|
|
70
|
-
return { title: '
|
|
71
|
-
content: data.message?.trim() ? data.message : '
|
|
69
|
+
throw new ConfigError(t('hook.invalidNotification'));
|
|
70
|
+
return { title: t('hook.notificationTitle'), level: 'warn', footer: details,
|
|
71
|
+
content: data.message?.trim() ? data.message : t('hook.notificationFallback') };
|
|
72
72
|
}
|
|
73
73
|
export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
74
74
|
const message = claudeHookMessage(bytes);
|
|
@@ -79,7 +79,7 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
79
79
|
await interactionHook(data, configFile, options);
|
|
80
80
|
}
|
|
81
81
|
catch {
|
|
82
|
-
context.err('
|
|
82
|
+
context.err(t('hook.interactionFailed'));
|
|
83
83
|
}
|
|
84
84
|
if (!message)
|
|
85
85
|
return;
|
|
@@ -88,7 +88,7 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
88
88
|
sessionName = await findSessionName(data.session_id, { env: context.env });
|
|
89
89
|
}
|
|
90
90
|
catch {
|
|
91
|
-
context.err('
|
|
91
|
+
context.err(t('hook.sessionNameFailed'));
|
|
92
92
|
}
|
|
93
93
|
const notification = sessionName ? claudeHookMessage(bytes, sessionName) ?? message : message;
|
|
94
94
|
let binding = null;
|
|
@@ -96,7 +96,7 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
96
96
|
binding = await attachReply(notification, data, configFile, options);
|
|
97
97
|
}
|
|
98
98
|
catch {
|
|
99
|
-
context.err('
|
|
99
|
+
context.err(t('hook.replyNotReady'));
|
|
100
100
|
}
|
|
101
101
|
const config = await loadConfig(configFile, true, context.env);
|
|
102
102
|
let covered = { telegram: [], feishu: false };
|
|
@@ -104,7 +104,7 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
104
104
|
covered = await notificationCoverage(data, configFile, config, options);
|
|
105
105
|
}
|
|
106
106
|
catch {
|
|
107
|
-
context.err('
|
|
107
|
+
context.err(t('hook.coverageFailed'));
|
|
108
108
|
}
|
|
109
109
|
let channels = 0;
|
|
110
110
|
for (const channel of ['telegram', 'feishu']) {
|
|
@@ -125,12 +125,12 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
125
125
|
mapReplies(plan, binding);
|
|
126
126
|
const result = await context.send(plan, config);
|
|
127
127
|
if (!result.ok)
|
|
128
|
-
context.err(
|
|
128
|
+
context.err(t('hook.sendIncomplete', { channel }));
|
|
129
129
|
}
|
|
130
130
|
catch {
|
|
131
|
-
context.err(
|
|
131
|
+
context.err(t('hook.sendFailed', { channel }));
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
if (!channels)
|
|
135
|
-
throw new ConfigError('
|
|
135
|
+
throw new ConfigError(t('hook.noChannels'));
|
|
136
136
|
}
|