@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/SECURITY.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # 安全边界(Node/npm)
2
2
 
3
- 当前源码为 `0.1.2`,npm 包名为 `@quecue/dev-notify`;发布版本以 npm registry 为准,源码更新不等同于已发布。本项目尚未完成安全审计或双平台实机验收。
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 与普通交互终端原生窗口并行等待,最多 5 分钟,不会默认选择。新增 PostToolUse、PostToolUseFailure、PermissionDenied 处理结果,完整安装共 10 类事件。当前核对的是 Claude Code 2.1.316 实现;旧版及特殊宿主的并行路径可能不同。
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 返回的实际答案用于内容确认;两端同时提交相同答案时,无法证明来源。拒绝/失败、取消、缺少结果或回传后 5 分钟确认超时,无法证明实际决定时,会明确报告结果未知,不会重放答案。结果收据在发送前登记,网络失败或强杀可能导致未送达;不会无限重发,也不提供恰好一次保证。撤销授权后不再发送。旧选项会替换为已处理/过期状态;编辑失败时仍会拒绝旧请求。
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('服务端返回的不是 JSON 对象。');
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: '💬 在 Telegram 回复 Claude' }, url: message.reply.url, type: 'primary' }] });
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('Telegram 分段后仍然超过消息长度限制。');
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: '💬 回复 Claude', callback_data: 'dn:r:' + message.reply.token }]] } } : {}) } });
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);
@@ -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('CLAUDE_CONFIG_DIR 需要是绝对路径,Claude Code 设置没有改动');
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('Claude Code 设置超过 1 MiB,操作已停止,文件没有改动。');
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('无法读取 Claude Code 设置,操作已停止。');
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('Claude Code 设置不是合法的 UTF-8 JSON,没有覆盖。');
44
+ throw new ConfigError(t('claude.settingsInvalidJson'));
44
45
  }
45
46
  if (!record(data) || (data.hooks !== undefined && !record(data.hooks)))
46
- throw new ConfigError('Claude Code 设置或 hooks 结构无效,没有覆盖。');
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('Claude Code hook 数组结构无效,没有覆盖。');
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' ? 360 : event === 'PreToolUse' ? 10 : 120 }), statusMessage: HOOK_MARKER };
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('暂时无法获取 Claude Code 设置写入锁。请确认没有安装进程在运行,再处理残留的 .dev-notify.lock。');
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('Claude Code 设置在确认期间发生了变化,没有覆盖,请重试。');
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('Claude Code 设置在保存期间发生了变化,没有覆盖,请重试。');
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('临时文件清理失败。安装结束后请删除 .dev-notify-*.tmp。');
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('锁文件清理失败。安装结束后请删除 .dev-notify.lock。');
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(`Claude Code 用户级设置:${file}`);
198
- prompts.out('没有找到本工具添加的 hooks,设置没有改动。');
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(`Claude Code 用户级设置:${file}`);
207
+ prompts.out(t('claude.settingsPath', { file }));
207
208
  if (JSON.stringify(data) === JSON.stringify(target)) {
208
- prompts.out(action === 'install' ? '本工具 hooks 已经安装,配置一致,无需重复添加。' : '没有找到本工具添加的 hooks,设置没有改动。');
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(`通知配置:${path.resolve(config)}`);
214
- prompts.out('会为这个 Claude Code 配置目录下的所有项目启用通知:任务结束时发送最终回复,需要你确认或输入时也会提醒。');
215
- prompts.out('最终回复和提醒正文可能包含敏感内容,会发送到配置实际生效的 Telegram / 飞书。环境变量仍然优先。');
216
- prompts.out('同时安装会话生命周期和结构化问题 hooks。远程输入需要另行运行 reply setup 授权。');
217
- prompts.out('不会读取整份会话记录。启用回复后,会读取会话的进程元数据;结构化问题或权限请求会发送对应的问题和工具信息。');
218
- prompts.out('PreToolUse 只登记调用身份;PermissionRequest 会和本机终端窗口同时等待,远程回答的有效期是 5 分钟。安装完成事件只用于反馈结果,不会代替你作答。已核对 Claude Code 2.1.316;其他版本或宿主环境需要另行验证。');
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(`当前文件里还有 ${status.others} 个其他相关处理器,可能会重复通知。`);
222
- prompts.out('项目级或插件的 hooks 也可能同时生效;本工具不会自动迁移或删除旧版 hooks。');
222
+ prompts.out(t('claude.otherHandlers', { count: status.others }));
223
+ prompts.out(t('claude.projectHooks'));
223
224
  if (status.disabled)
224
- prompts.out('注意:该文件启用了 disableAllHooks,安装后 hooks 也不会生效;请自行解除禁用。');
225
+ prompts.out(t('claude.disabledWarning'));
225
226
  }
