@quecue/dev-notify 0.2.3 → 0.2.5
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 +14 -0
- package/CHANGELOG.md +14 -0
- package/README.en.md +5 -5
- package/README.md +5 -5
- package/SECURITY.en.md +3 -3
- package/SECURITY.md +3 -3
- package/dist/claude-hook.js +8 -5
- package/dist/cli.js +1 -1
- package/dist/i18n.js +28 -6
- package/dist/maintenance.js +27 -14
- package/dist/notification-diagnostics.js +41 -0
- package/dist/reply-bridge.js +65 -44
- package/dist/reply-hooks.js +9 -1
- package/dist/reply-lock.js +116 -0
- package/dist/reply-telegram.js +2 -3
- package/dist/update.js +12 -0
- package/package.json +1 -1
package/CHANGELOG.en.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](CHANGELOG.md)
|
|
4
4
|
|
|
5
|
+
## 0.2.5 — 2026-11-07
|
|
6
|
+
|
|
7
|
+
- Distinguish Telegram `getMe` failures during reply-button preparation, bridge unreadiness, and notification-delivery failures. Retain bounded API error codes and report safe delivery categories and API-acknowledged part counts, without exposing underlying exceptions or server bodies. Do not infer exact send error codes discarded by the communication library.
|
|
8
|
+
- Add isolated regressions for Telegram failure with successful buttonless Feishu delivery, bridge unreadiness with ordinary delivery on both channels, and recovery with buttons on both channels. No new retries, replays, or authorization.
|
|
9
|
+
- Cover explicit channel rejection, unknown exceptions, and successful API delivery followed by local message-mapping failure without incorrectly claiming non-delivery or resending. Windows x64 / Node 24 totals 247 tests: 246 passed, 0 failed, and 1 opt-in native credential test skipped; not macOS real-machine acceptance.
|
|
10
|
+
- This release improves diagnostics; it does not establish or fix the specific cause of historical missing notifications on any particular device.
|
|
11
|
+
|
|
12
|
+
## 0.2.4 — 2026-11-07
|
|
13
|
+
|
|
14
|
+
- Recover valid bot locks belonging to confirmed exited processes so a crashed reply bridge does not permanently remove Reply to Claude buttons. Live processes, reused PIDs, unknown permissions, and damaged locks are not preempted; concurrent recovery is serialized.
|
|
15
|
+
- Maintain an independent bridge heartbeat and distinguish process liveness from readiness. Bound startup waiting at 15 seconds and ignore errors left by an exited instance while a new instance starts.
|
|
16
|
+
- Restore an authorized but stopped bridge during `update` maintenance; wait for the old process to exit rather than treating a stale heartbeat as an exit. After npm installation, verify the current entry's package version and report a mismatch instead of maintaining hooks from an old Node/npm installation.
|
|
17
|
+
- Add dead-lock, concurrent recovery, actual isolated child-process termination, heartbeat, stale-error, and update-maintenance regressions. Current totals: 237 tests, 236 passed and 1 opt-in native credential test skipped; not acceptance on every platform.
|
|
18
|
+
|
|
5
19
|
## 0.2.3 — 2026-11-07
|
|
6
20
|
|
|
7
21
|
- Change `@quecue/notify-core` from exact `0.1.0` to the compatible range `^0.1.0`, allowing stable releases `>=0.1.0 <0.2.0`. The lockfile still pins the tested `0.1.0`; update package validation and tests accordingly. This does not change the published `0.2.2`.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
简体中文 | [English](CHANGELOG.en.md)
|
|
4
4
|
|
|
5
|
+
## 0.2.5 — 2026-11-07
|
|
6
|
+
|
|
7
|
+
- 区分回复按钮的 Telegram `getMe` 身份查询失败、桥接未就绪与通知投递失败;保留有界 API 错误码,投递失败显示安全分类及 API 确认分段数,不输出底层异常或服务端正文。通讯库未保留的具体发送错误码不会猜测。
|
|
8
|
+
- 补充 Telegram 失败而飞书成功且无按钮、桥接未就绪仍发送双渠道普通通知、恢复后双渠道带按钮的隔离回归;不新增重试、补发或授权。
|
|
9
|
+
- 补充渠道明确拒绝、未知异常和 API 成功但本地消息映射失败的回归,避免误报未送达或重复发送。Windows x64/Node 24 共 247 项测试:246 通过、0 失败、1 项显式原生凭据测试跳过;不代表 macOS 实机验收。
|
|
10
|
+
- 本版改进故障诊断,不代表已确认或修复某台设备历史漏发的具体根因。
|
|
11
|
+
|
|
12
|
+
## 0.2.4 — 2026-11-07
|
|
13
|
+
|
|
14
|
+
- 修复回复桥接异常退出后残留 Bot 锁导致通知不再出现“回复 Claude”按钮:仅回收格式有效且已确认进程退出的锁;活进程、PID 复用、权限未知或损坏锁均不抢占,并串行化并发恢复。
|
|
15
|
+
- 独立续写桥接心跳,区分“进程存活”和“就绪”;启动等待延长为有界 15 秒,不再被上次已退出实例的错误提前中断。
|
|
16
|
+
- `update` 维护恢复已授权但未运行的桥接;重启前等待旧进程真正退出,而非依据过期心跳提前启动。npm 安装结束后核验当前入口对应的包版本,若更新了另一 Node/npm 安装则明确失败,不从旧入口改写 hooks。
|
|
17
|
+
- 新增死亡锁、并发恢复、真实隔离子进程强杀、心跳、旧错误及升级维护回归;当前共 237 项测试,236 通过、1 项显式原生凭据测试跳过,不代表所有平台实机验收。
|
|
18
|
+
|
|
5
19
|
## 0.2.3 — 2026-11-07
|
|
6
20
|
|
|
7
21
|
- 将 `@quecue/notify-core` 依赖由精确 `0.1.0` 改为兼容范围 `^0.1.0`,允许 `>=0.1.0 <0.2.0` 的稳定版本。锁文件仍固定已验证的 `0.1.0`;同步发布校验和测试。此调整不改变已发布的 `0.2.2`。
|
package/README.en.md
CHANGED
|
@@ -53,7 +53,7 @@ dev-notify update --yes # skip confirmation (suitable for scripts)
|
|
|
53
53
|
dev-notify update --no-post # update the package only, skip hooks/bridge maintenance
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
Actual integrity verification, registry configuration, permission handling, and native dependency resolution are still handled by npm.
|
|
56
|
+
Actual integrity verification, registry configuration, permission handling, and native dependency resolution are still handled by npm. Version 0.2.4 verifies that the current command entry matches the target version after installation. If npm updated another Node/npm installation, it reports a mismatch instead of maintaining from the old entry. Once verified, `update` reinstalls Claude Code hooks bound to the current configuration (when they exist and match) and restores or restarts the authorized reply bridge; use `--no-post` to skip. As of 0.1.3, `update` performs the same maintenance even when already up to date, so the same command can complete post-upgrade cleanup.
|
|
57
57
|
|
|
58
58
|
## Features
|
|
59
59
|
|
|
@@ -172,11 +172,11 @@ dev-notify reply disable # revoke authorization and request stop after confir
|
|
|
172
172
|
dev-notify reply run # start in the foreground to diagnose startup/network issues; do not run in parallel with a start instance
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
-
Stop sends a request through a control file with a random instance identifier and does not force-kill based on an old PID. System input that has already started cannot be withdrawn. `status` reflects only
|
|
175
|
+
Stop sends a request through a control file with a random instance identifier and does not force-kill based on an old PID. System input that has already started cannot be withdrawn. Version 0.2.4 maintains an independent heartbeat and separates liveness from readiness: an expired heartbeat is not an exited process. Startup waits at most 15 seconds and is not interrupted by an exited instance's old error. `status` still reflects only local state; Telegram end-to-end and real Claude require separate acceptance.
|
|
176
176
|
|
|
177
177
|
Authorization is in `<notification config>.reply.json`; messages/sessions/offset/queue/interaction state in `<notification config>.reply-data/`; the bot lock in `reply-bots/` under the data directory shown by `paths`. Queued reply text, questions, tool input, and answers pending return are briefly written to local state; normal reply text is removed before submission; structured interactions are retained until completion or confirmation timeout, after which question, argument, and answer text are cleared, while result receipts and message IDs are retained briefly for feedback and for rejecting old buttons. Validity checks are logical decisions only; files are not automatically erased after shutdown. POSIX uses 0700/0600; Windows relies on account directory ACLs; other processes of the same user can still read them. Do not commit, share, or package these files (already added to ignore rules and kept out of the npm whitelist).
|
|
178
178
|
|
|
179
|
-
Force-kill may leave locks
|
|
179
|
+
Force-kill may leave locks. Version 0.2.4 can recover a valid bot lock at startup only after confirming its PID no longer exists. Unknown permissions, PIDs reused by a live process, damaged locks, and leftover `.recover` recovery gates fail closed. Other locks such as state-write locks are not recovered automatically; confirm their owning process has exited before handling them manually. Never delete a live process's lock. `reply disable` does not delete historical state on its own; to clear it, first disable or stop, then manually delete the dedicated files after confirmation. Clearing credentials, removing hooks, and deleting reply state are three independent operations.
|
|
180
180
|
|
|
181
181
|
## System credentials and compatibility
|
|
182
182
|
|
|
@@ -314,9 +314,9 @@ For other CLI examples, replace `dev-notify` with `node dist/cli.js`; no global
|
|
|
314
314
|
|
|
315
315
|
`node-src/` is TypeScript source, `dist/` is compiled output, and `test-js/` is tests. Default tests use only fake credentials and mock backends/network; the local native fake-entry round-trip test requires explicitly setting `DEV_NOTIFY_NATIVE_TEST=1`, is enabled only on Windows, uses random IDs, and cleans up afterward.
|
|
316
316
|
|
|
317
|
-
For the
|
|
317
|
+
For the 0.2.4 source, the package whitelist includes only this application's compiled CLI JavaScript, README, SECURITY, CHANGELOG, LICENSE and package.json. Library copies, tests, real configuration, ciphertext and dependency tarballs are excluded. npm resolves runtime dependencies separately. Published package contents depend on the corresponding version. There is no postinstall, auto-start, or self-written download script; prebuilt native dependencies are resolved by npm's platform optional packages, **do not use `--omit=optional`**. macOS x64/arm64 and Windows x64/arm64 prebuilt packages are provided by dependencies; providing packages does not mean this project has completed real-machine acceptance.
|
|
318
318
|
|
|
319
|
-
See [CHANGELOG.en.md](CHANGELOG.en.md) for verification results of published versions. The regression record for
|
|
319
|
+
See [CHANGELOG.en.md](CHANGELOG.en.md) for verification results of published versions. The regression record for the 0.2.4 source on Windows x64 with Node 24 is 237 tests: 236 passed, 0 failed, and 1 opt-in native credential test skipped. This record does not establish real-machine acceptance on every platform.
|
|
320
320
|
|
|
321
321
|
Automated coverage includes temporary hooks, reply/authorization/expiry/anti-replay, single-select/multi-select/custom input, one-time permission JSON, two-sided races, feedback exceptions, and per-recipient duplicate reminder suppression. The actual CLI subprocess completes the loop of background start → PreToolUse registration → PermissionRequest → fake bot button → JSON answer → simulated PostToolUse → final feedback → notification deduplication/subsequent tool reminder → stop; this loop uses mock network and process metadata and cannot replace real-machine input verification. Automated tests for the Windows native input helper class only verify compilation/layout and do not perform a real Send.
|
|
322
322
|
|
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ dev-notify update --yes # 跳过确认(适合脚本)
|
|
|
53
53
|
dev-notify update --no-post # 只更新包,不执行 hooks/桥接维护
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
实际的完整性校验、registry 配置、权限处理和原生依赖解析仍由 npm
|
|
56
|
+
实际的完整性校验、registry 配置、权限处理和原生依赖解析仍由 npm 完成。0.2.4会在安装后核验当前命令入口确实对应目标版本;若 npm 更新了另一份 Node/npm 安装,会明确报错而不从旧入口维护。核验通过后,`update` 会重新安装绑定当前配置的 Claude Code hooks(已存在且配置匹配时),并恢复或重启已授权回复桥接;不需要时可用 `--no-post` 跳过。在 0.1.3 及以后,即使已经是最新版本,`update` 也会执行同样的维护,因此可以用同一条命令完成升级后的收尾。
|
|
57
57
|
|
|
58
58
|
## 已提供的功能
|
|
59
59
|
|
|
@@ -172,11 +172,11 @@ dev-notify reply disable # 确认后撤销授权并请求停止;彻底关
|
|
|
172
172
|
dev-notify reply run # 前台诊断启动/网络问题,不与 start 实例并行
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
-
停止通过实例随机标识的控制文件请求,不会根据旧 PID
|
|
175
|
+
停止通过实例随机标识的控制文件请求,不会根据旧 PID 强制结束进程。已经开始的系统输入无法撤回。0.2.4使用独立心跳区分进程存活与就绪;心跳过期不会被当作进程已退出,启动最多等待 15 秒且不被旧实例错误提前中断。`status` 仍只反映本地状态,Telegram 端到端和真实 Claude 需另行验收。
|
|
176
176
|
|
|
177
177
|
授权在 `<通知配置>.reply.json`,消息/会话/offset/队列/交互状态在 `<通知配置>.reply-data/`;Bot 锁在 `paths` 所示 data 目录的 `reply-bots/`。排队回复原文、问题、工具输入和待回传答案会短暂写入本地状态;提交前会移除普通回复原文;结构化交互保留到完成或确认超时,随后清除问题、参数与答案正文,短期保留结果收据和消息 ID,用于反馈及拒绝旧按钮。有效期检查只是逻辑判断,停机后文件不会自动擦除。POSIX 使用 0700/0600,Windows 依赖账号目录 ACL;同一用户的其他进程仍然可以读取。请不要提交、共享或打包这些文件(已加入忽略规则及 npm 白名单)。
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
强杀可能留下锁。0.2.4可在启动时自动回收有效 Bot 锁,但必须先确认原 PID 已不存在;权限未知、PID 复用为活进程、坏锁及残留 `.recover` 恢复门闩一律保守拒绝。状态写入锁等其他锁不自动回收,需先确认对应进程已退出再人工处理。不能在进程运行时删除锁。`reply disable` 不会擅自删除历史状态;需要清除时,请先禁用或停止,确认后手工删除上述专属文件。清理凭据、移除 hooks 和删除回复状态是三项独立操作。
|
|
180
180
|
|
|
181
181
|
## 系统凭据与兼容性
|
|
182
182
|
|
|
@@ -314,9 +314,9 @@ npm pack --dry-run
|
|
|
314
314
|
|
|
315
315
|
`node-src/` 是 TypeScript 源码,`dist/` 是编译输出,`test-js/` 是测试。默认测试只用假凭据和模拟后端/网络;本机原生假条目往返测试需显式设置 `DEV_NOTIFY_NATIVE_TEST=1`,且只在 Windows 启用,使用随机 ID,结束后清理。
|
|
316
316
|
|
|
317
|
-
|
|
317
|
+
0.2.4 源码的 npm 白名单仅包含本应用 CLI 编译 JS、README、SECURITY、CHANGELOG、LICENSE 和 package.json;不包含通讯库副本、测试、真实配置、密文或依赖 tarball。运行时依赖由 npm 单独解析。已发布版本的包内容以对应版本为准。没有 postinstall、自动启动或自写下载脚本;预编译原生依赖由 npm 的平台 optional 包解析,**请勿使用 `--omit=optional`**。macOS x64/arm64 和 Windows x64/arm64 预编译包由依赖提供,提供包并不等同于本项目已完成实机验收。
|
|
318
318
|
|
|
319
|
-
各已发布版本的验证结果见 [CHANGELOG.md](CHANGELOG.md)
|
|
319
|
+
各已发布版本的验证结果见 [CHANGELOG.md](CHANGELOG.md)。0.2.4 源码在 Windows x64、Node 24 下的回归记录为 237 项:236 通过、0 失败、1 项需显式启用的原生凭据测试跳过;该记录不代表所有平台的实机验收。
|
|
320
320
|
|
|
321
321
|
自动化覆盖临时 hooks、回复/授权/过期/防重放、单选/多选/自定义、单次权限 JSON、双端竞争、反馈异常及按接收人的重复提醒去重。实际 CLI 子进程完成后台 start → PreToolUse 登记 → PermissionRequest → 假 Bot 按钮 → JSON 答案 → 模拟 PostToolUse → 最终反馈 → 通知去重/后续工具提醒 → stop;该闭环使用模拟网络和进程元数据,不能替代实机输入验证。Windows 原生输入帮助类的自动化测试只做编译/布局验证,不会执行真实 Send。
|
|
322
322
|
|
package/SECURITY.en.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](SECURITY.md)
|
|
4
4
|
|
|
5
|
-
The npm package name is `@quecue/dev-notify`, with public source at [QueCue/dev-notify](https://github.com/QueCue/dev-notify). Published versions are authoritative on the npm registry; this document describes the dependency range and security boundaries of 0.2.
|
|
5
|
+
The npm package name is `@quecue/dev-notify`, with public source at [QueCue/dev-notify](https://github.com/QueCue/dev-notify). Published versions are authoritative on the npm registry; this document describes the dependency range and security boundaries of 0.2.4, and a source version alone does not establish that an installation package has been published. This project has not completed a security audit or real-machine acceptance on both platforms.
|
|
6
6
|
|
|
7
7
|
## Communication dependency in current source
|
|
8
8
|
|
|
@@ -67,12 +67,12 @@ Reusing a dependency does not automatically share credentials or authorization,
|
|
|
67
67
|
- Questions, options, tool arguments, reply text, and answers may contain sensitive information; after enabling, questions and the corresponding requests are sent to authorized private chats and configured Feishu, with no automatic redaction. Queued text, request input, answers pending return, and session paths are persistently written to dedicated local state; 0600/0700 cannot replace Windows ACL and cannot defend against same-user processes. Logical TTLs do not automatically erase files after power loss.
|
|
68
68
|
- The dedicated files are `<config>.reply.json`, `<config>.reply-data/`, and the data directory `reply-bots/`, already added to ignore rules and not in the npm whitelist. Disabling does not delete historical state on its own; to clear it, disable and stop first, then confirm deletion of the dedicated files. The bot token exists only in system credentials, environment variables, or memory and is not written to reply state.
|
|
69
69
|
- The bridge does not listen on ports or configure system auto-start. After user authorization, it can be started manually or on demand by hooks; it exits after 24 hours without updates, and also after 5 consecutive polling failures or a 409 conflict. It does not delete webhooks or force-kill old or other instances; cross-machine, cross-implementation bot exclusivity must be ensured by the user. This tool's lock only constrains instances that follow the same local lock directory.
|
|
70
|
-
- Stop sends a control request using a random instance identifier and does not kill processes based on an old PID; stop does not revoke authorization, and later hooks may restart the bridge. Use disable for a complete shutdown; native calls cannot be withdrawn once started.
|
|
70
|
+
- Stop sends a control request using a random instance identifier and does not kill processes based on an old PID; stop does not revoke authorization, and later hooks may restart the bridge. Use disable for a complete shutdown; native calls cannot be withdrawn once started. Version 0.2.4 separates process liveness from heartbeat readiness; an expired heartbeat is not grounds for preemption. A valid bot lock is recovered only when its PID is confirmed absent, with an exclusive recovery gate serializing recovery. Live processes (including reused PIDs), unknown permissions, damaged locks, and leftover recovery gates fail closed; other locks such as state-write locks still require manual handling after exit confirmation. Heartbeat/PID remain status snapshots, not proof of network connectivity.
|
|
71
71
|
|
|
72
72
|
## npm and testing
|
|
73
73
|
|
|
74
74
|
- Runtime has a native credential dependency, with the version and platform optional packages pinned in `package-lock.json`. Keep optional dependencies. There is no postinstall, self-written binary download, or background startup script.
|
|
75
|
-
- For the
|
|
75
|
+
- For the 0.2.4 source, the npm whitelist includes only this application's compiled CLI JS, README, SECURITY, CHANGELOG, LICENSE and package.json. Library copies, tests, real configuration, logs, ciphertext and dependency tarballs are excluded. Published versions are governed by their actual package contents.
|
|
76
76
|
- Third-party native packages carry their own licenses and Rust dependency declarations. Check the supply chain and licenses for releases and dependency upgrades; pinned versions are not equivalent to a completed audit.
|
|
77
77
|
- Default tests use fake credentials, temporary directories, and mock system backends/network. `DEV_NOTIFY_NATIVE_TEST=1` explicitly enables Windows native tests, creates only fake entries with random IDs for this run, cleans up afterward, and does not read the user's existing entries.
|
|
78
78
|
- npm uninstall does not automatically delete user configuration or credentials. To clean up, first run the interactive cleanup command; uninstall lifecycle scripts will not secretly manipulate the user's system storage.
|
package/SECURITY.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
简体中文 | [English](SECURITY.en.md)
|
|
4
4
|
|
|
5
|
-
npm 包名为 `@quecue/dev-notify`,公开源码见 [QueCue/dev-notify](https://github.com/QueCue/dev-notify)。发布版本以 npm registry 为准,本文描述 0.2.
|
|
5
|
+
npm 包名为 `@quecue/dev-notify`,公开源码见 [QueCue/dev-notify](https://github.com/QueCue/dev-notify)。发布版本以 npm registry 为准,本文描述 0.2.4 的依赖范围与安全边界,不能仅依据源码版本认定安装包已发布。本项目尚未完成安全审计或双平台实机验收。
|
|
6
6
|
|
|
7
7
|
## 当前源码的通讯层依赖
|
|
8
8
|
|
|
@@ -67,12 +67,12 @@ npm 包名为 `@quecue/dev-notify`,公开源码见 [QueCue/dev-notify](https:/
|
|
|
67
67
|
- 问题、选项、工具参数、回复正文和答案可能包含敏感信息;启用后,问题和相应请求会发送到授权私聊及已配置飞书,无法自动脱敏。排队文本、请求输入、待回传答案及会话路径会持久写入专属本地状态;0600/0700 无法替代 Windows ACL,也无法防御同一用户的进程。逻辑 TTL 不会在断电后自动擦除文件。
|
|
68
68
|
- 专属文件为 `<config>.reply.json`、`<config>.reply-data/` 与数据目录 `reply-bots/`,已加入忽略规则,且不在 npm 白名单中。禁用不会擅自删除历史状态;需要清除时,请先禁用并停止,再确认删除专属文件。Bot Token 只存在于系统凭据、环境变量或内存中,不会写入回复状态。
|
|
69
69
|
- 桥接不监听端口,也不配置系统自启动。用户授权后可手动或由 hook 按需启动;24 小时没有更新会退出,连续轮询失败 5 次或遇到 409 冲突也会退出。不会删除 webhook,也不会强制结束旧版或其他实例;跨机器、跨实现的 Bot 独占需要由用户保证。本工具的锁只能约束遵守相同本地锁目录的实例。
|
|
70
|
-
- stop 使用随机实例标识发送控制请求,不会依据旧 PID 结束进程;stop 不撤销授权,后续 hook 仍可重启。彻底关闭请使用 disable
|
|
70
|
+
- stop 使用随机实例标识发送控制请求,不会依据旧 PID 结束进程;stop 不撤销授权,后续 hook 仍可重启。彻底关闭请使用 disable;原生调用一旦开始就无法撤回。0.2.4区分进程存活和心跳就绪,心跳过期不作为抢占依据;有效 Bot 锁仅在原 PID 确认不存在时自动回收,并通过独占恢复门闩串行化。活进程(包括 PID 复用)、权限未知、坏锁和残留恢复门闩不抢占;状态写入锁等其他锁仍需人工确认后处理。心跳/PID 只是状态快照,网络连通性需单独验证。
|
|
71
71
|
|
|
72
72
|
## npm 与测试
|
|
73
73
|
|
|
74
74
|
- 运行时有原生凭据依赖,版本和平台 optional 包锁定于 `package-lock.json`。请保留 optional 依赖。项目没有 postinstall、自写二进制下载或后台启动脚本。
|
|
75
|
-
-
|
|
75
|
+
- 0.2.4 源码的 npm 白名单仅包含本应用 CLI 编译 JS、README、SECURITY、CHANGELOG、LICENSE 和 package.json。通讯库副本、测试、真实配置、日志、密文及依赖 tarball 不进入发布包;已发布版本以实际包内容为准。
|
|
76
76
|
- 第三方原生包带有其许可证与 Rust 依赖声明。发布及依赖升级时应核对供应链和许可;固定版本并不等同于完成审计。
|
|
77
77
|
- 默认测试使用假凭据、临时目录和模拟系统后端、网络。`DEV_NOTIFY_NATIVE_TEST=1` 会显式开启 Windows 原生测试,只创建本次随机 ID 的假条目,结束后清理,不读取用户现有条目。
|
|
78
78
|
- npm 卸载不会自动删除用户配置或凭据。需要清理时,请先运行交互清理命令;卸载生命周期脚本不会偷偷操作用户的系统存储。
|
package/dist/claude-hook.js
CHANGED
|
@@ -6,6 +6,7 @@ import { t } from './i18n.js';
|
|
|
6
6
|
import { shouldNotify } from './notifications.js';
|
|
7
7
|
import { transcriptEndTime } from './hook-time.js';
|
|
8
8
|
import { findSessionName } from './reply-platform.js';
|
|
9
|
+
import { ReplyPreparationError, safeDeliveryReason, safeFailureReason } from './notification-diagnostics.js';
|
|
9
10
|
import { lifecycleHook, interactionHook, attachReply, mapReplies, notificationCoverage } from './reply-hooks.js';
|
|
10
11
|
export const MAX_HOOK_BYTES = 8 * 1024 * 1024;
|
|
11
12
|
export async function readHookInput(input = process.stdin, limit = MAX_HOOK_BYTES) {
|
|
@@ -139,8 +140,8 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
139
140
|
try {
|
|
140
141
|
binding = await attachReply(notification, data, configFile, options);
|
|
141
142
|
}
|
|
142
|
-
catch {
|
|
143
|
-
context.err(t('hook.replyNotReady'));
|
|
143
|
+
catch (error) {
|
|
144
|
+
context.err(error instanceof ReplyPreparationError ? error.message : t('hook.replyNotReady'));
|
|
144
145
|
}
|
|
145
146
|
let covered = { telegram: [], feishu: false };
|
|
146
147
|
try {
|
|
@@ -168,10 +169,12 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
168
169
|
mapReplies(plan, binding);
|
|
169
170
|
const result = await context.send(plan, config);
|
|
170
171
|
if (!result.ok)
|
|
171
|
-
context.err(t('hook.
|
|
172
|
+
context.err(t('hook.sendIncompleteDetail', {
|
|
173
|
+
channel, sent: result.sent, planned: result.planned, reason: safeDeliveryReason(result.error),
|
|
174
|
+
}));
|
|
172
175
|
}
|
|
173
|
-
catch {
|
|
174
|
-
context.err(t('hook.
|
|
176
|
+
catch (error) {
|
|
177
|
+
context.err(t('hook.sendFailedDetail', { channel, reason: safeFailureReason(error) }));
|
|
175
178
|
}
|
|
176
179
|
}
|
|
177
180
|
if (!channels)
|
package/dist/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ import { startBridge, stopBridge, bridgeStatus, runBridge } from './reply-bridge
|
|
|
16
16
|
import { updatePackage } from './update.js';
|
|
17
17
|
import { postUpdate } from './maintenance.js';
|
|
18
18
|
import { localeName, parseLocale, resolveLocale, setLocale, t } from './i18n.js';
|
|
19
|
-
export const VERSION = '0.2.
|
|
19
|
+
export const VERSION = '0.2.5';
|
|
20
20
|
const helpText = () => t('cli.help', { version: VERSION });
|
|
21
21
|
function rawLang(argv) {
|
|
22
22
|
for (let i = 0; i < argv.length; i++) {
|
package/dist/i18n.js
CHANGED
|
@@ -237,11 +237,12 @@ const zh = {
|
|
|
237
237
|
'update.maintenanceNote': '将执行 Claude Code hooks 和回复桥接维护。',
|
|
238
238
|
'update.versions': '当前版本 {current},npm 最新版本 {latest}。',
|
|
239
239
|
'update.checkHint': '运行 dev-notify update 更新。',
|
|
240
|
-
'update.postNote': '
|
|
240
|
+
'update.postNote': '更新并核验命令入口后,会重新安装绑定当前配置的 Claude Code hooks,并恢复或重启已授权回复桥接;可用 --no-post 跳过。',
|
|
241
241
|
'update.cancelled': '已取消,没有更新。',
|
|
242
242
|
'update.running': '运行 npm install -g {name}@{latest} ...',
|
|
243
243
|
'update.failed': '更新没有完成。请检查 npm 权限,或手动运行 npm install -g {name}@latest。',
|
|
244
244
|
'update.updated': '已更新到 {latest}。请重新打开终端后运行 dev-notify --version 确认。',
|
|
245
|
+
'update.entryMismatch': 'npm 安装已结束,但当前命令入口未更新到 {latest},可能使用了不同 Node/npm 安装目录。未从旧入口改写 hooks 或重启桥接。请在目标 Node 环境重新打开终端,核对 dev-notify --version 后运行 dev-notify update 完成维护。',
|
|
245
246
|
'maintenance.runFailed': '无法运行更新后的 dev-notify。请重新打开终端后运行 dev-notify --version 检查。',
|
|
246
247
|
'maintenance.reasonHint': '请运行 dev-notify integration claude-code status 或 dev-notify reply status 检查。',
|
|
247
248
|
'maintenance.hooksCheckFailed': '无法检查 Claude Code hooks:{reason}',
|
|
@@ -253,6 +254,7 @@ const zh = {
|
|
|
253
254
|
'maintenance.hooksFailedReason': 'Claude Code hooks 更新失败:{reason}',
|
|
254
255
|
'maintenance.bridgeCheckFailed': '无法检查回复桥接:{reason}',
|
|
255
256
|
'maintenance.restartingBridge': '重启回复桥接 ...',
|
|
257
|
+
'maintenance.startingBridge': '恢复已授权但未运行的回复桥接 ...',
|
|
256
258
|
'maintenance.bridgeStillRunning': '回复桥接仍在退出,请稍后运行 dev-notify reply start。',
|
|
257
259
|
'maintenance.bridgeRestarted': '回复桥接已重启。',
|
|
258
260
|
'maintenance.bridgeStartFailed': '回复桥接启动失败。',
|
|
@@ -303,7 +305,16 @@ const zh = {
|
|
|
303
305
|
'hook.notificationFallback': 'Claude Code 正在等待你的确认或输入,请回到终端处理。',
|
|
304
306
|
'hook.interactionFailed': 'Claude Code 的回复状态或交互处理失败。没有自动选择答案或批准权限,请在本机处理。',
|
|
305
307
|
'hook.sessionNameFailed': '读取不到 Claude Code 会话名称,通知底部将省略主题。',
|
|
306
|
-
'hook.replyNotReady': '
|
|
308
|
+
'hook.replyNotReady': '回复按钮准备失败(会话、授权、本地状态或桥接检查);仍会尝试向各渠道发送普通通知,不代表 Telegram 已送达。',
|
|
309
|
+
'hook.replyIdentityFailed': '回复按钮准备失败:Telegram getMe 身份查询失败({reason});仍会尝试发送不带按钮的普通通知。',
|
|
310
|
+
'hook.sendIncompleteDetail': '{channel}:通知投递或发送后处理未全部完成(API 确认 {sent}/{planned} 段;{reason})。整条消息不会自动重发。',
|
|
311
|
+
'hook.sendFailedDetail': '{channel}:通知发送流程失败({reason})。整条消息不会自动重发。',
|
|
312
|
+
'diagnostic.network': '网络请求失败或响应无效(可能为超时或连接异常,无法进一步区分)',
|
|
313
|
+
'diagnostic.rejected': '渠道明确拒绝请求(当前接口未保留具体错误码)',
|
|
314
|
+
'diagnostic.unknown': '原因未分类;为避免泄密,不显示底层错误',
|
|
315
|
+
'diagnostic.telegramCode': 'Telegram API 错误码 {code}',
|
|
316
|
+
'diagnostic.telegramUnknown': 'Telegram API 返回失败,错误码未知',
|
|
317
|
+
'diagnostic.httpCode': 'HTTP 状态码 {code}',
|
|
307
318
|
'hook.coverageFailed': '没有核验到选择题通知的覆盖情况,将保留普通权限提醒。',
|
|
308
319
|
'hook.sendIncomplete': '{channel}:Claude Code 通知没有完整发送。整条消息不会自动重发。',
|
|
309
320
|
'hook.sendFailed': '{channel}:Claude Code 通知发送失败。请检查配置、系统凭据和网络;整条消息不会自动重发。',
|
|
@@ -477,7 +488,7 @@ const zh = {
|
|
|
477
488
|
'reply.telegramRequestFailed': 'Telegram 桥接请求失败;不输出服务端正文或秘密',
|
|
478
489
|
'reply.bridgeAuthFirst': '远程回复尚未授权,请先运行 reply setup。',
|
|
479
490
|
'reply.bridgeNotAuthorized': '远程回复尚未授权。',
|
|
480
|
-
'reply.bridgeLocked': '
|
|
491
|
+
'reply.bridgeLocked': '同一个 Bot 的桥接锁仍被占用,或不能安全确认其归属。仅确认进程已退出的有效残留锁会自动恢复;活进程、权限未知或损坏锁不会被抢占,请运行 reply status 检查。',
|
|
481
492
|
'reply.webhookConflict': '这个 Bot 已经设置了 webhook,不能同时使用 getUpdates。请先移除 webhook;本工具不会自动删除。',
|
|
482
493
|
'reply.pollFailedLog': 'Telegram 轮询失败;日志中没有记录密钥或服务端正文。',
|
|
483
494
|
'reply.pollFailedFatal': 'Telegram 连续轮询失败 5 次,桥接已经退出。请检查网络、Token 或 API 冲突后重新启动。',
|
|
@@ -733,11 +744,12 @@ secrets are never written in plaintext.`,
|
|
|
733
744
|
'update.maintenanceNote': 'Claude Code hooks and reply bridge maintenance will run.',
|
|
734
745
|
'update.versions': 'Current version {current}; latest npm version {latest}.',
|
|
735
746
|
'update.checkHint': 'Run dev-notify update to update.',
|
|
736
|
-
'update.postNote': 'After updating, Claude Code hooks bound to the current configuration will be reinstalled and
|
|
747
|
+
'update.postNote': 'After updating and verifying the command entry, Claude Code hooks bound to the current configuration will be reinstalled and the authorized reply bridge restored or restarted; use --no-post to skip.',
|
|
737
748
|
'update.cancelled': 'Cancelled; no update was performed.',
|
|
738
749
|
'update.running': 'Running npm install -g {name}@{latest} ...',
|
|
739
750
|
'update.failed': 'The update did not complete. Check npm permissions, or run npm install -g {name}@latest manually.',
|
|
740
751
|
'update.updated': 'Updated to {latest}. Reopen the terminal and run dev-notify --version to confirm.',
|
|
752
|
+
'update.entryMismatch': 'npm finished, but the current command entry was not updated to {latest}; different Node/npm installation directories may be in use. Hooks and the bridge were not maintained from the old entry. Reopen a terminal in the intended Node environment, verify dev-notify --version, then run dev-notify update to finish maintenance.',
|
|
741
753
|
'maintenance.runFailed': 'Could not run the updated dev-notify. Reopen the terminal and run dev-notify --version to check.',
|
|
742
754
|
'maintenance.reasonHint': 'Run dev-notify integration claude-code status or dev-notify reply status to check.',
|
|
743
755
|
'maintenance.hooksCheckFailed': 'Could not check Claude Code hooks: {reason}',
|
|
@@ -749,6 +761,7 @@ secrets are never written in plaintext.`,
|
|
|
749
761
|
'maintenance.hooksFailedReason': 'Claude Code hooks update failed: {reason}',
|
|
750
762
|
'maintenance.bridgeCheckFailed': 'Could not check the reply bridge: {reason}',
|
|
751
763
|
'maintenance.restartingBridge': 'Restarting the reply bridge ...',
|
|
764
|
+
'maintenance.startingBridge': 'Restoring the authorized reply bridge that is not running ...',
|
|
752
765
|
'maintenance.bridgeStillRunning': 'The reply bridge is still exiting; run dev-notify reply start later.',
|
|
753
766
|
'maintenance.bridgeRestarted': 'Reply bridge restarted.',
|
|
754
767
|
'maintenance.bridgeStartFailed': 'The reply bridge failed to start.',
|
|
@@ -799,7 +812,16 @@ secrets are never written in plaintext.`,
|
|
|
799
812
|
'hook.notificationFallback': 'Claude Code is waiting for your confirmation or input. Return to the terminal to handle it.',
|
|
800
813
|
'hook.interactionFailed': 'Claude Code reply state or interaction handling failed. No answer or permission was selected automatically; handle it locally.',
|
|
801
814
|
'hook.sessionNameFailed': 'Could not read the Claude Code session name; the topic will be omitted from the notification footer.',
|
|
802
|
-
'hook.replyNotReady': '
|
|
815
|
+
'hook.replyNotReady': 'Reply button preparation failed during session, authorization, local-state, or bridge checks; normal notifications will still be attempted on each channel. This does not mean Telegram delivery succeeded.',
|
|
816
|
+
'hook.replyIdentityFailed': 'Reply button preparation failed: Telegram getMe identity lookup failed ({reason}); normal notifications without buttons will still be attempted.',
|
|
817
|
+
'hook.sendIncompleteDetail': '{channel}: delivery or post-send processing did not fully complete (API acknowledged {sent}/{planned} parts; {reason}). The message will not be resent automatically.',
|
|
818
|
+
'hook.sendFailedDetail': '{channel}: notification sending failed ({reason}). The message will not be resent automatically.',
|
|
819
|
+
'diagnostic.network': 'Network request failed or response was invalid (possibly a timeout or connection failure; cannot distinguish further)',
|
|
820
|
+
'diagnostic.rejected': 'The channel explicitly rejected the request (the current interface does not retain its exact error code)',
|
|
821
|
+
'diagnostic.unknown': 'Unclassified reason; underlying error withheld to protect secrets',
|
|
822
|
+
'diagnostic.telegramCode': 'Telegram API error code {code}',
|
|
823
|
+
'diagnostic.telegramUnknown': 'Telegram API failed with an unknown error code',
|
|
824
|
+
'diagnostic.httpCode': 'HTTP status {code}',
|
|
803
825
|
'hook.coverageFailed': 'Could not verify question-notification coverage; the general permission reminder will be kept.',
|
|
804
826
|
'hook.sendIncomplete': '{channel}: the Claude Code notification was not fully sent. The message will not be resent automatically.',
|
|
805
827
|
'hook.sendFailed': '{channel}: the Claude Code notification failed to send. Check configuration, system credentials, and network; the message will not be resent automatically.',
|
|
@@ -973,7 +995,7 @@ secrets are never written in plaintext.`,
|
|
|
973
995
|
'reply.telegramRequestFailed': 'The Telegram bridge request failed; the server body and secrets are not printed',
|
|
974
996
|
'reply.bridgeAuthFirst': 'Remote replies are not authorized yet. Run reply setup first.',
|
|
975
997
|
'reply.bridgeNotAuthorized': 'Remote replies are not authorized yet.',
|
|
976
|
-
'reply.bridgeLocked': '
|
|
998
|
+
'reply.bridgeLocked': 'The bridge lock for the same bot is occupied or its ownership cannot be verified safely. Only valid locks belonging to confirmed exited processes are recovered automatically; live processes, unknown permissions, and damaged locks are not preempted. Run reply status to check.',
|
|
977
999
|
'reply.webhookConflict': 'This bot already has a webhook and cannot use getUpdates at the same time. Remove the webhook first; this tool will not delete it automatically.',
|
|
978
1000
|
'reply.pollFailedLog': 'Telegram polling failed; no secrets or server body are written to the log.',
|
|
979
1001
|
'reply.pollFailedFatal': 'Telegram polling failed 5 times in a row; the bridge has exited. Check the network, token, or API conflicts, then start it again.',
|
package/dist/maintenance.js
CHANGED
|
@@ -6,6 +6,7 @@ import { ConfigError } from './config.js';
|
|
|
6
6
|
import { t } from './i18n.js';
|
|
7
7
|
import { claudeCodeStatus } from './claude-code.js';
|
|
8
8
|
import { bridgeStatus } from './reply-bridge.js';
|
|
9
|
+
import { accessReplies } from './reply-state.js';
|
|
9
10
|
function runCli(args) {
|
|
10
11
|
const cli = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
11
12
|
const result = spawnSync(process.execPath, [cli, ...args], { stdio: 'inherit', timeout: 300000 });
|
|
@@ -62,22 +63,34 @@ export async function postUpdate(options) {
|
|
|
62
63
|
options.err(t('maintenance.bridgeCheckFailed', { reason: reason(error) }));
|
|
63
64
|
failed = true;
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
let resume = bridgeState?.running === true;
|
|
67
|
+
if (bridgeState && !resume) {
|
|
67
68
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
resume = await (options.authorized ?? (async () => !!await accessReplies(options.config, options.env)))();
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
options.err(t('maintenance.bridgeCheckFailed', { reason: reason(error) }));
|
|
73
|
+
failed = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (bridgeState && resume) {
|
|
77
|
+
options.out(t(bridgeState.running ? 'maintenance.restartingBridge' : 'maintenance.startingBridge'));
|
|
78
|
+
try {
|
|
79
|
+
let stillRunning = bridgeState.running;
|
|
80
|
+
if (stillRunning) {
|
|
81
|
+
// 心跳过期不等于进程退出;必须等待活进程真正退出,才启动新版本。
|
|
82
|
+
run(['reply', 'stop', '--config', options.config]);
|
|
83
|
+
for (let i = 0; i < 20; i++) {
|
|
84
|
+
try {
|
|
85
|
+
stillRunning = (await bridge()).running;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (!stillRunning)
|
|
91
|
+
break;
|
|
92
|
+
await wait(500);
|
|
77
93
|
}
|
|
78
|
-
if (!stillRunning)
|
|
79
|
-
break;
|
|
80
|
-
await wait(500);
|
|
81
94
|
}
|
|
82
95
|
if (stillRunning) {
|
|
83
96
|
options.err(t('maintenance.bridgeStillRunning'));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { HttpError, TelegramApiError, TransportError } from '@quecue/notify-core';
|
|
2
|
+
import { ConfigError } from './config.js';
|
|
3
|
+
import { t } from './i18n.js';
|
|
4
|
+
// 仅保留类型和有界状态码;不输出底层异常、URL、Token 或服务端正文。
|
|
5
|
+
export class TelegramRequestError extends ConfigError {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code) {
|
|
8
|
+
super(t(code === 409 ? 'reply.telegramConflict' : 'reply.telegramRequestFailed'));
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function safeFailureReason(error) {
|
|
13
|
+
if (error instanceof TelegramRequestError || error instanceof TelegramApiError) {
|
|
14
|
+
const code = error.code;
|
|
15
|
+
return Number.isSafeInteger(code) && code >= 100 && code <= 599
|
|
16
|
+
? t('diagnostic.telegramCode', { code }) : t('diagnostic.telegramUnknown');
|
|
17
|
+
}
|
|
18
|
+
if (error instanceof HttpError) {
|
|
19
|
+
const code = error.status;
|
|
20
|
+
return Number.isSafeInteger(code) && code >= 100 && code <= 599
|
|
21
|
+
? t('diagnostic.httpCode', { code }) : t('diagnostic.network');
|
|
22
|
+
}
|
|
23
|
+
if (error instanceof TransportError)
|
|
24
|
+
return t('diagnostic.network');
|
|
25
|
+
return t('diagnostic.unknown');
|
|
26
|
+
}
|
|
27
|
+
export class ReplyPreparationError extends ConfigError {
|
|
28
|
+
constructor(error) {
|
|
29
|
+
super(t('hook.replyIdentityFailed', { reason: safeFailureReason(error) }));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// 公共库目前只返回安全文本而非错误类型。仅匹配本地已知值,不转发任意字符串。
|
|
33
|
+
export function safeDeliveryReason(error) {
|
|
34
|
+
for (const key of ['channels.network', 'channels.noResponse', 'channels.responseTooLarge', 'channels.notJsonObject']) {
|
|
35
|
+
if (error === t(key))
|
|
36
|
+
return t('diagnostic.network');
|
|
37
|
+
}
|
|
38
|
+
if (error === t('channels.rejected'))
|
|
39
|
+
return t('diagnostic.rejected');
|
|
40
|
+
return t('diagnostic.unknown');
|
|
41
|
+
}
|
package/dist/reply-bridge.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { mkdir,
|
|
2
|
+
import { mkdir, unlink, realpath } from 'node:fs/promises';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
@@ -8,6 +8,7 @@ import { appPaths, ConfigError, readConfigLanguage } from './config.js';
|
|
|
8
8
|
import { resolveLocale, setLocale, t } from './i18n.js';
|
|
9
9
|
import { accessReplies, atomicJson, readJson, replyDirectory, stateTransaction } from './reply-state.js';
|
|
10
10
|
import { telegramApi } from './reply-telegram.js';
|
|
11
|
+
import { acquireBotLock, processState } from './reply-lock.js';
|
|
11
12
|
import { drainReplies, processUpdate } from './reply-engine.js';
|
|
12
13
|
import { drainInteractionFeedback } from './reply-outcomes.js';
|
|
13
14
|
const leasePath = (file) => path.join(replyDirectory(file), 'bridge.json');
|
|
@@ -15,18 +16,16 @@ const controlPath = (file) => path.join(replyDirectory(file), 'stop.json');
|
|
|
15
16
|
function alive(pid) {
|
|
16
17
|
if (!Number.isSafeInteger(pid) || Number(pid) <= 0)
|
|
17
18
|
return false;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
19
|
+
// 权限不足不能当作已退出,否则升级维护可能提前启动第二个消费者。
|
|
20
|
+
return processState(Number(pid)) !== 'dead';
|
|
21
|
+
}
|
|
22
|
+
function statusOf(lease) {
|
|
23
|
+
const running = !!lease && alive(lease.pid);
|
|
24
|
+
const fresh = !!lease && Number.isFinite(lease.updated) && Date.now() - lease.updated < 30000 && lease.updated <= Date.now() + 10000;
|
|
25
|
+
return { running, ready: lease?.ready === true && running && fresh && !lease.error && processState(lease.pid) === 'alive', error: lease?.error };
|
|
25
26
|
}
|
|
26
27
|
export async function bridgeStatus(file) {
|
|
27
|
-
|
|
28
|
-
const running = !!lease && alive(lease.pid) && Date.now() - lease.updated < 30000;
|
|
29
|
-
return { running, ready: !!lease?.ready && running, error: lease?.error };
|
|
28
|
+
return statusOf(await readJson(leasePath(file)));
|
|
30
29
|
}
|
|
31
30
|
export async function stopBridge(file) {
|
|
32
31
|
const lease = await readJson(leasePath(file));
|
|
@@ -42,29 +41,30 @@ export async function stopBridge(file) {
|
|
|
42
41
|
}
|
|
43
42
|
return false;
|
|
44
43
|
}
|
|
45
|
-
export async function startBridge(file, env = process.env) {
|
|
44
|
+
export async function startBridge(file, env = process.env, deps = {}) {
|
|
46
45
|
const access = await accessReplies(file, env);
|
|
47
46
|
if (!access)
|
|
48
47
|
throw new ConfigError(t('reply.bridgeAuthFirst'));
|
|
49
|
-
const
|
|
48
|
+
const previous = await readJson(leasePath(file));
|
|
49
|
+
const existing = statusOf(previous);
|
|
50
50
|
if (existing.running)
|
|
51
51
|
return existing.ready;
|
|
52
52
|
const node = await realpath(process.execPath), cli = await realpath(fileURLToPath(new URL('./cli.js', import.meta.url)));
|
|
53
53
|
// 无 shell、无命令行秘密、无系统开机自启;worker 自己持有排他锁。
|
|
54
|
-
const child = spawn(node, [cli, 'reply', 'run', '--config', path.resolve(file)], {
|
|
54
|
+
const child = (deps.spawn ?? spawn)(node, [cli, 'reply', 'run', '--config', path.resolve(file)], {
|
|
55
55
|
detached: true, windowsHide: true, shell: false, stdio: 'ignore', env: { ...process.env, ...env },
|
|
56
56
|
});
|
|
57
|
-
let failed = false;
|
|
57
|
+
let failed = false, exited = false;
|
|
58
58
|
child.once('error', () => { failed = true; });
|
|
59
|
+
child.once('exit', () => { exited = true; });
|
|
59
60
|
child.unref();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (status.ready)
|
|
61
|
+
// 实际网络启动可能超过3秒;只等待有界15秒,不因旧进程留下的error提前放弃。
|
|
62
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
63
|
+
await (deps.sleep ?? delay)(150);
|
|
64
|
+
const lease = await readJson(leasePath(file));
|
|
65
|
+
if (statusOf(lease).ready)
|
|
66
66
|
return true;
|
|
67
|
-
if (
|
|
67
|
+
if (failed || exited || lease?.pid === child.pid && lease?.owner !== previous?.owner && lease?.error)
|
|
68
68
|
return false;
|
|
69
69
|
}
|
|
70
70
|
return false;
|
|
@@ -74,19 +74,17 @@ export async function runBridge(file, options = {}) {
|
|
|
74
74
|
let access = await accessReplies(file, env);
|
|
75
75
|
if (!access)
|
|
76
76
|
throw new ConfigError(t('reply.bridgeNotAuthorized'));
|
|
77
|
+
const heartbeatMs = options.heartbeatMs ?? 5000;
|
|
78
|
+
if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 10 || heartbeatMs > 10000)
|
|
79
|
+
throw new ConfigError(t('reply.bridgeFailed'));
|
|
77
80
|
const directory = replyDirectory(file);
|
|
78
81
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
79
82
|
const botDirectory = path.join(appPaths(process.platform, env).data, 'reply-bots');
|
|
80
83
|
await mkdir(botDirectory, { recursive: true, mode: 0o700 });
|
|
81
84
|
const lockPath = path.join(botDirectory, access.policy.bot + '.lock');
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
throw new ConfigError(t('reply.bridgeLocked'));
|
|
88
|
-
}
|
|
89
|
-
const owner = randomBytes(16).toString('hex'), lease = { pid: process.pid, owner, ready: false, updated: Date.now() };
|
|
85
|
+
const owner = randomBytes(16).toString('hex');
|
|
86
|
+
const lock = await acquireBotLock(lockPath, owner);
|
|
87
|
+
const lease = { pid: process.pid, owner, ready: false, updated: Date.now() };
|
|
90
88
|
let stopping = false;
|
|
91
89
|
const stop = () => { stopping = true; };
|
|
92
90
|
process.on('SIGTERM', stop);
|
|
@@ -95,9 +93,27 @@ export async function runBridge(file, options = {}) {
|
|
|
95
93
|
const log = options.log ?? (() => { });
|
|
96
94
|
let lastActivity = Date.now();
|
|
97
95
|
const isStopped = async () => stopping || options.stopped?.() === true || (await readJson(controlPath(file)))?.owner === owner;
|
|
96
|
+
let writes = Promise.resolve();
|
|
97
|
+
const saveLease = () => {
|
|
98
|
+
lease.updated = Date.now();
|
|
99
|
+
const snapshot = { ...lease };
|
|
100
|
+
const next = writes.then(() => atomicJson(leasePath(file), snapshot));
|
|
101
|
+
writes = next.catch(() => { });
|
|
102
|
+
return next;
|
|
103
|
+
};
|
|
104
|
+
let heartbeat;
|
|
105
|
+
let beating = false, heartbeatFailed = false;
|
|
106
|
+
const stopHeartbeat = () => { stopping = true; if (heartbeat)
|
|
107
|
+
clearInterval(heartbeat); };
|
|
98
108
|
try {
|
|
99
|
-
await
|
|
100
|
-
|
|
109
|
+
await saveLease();
|
|
110
|
+
heartbeat = setInterval(() => {
|
|
111
|
+
if (beating || stopping)
|
|
112
|
+
return;
|
|
113
|
+
beating = true;
|
|
114
|
+
void saveLease().catch(() => { heartbeatFailed = true; stopping = true; }).finally(() => { beating = false; });
|
|
115
|
+
}, heartbeatMs);
|
|
116
|
+
heartbeat.unref();
|
|
101
117
|
let api = (options.api ?? (value => telegramApi(value.config.telegram.token)))(access);
|
|
102
118
|
const webhook = await api('getWebhookInfo', {});
|
|
103
119
|
if (webhook?.url)
|
|
@@ -114,8 +130,7 @@ export async function runBridge(file, options = {}) {
|
|
|
114
130
|
});
|
|
115
131
|
}
|
|
116
132
|
lease.ready = true;
|
|
117
|
-
|
|
118
|
-
await atomicJson(leasePath(file), lease);
|
|
133
|
+
await saveLease();
|
|
119
134
|
let iteration = 0, failures = 0;
|
|
120
135
|
while (!await isStopped() && iteration++ < (options.maxIterations ?? Infinity)) {
|
|
121
136
|
// 桥接是长驻进程,语言切换后要跟着配置文件走;否则后续消息会和 hook 进程的语言不一致。
|
|
@@ -145,8 +160,7 @@ export async function runBridge(file, options = {}) {
|
|
|
145
160
|
log(t('reply.pollFailedLog'));
|
|
146
161
|
if (++failures >= 5)
|
|
147
162
|
throw new ConfigError(t('reply.pollFailedFatal'));
|
|
148
|
-
|
|
149
|
-
await atomicJson(leasePath(file), lease);
|
|
163
|
+
await saveLease();
|
|
150
164
|
await (options.sleep ?? delay)(3000);
|
|
151
165
|
continue;
|
|
152
166
|
}
|
|
@@ -168,25 +182,32 @@ export async function runBridge(file, options = {}) {
|
|
|
168
182
|
}
|
|
169
183
|
if (updates.length)
|
|
170
184
|
lastActivity = Date.now();
|
|
171
|
-
|
|
172
|
-
await atomicJson(leasePath(file), lease);
|
|
185
|
+
await saveLease();
|
|
173
186
|
if (Date.now() - lastActivity > 24 * 60 * 60 * 1000)
|
|
174
187
|
break;
|
|
175
188
|
}
|
|
189
|
+
if (heartbeatFailed)
|
|
190
|
+
throw new ConfigError(t('reply.bridgeFailed'));
|
|
176
191
|
}
|
|
177
192
|
catch (error) {
|
|
193
|
+
stopHeartbeat();
|
|
178
194
|
lease.ready = false;
|
|
179
195
|
lease.error = error instanceof ConfigError ? error.message : t('reply.bridgeFailed');
|
|
180
|
-
await
|
|
196
|
+
await saveLease().catch(() => { });
|
|
181
197
|
throw error;
|
|
182
198
|
}
|
|
183
199
|
finally {
|
|
200
|
+
stopHeartbeat();
|
|
201
|
+
await writes;
|
|
184
202
|
process.off('SIGTERM', stop);
|
|
185
203
|
process.off('SIGINT', stop);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
204
|
+
try {
|
|
205
|
+
const current = await readJson(leasePath(file)).catch(() => null);
|
|
206
|
+
if (current?.owner === owner && !lease.error)
|
|
207
|
+
await unlink(leasePath(file)).catch(() => { });
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
await lock.release();
|
|
211
|
+
}
|
|
191
212
|
}
|
|
192
213
|
}
|
package/dist/reply-hooks.js
CHANGED
|
@@ -6,6 +6,7 @@ import { findSession } from './reply-platform.js';
|
|
|
6
6
|
import { textHash } from './reply-engine.js';
|
|
7
7
|
import { telegramApi, replyHint } from './reply-telegram.js';
|
|
8
8
|
import { bridgeStatus, startBridge } from './reply-bridge.js';
|
|
9
|
+
import { ReplyPreparationError } from './notification-diagnostics.js';
|
|
9
10
|
import { createInteraction, questionsContent, showInteraction, waitInteraction } from './reply-interactions.js';
|
|
10
11
|
import { makePlan, deliver } from './channels.js';
|
|
11
12
|
import { completeToolCall, endSessionInteractions, finishInteraction, matchingCall, registerToolCall } from './reply-outcomes.js';
|
|
@@ -115,7 +116,14 @@ export async function attachReply(message, data, file, options = {}) {
|
|
|
115
116
|
options.err?.(t('reply.bridgeNotReadyButton'));
|
|
116
117
|
return null;
|
|
117
118
|
}
|
|
118
|
-
const api = apiFor(access, options)
|
|
119
|
+
const api = apiFor(access, options);
|
|
120
|
+
let bot;
|
|
121
|
+
try {
|
|
122
|
+
bot = await api('getMe', {});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
throw new ReplyPreparationError(error);
|
|
126
|
+
}
|
|
119
127
|
if (typeof bot?.username !== 'string' || !/^[A-Za-z0-9_]{5,64}$/.test(bot.username))
|
|
120
128
|
throw new ConfigError(t('reply.botUsernameInvalid'));
|
|
121
129
|
const token = randomBytes(16).toString('hex');
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { open, readFile, unlink, lstat } from 'node:fs/promises';
|
|
2
|
+
import { ConfigError } from './config.js';
|
|
3
|
+
import { t } from './i18n.js';
|
|
4
|
+
export function processState(pid) {
|
|
5
|
+
try {
|
|
6
|
+
process.kill(pid, 0);
|
|
7
|
+
return 'alive';
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
return error.code === 'ESRCH' ? 'dead' : 'unknown';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// 只有 ESRCH 可证明进程已退出;权限不足、PID复用、坏文件都不允许抢占。
|
|
14
|
+
export async function acquireBotLock(file, owner, probe = processState) {
|
|
15
|
+
if (!/^[a-f0-9]{32}$/.test(owner))
|
|
16
|
+
throw new ConfigError(t('reply.bridgeLocked'));
|
|
17
|
+
const record = { pid: process.pid, owner };
|
|
18
|
+
const readRecord = async () => {
|
|
19
|
+
try {
|
|
20
|
+
const info = await lstat(file);
|
|
21
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 4096)
|
|
22
|
+
throw new Error();
|
|
23
|
+
const value = JSON.parse(await readFile(file, 'utf8'));
|
|
24
|
+
if (!Number.isSafeInteger(value?.pid) || value.pid <= 0 || typeof value.owner !== 'string' ||
|
|
25
|
+
!/^[a-f0-9]{32}$/.test(value.owner))
|
|
26
|
+
throw new Error();
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error.code === 'ENOENT')
|
|
31
|
+
return null;
|
|
32
|
+
throw new ConfigError(t('reply.bridgeLocked'));
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const dead = (value) => {
|
|
36
|
+
if (!value)
|
|
37
|
+
return true;
|
|
38
|
+
try {
|
|
39
|
+
return probe(value.pid) === 'dead';
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const create = async () => {
|
|
46
|
+
const handle = await open(file, 'wx', 0o600);
|
|
47
|
+
try {
|
|
48
|
+
await handle.writeFile(JSON.stringify(record));
|
|
49
|
+
await handle.sync();
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
try {
|
|
53
|
+
await handle.close();
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await unlink(file);
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
let released = false;
|
|
61
|
+
return { async release() {
|
|
62
|
+
if (released)
|
|
63
|
+
return;
|
|
64
|
+
released = true;
|
|
65
|
+
try {
|
|
66
|
+
const current = await readRecord();
|
|
67
|
+
if (current?.pid === record.pid && current.owner === record.owner)
|
|
68
|
+
await unlink(file);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await handle.close();
|
|
72
|
+
}
|
|
73
|
+
} };
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
return await create();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code !== 'EEXIST')
|
|
80
|
+
throw new ConfigError(t('reply.bridgeLocked'));
|
|
81
|
+
}
|
|
82
|
+
if (!dead(await readRecord()))
|
|
83
|
+
throw new ConfigError(t('reply.bridgeLocked'));
|
|
84
|
+
// 串行化“检查死亡→删除→重新创建”,两个恢复者不能误删对方的新锁。
|
|
85
|
+
// 恢复门闩自身若损坏或残留则保守拒绝,不递归猜测并抢锁。
|
|
86
|
+
const recoveryFile = file + '.recover';
|
|
87
|
+
let gate;
|
|
88
|
+
try {
|
|
89
|
+
gate = await open(recoveryFile, 'wx', 0o600);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new ConfigError(t('reply.bridgeLocked'));
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await gate.writeFile(JSON.stringify(record));
|
|
96
|
+
const current = await readRecord();
|
|
97
|
+
if (!dead(current))
|
|
98
|
+
throw new ConfigError(t('reply.bridgeLocked'));
|
|
99
|
+
if (current)
|
|
100
|
+
await unlink(file);
|
|
101
|
+
try {
|
|
102
|
+
return await create();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new ConfigError(t('reply.bridgeLocked'));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
try {
|
|
110
|
+
await gate.close();
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await unlink(recoveryFile);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dist/reply-telegram.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createTelegramApi, TelegramApiError } from '@quecue/notify-core';
|
|
|
2
2
|
import { requestJson } from './channels.js';
|
|
3
3
|
import { ConfigError } from './config.js';
|
|
4
4
|
import { t } from './i18n.js';
|
|
5
|
+
import { TelegramRequestError } from './notification-diagnostics.js';
|
|
5
6
|
export function telegramApi(token, request = requestJson) {
|
|
6
7
|
const call = createTelegramApi(token, { request });
|
|
7
8
|
return async (method, payload) => {
|
|
@@ -12,9 +13,7 @@ export function telegramApi(token, request = requestJson) {
|
|
|
12
13
|
}
|
|
13
14
|
catch (error) {
|
|
14
15
|
if (error instanceof TelegramApiError) {
|
|
15
|
-
|
|
16
|
-
throw new ConfigError(t('reply.telegramConflict'));
|
|
17
|
-
throw new ConfigError(t('reply.telegramRequestFailed'));
|
|
16
|
+
throw new TelegramRequestError(error.code);
|
|
18
17
|
}
|
|
19
18
|
throw error;
|
|
20
19
|
}
|
package/dist/update.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import { ConfigError } from './config.js';
|
|
3
4
|
import { t } from './i18n.js';
|
|
4
5
|
import { terminalPrompts } from './prompts.js';
|
|
@@ -78,6 +79,15 @@ async function runMaintenance(options, updated) {
|
|
|
78
79
|
options.out(t(updated ? 'update.postDoneUpdated' : 'update.postDone'));
|
|
79
80
|
return 0;
|
|
80
81
|
}
|
|
82
|
+
async function currentEntryInstalled(expected) {
|
|
83
|
+
try {
|
|
84
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
85
|
+
return pkg.name === NAME && pkg.version === expected;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
81
91
|
export async function updatePackage(current, options) {
|
|
82
92
|
if (!VERSION.test(current))
|
|
83
93
|
throw new ConfigError(t('update.currentFormat'));
|
|
@@ -107,6 +117,8 @@ export async function updatePackage(current, options) {
|
|
|
107
117
|
const result = (options.runner ?? npmRunner)(['install', '-g', `${NAME}@${latest}`], false);
|
|
108
118
|
if (result.status !== 0)
|
|
109
119
|
throw new ConfigError(t('update.failed', { name: NAME }));
|
|
120
|
+
if (!await (options.installed ?? currentEntryInstalled)(latest))
|
|
121
|
+
throw new ConfigError(t('update.entryMismatch', { latest }));
|
|
110
122
|
options.out(t('update.updated', { latest }));
|
|
111
123
|
return await runMaintenance(options, true);
|
|
112
124
|
}
|