226
227
  else if (!quiet)
227
- prompts.out('只会移除本工具添加的通知、生命周期和交互处理器;其他设置、hooks、通知配置和系统凭据都会保留。如需停止回复桥接,请运行 reply disable 或 reply stop。');
228
+ prompts.out(t('claude.removeScope'));
228
229
  if (options.confirm !== false
229
- && !yes(await prompts.text(action === 'install' ? '安装这些 Claude Code hooks?[y/N] ' : '移除本工具的 Claude Code hooks?[y/N] '))) {
230
- prompts.out('已取消,Claude Code 设置没有改动。');
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' ? 'Claude Code hooks 已安装。请在 /hooks 中检查并按界面确认;如果没有生效,请重启会话。'
235
- : '本工具的 Claude Code hooks 已移除。请在 /hooks 中检查,必要时重启会话。');
235
+ prompts.out(action === 'install' ? t('claude.installed') : t('claude.removed'));
236
236
  return true;
237
237
  }
@@ -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('Claude Code hook 输入超时,通知没有发送。')), 15000);
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('Claude Code hook 输入超过大小限制,通知没有发送;内容不会被截断。');
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() ? `项目:${metadata(data.cwd, 300)}` : '';
35
- const topic = typeof sessionName === 'string' && sessionName.trim() ? `主题:${metadata(sessionName.trim(), 120)}` : '';
36
- const session = typeof data.session_id === 'string' && data.session_id.trim() ? `会话:${metadata(data.session_id, 120)}` : '';
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('Claude Code hook 输入超过大小限制,通知没有发送;内容不会被截断。');
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('Claude Code hook 输入不是合法的 UTF-8 JSON,通知没有发送。');
48
+ throw new ConfigError(t('hook.invalidJson'));
48
49
  }
49
50
  if (!data || typeof data !== 'object' || Array.isArray(data))
50
- throw new ConfigError('Claude Code hook 输入需要是 JSON 对象。');
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('Claude Code Stop 状态字段无效。');
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('Claude Code 最终回复字段无效。');
61
- return { title: 'Claude Code 回复完成', level: 'success', footer: details,
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('Claude Code 通知正文字段无效。');
70
- return { title: 'Claude Code 需要你处理', level: 'warn', footer: details,
71
- content: data.message?.trim() ? data.message : 'Claude Code 正在等待你的确认或输入,请回到终端处理。' };
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('Claude Code 的回复状态或交互处理失败。没有自动选择答案或批准权限,请在本机处理。');
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('读取不到 Claude Code 会话名称,通知底部将省略主题。');
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(`${channel}:Claude Code 通知没有完整发送。整条消息不会自动重发。`);
128
+ context.err(t('hook.sendIncomplete', { channel }));
129
129
  }
130
130
  catch {
131
- context.err(`${channel}:Claude Code 通知发送失败。请检查配置、系统凭据和网络;整条消息不会自动重发。`);
131
+ context.err(t('hook.sendFailed', { channel }));
132
132
  }
133
133
  }
134
134
  if (!channels)
135
- throw new ConfigError('Claude Code 通知没有可用渠道,请运行 dev-notify doctor 检查。');
135
+ throw new ConfigError(t('hook.noChannels'));
136
136
  }