@quecue/dev-notify 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +18 -0
- package/CHANGELOG.md +18 -0
- package/README.en.md +41 -5
- package/README.md +41 -5
- package/SECURITY.en.md +1 -1
- package/SECURITY.md +1 -1
- package/dist/claude-hook.js +48 -5
- package/dist/cli.js +1 -1
- package/dist/config.js +57 -2
- package/dist/hook-time.js +67 -0
- package/dist/i18n.js +54 -2
- package/dist/notifications.js +39 -0
- package/dist/setup.js +52 -5
- package/dist/telegram-discovery.js +63 -0
- package/package.json +1 -1
package/CHANGELOG.en.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](CHANGELOG.md)
|
|
4
4
|
|
|
5
|
+
## 0.2.0 — 2026-11-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `setup` now reads the Telegram bot's recent updates, lists recent chats that have talked to the bot, and supports selecting them with `#1,#3`; it falls back to manual entry when the bridge is running, the bot has a webhook configured, or the network request fails. Only chat_id and name are extracted; message text is never displayed or saved.
|
|
10
|
+
- New `notifications` configuration section: `events.stop` / `events.waiting` control completion notifications and waiting reminders; `projects.allow` / `deny` filter by absolute project path prefixes; `quiet_hours` supports local time and ranges that cross midnight.
|
|
11
|
+
- New `templates.footer` custom footer template with `{project}`, `{topic}`, `{session}`, and `{time}`. `{time}` prefers the timestamp of the last assistant record at the end of the transcript (when Claude Code actually finished) and falls back to the hook trigger time, never the time the notification is sent.
|
|
12
|
+
|
|
13
|
+
### Improved
|
|
14
|
+
|
|
15
|
+
- Quiet hours and project filtering suppress only ordinary notifications; they do not start the reply bridge or create reply buttons. Remote question answering, tool permissions, and lifecycle events are unaffected.
|
|
16
|
+
- The transcript is read only when the template contains `{time}`, only up to the last 2 MiB, only under the Claude config directory's `projects` folder, and only to extract the timestamp without reading or sending body text.
|
|
17
|
+
|
|
18
|
+
### Verification
|
|
19
|
+
|
|
20
|
+
- Automated regression totals 203 items: 202 passed, 0 failed, and 1 native credential test requiring explicit enablement was skipped.
|
|
21
|
+
- Windows x64 / Node 24 real-machine verification covered the four-line footer template, real Telegram/Feishu notifications, and remote-reply state cleanup.
|
|
22
|
+
|
|
5
23
|
## 0.1.5 — 2026-11-04
|
|
6
24
|
|
|
7
25
|
### Fixed
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
简体中文 | [English](CHANGELOG.en.md)
|
|
4
4
|
|
|
5
|
+
## 0.2.0 — 2026-11-04
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
|
|
9
|
+
- `setup` 自动读取 Telegram Bot 最近的更新,列出最近与 Bot 对话的会话并支持 `#1,#3` 选择;桥接正在运行、Bot 配置了 webhook 或网络失败时回退手动填写。只提取 chat_id 和名称,不显示或保存消息正文。
|
|
10
|
+
- 新增 `notifications` 配置段:`events.stop` / `events.waiting` 分别控制完成通知和等待提醒;`projects.allow` / `deny` 按项目绝对路径前缀过滤;`quiet_hours` 支持本机本地时间和跨午夜静默。
|
|
11
|
+
- 新增 `templates.footer` 自定义通知底部模板,支持 `{project}`、`{topic}`、`{session}`、`{time}`。`{time}` 优先使用 transcript 尾部最后一条 assistant 记录的时间(Claude Code 实际结束时间),读取不到时回退 hook 触发时间,不是发送时间。
|
|
12
|
+
|
|
13
|
+
### 改进
|
|
14
|
+
|
|
15
|
+
- 静默或项目过滤只抑制普通通知,不会启动回复桥接或生成回复按钮;远程答题、工具权限处理和生命周期事件不受影响。
|
|
16
|
+
- 仅在模板包含 `{time}` 时读取 transcript 尾部最多 2 MiB,路径限制在 Claude 配置目录的 `projects` 下,只提取时间戳,不读取或发送正文。
|
|
17
|
+
|
|
18
|
+
### 验证
|
|
19
|
+
|
|
20
|
+
- 自动化回归共 203 项:202 通过、0 失败、1 项需要显式启用的原生凭据测试跳过。
|
|
21
|
+
- Windows x64 / Node 24 实机完成四行 footer 模板、真实 Telegram/飞书通知与远程回复状态清理验证。
|
|
22
|
+
|
|
5
23
|
## 0.1.5 — 2026-11-04
|
|
6
24
|
|
|
7
25
|
### 修复
|
package/README.en.md
CHANGED
|
@@ -57,17 +57,18 @@ Actual integrity verification, registry configuration, permission handling, and
|
|
|
57
57
|
|
|
58
58
|
- Telegram and Feishu notification channels, split by rendered length without silently truncating content.
|
|
59
59
|
- Text, strict UTF-8 files, or standard input; channel selection, notification level, and auxiliary metadata.
|
|
60
|
-
- Interactive configuration, hidden input, system credential storage, and environment variable overrides.
|
|
60
|
+
- Interactive configuration (including Telegram recipient discovery), hidden input, system credential storage, and environment variable overrides.
|
|
61
61
|
- Configuration update conflict detection, failure recovery, and credential cleanup.
|
|
62
62
|
- By default, installation sends a test notification as part of the summary confirmation, which `--no-test` skips; a failed test does not roll back saved configuration.
|
|
63
63
|
- By default, installs the Claude Code integration as part of the summary confirmation: final replies and waiting notifications; `--no-hooks` skips this, and independent query and removal are supported.
|
|
64
|
+
- Claude Code notifications can be filtered by completion/waiting event and project absolute paths, with local-time quiet hours.
|
|
64
65
|
- Separately authorized Telegram button replies, direct message replies, and Feishu jumps; each segment maps to a specific session.
|
|
65
66
|
- Normal replies queue locally, are submitted to the terminal, and `UserPromptSubmit` confirms receipt, with the states kept distinct.
|
|
66
67
|
- `AskUserQuestion` original options, single-select/multi-select, and custom answers are returned directly through the official hook JSON.
|
|
67
68
|
- When separately enabled, you can allow or deny tool permissions one at a time, or provide a custom denial reason; no persistent permission rules are written.
|
|
68
69
|
- Offline diagnostics via `paths`, `doctor`, `status`, and `send --dry-run`; manage the bridge with `reply start/status/stop/disable/run`.
|
|
69
70
|
|
|
70
|
-
**General notifications do not depend on Claude Code.** npm installation does not modify other applications' configuration or start background services. Notification hooks and remote input are two independent layers of authorization: by default only notifications are sent, and the reply bridge is enabled only after authorization. There is no automatic pairing, remote shell executor, or system auto-start.
|
|
71
|
+
**General notifications do not depend on Claude Code.** npm installation does not modify other applications' configuration or start background services. Notification hooks and remote input are two independent layers of authorization: by default only notifications are sent, and the reply bridge is enabled only after authorization. There is no automatic authorization or pairing, remote shell executor, or system auto-start.
|
|
71
72
|
|
|
72
73
|
## Configuration wizard
|
|
73
74
|
|
|
@@ -77,7 +78,7 @@ node dist/cli.js setup
|
|
|
77
78
|
|
|
78
79
|
1. Choose Telegram, Feishu, or both; when editing an existing configuration, the current selection is shown and pressing Enter keeps it.
|
|
79
80
|
2. Token, webhook, and signing secret are all read as hidden input (not echoed). If the terminal cannot safely hide input, the wizard stops and does not switch to plaintext input.
|
|
80
|
-
3. For Telegram, enter numeric chat_id
|
|
81
|
+
3. For Telegram, setup first reads the bot's recent updates and lists recent private chats, groups, or channels that have talked to the bot (only chat_id and name are extracted; message text is never displayed or saved). Enter `#1,#3` to select them, or enter numeric chat_id values separated by commas. Discovery is skipped and manual entry is used when the bridge is running, the bot has a webhook configured, or the network request fails. Start the bot and send it a message first.
|
|
81
82
|
4. The summary shows channels, recipient count, test notification, Claude Code user-level settings path, and sending scope; save after one confirmation. Input errors can be retried; Ctrl+C cancels.
|
|
82
83
|
5. After confirmation, a fixed-content test notification is sent by default (`--no-test` skips it). A failed test does not roll back successfully saved configuration.
|
|
83
84
|
6. After confirmation, Claude Code notification hooks are merged and installed by default (`--no-hooks` skips it). Existing integrations are left as-is and are not added again.
|
|
@@ -110,7 +111,7 @@ dev-notify integration claude-code remove
|
|
|
110
111
|
For source verification, replace `dev-notify` above with `node dist/cli.js`. Installation can use `--config <absolute or relative path>` to bind a specified notification configuration; at execution it resolves the path to absolute and saves it in the hook arguments. Users with a custom `DEV_NOTIFY_HOME` should set that variable during installation.
|
|
111
112
|
|
|
112
113
|
- **Configuration scope**: by default user-level `~/.claude/settings.json`; when `CLAUDE_CONFIG_DIR` is set, `settings.json` under that absolute directory, covering all projects under that Claude Code configuration directory. Per-project settings are not scanned or modified.
|
|
113
|
-
- **Sending scope**: Stop directly uses the event's `last_assistant_message` to forward the final reply; when there is no body, it sends an explicit completion notice and does not
|
|
114
|
+
- **Sending scope**: Stop directly uses the event's `last_assistant_message` to forward the final reply; when there is no body, it sends an explicit completion notice and does not use a possibly lagging transcript to fill in body text. Only when the footer template uses `{time}` does it read the timestamp of the last assistant record at the end of the transcript, without reading message text. It does not send thinking processes or tool results. The notification footer includes the project absolute path, session name (when readable from the local session registry), and session ID. After remote replies are enabled, it also sends structured questions, options, and separately authorized tool permission request parameters; these may contain sensitive information, so check the recipients before enabling.
|
|
114
115
|
- **Waiting reminders**: forwards `permission_prompt`, `elicitation_dialog`, `elicitation_url_dialog`, and `agent_needs_input`; ignores `idle_prompt`, authentication success, and other notifications. When the current session's last call is a question, there are no other unfinished calls, and the structured notification has been fully delivered, duplicate `permission_prompt` is suppressed per recipient; Feishu separately verifies successful delivery and target. Completed-question override state is retained briefly to handle late reminders; it is not reused after a new round of input, subsequent other tool calls, a session switch, or interaction validity expiry. Recipients not covered, partial or failed delivery, and unverifiable cases still receive the general reminder; other notification types are unaffected. The Notification event itself does not contain complete options, so answers are not guessed from prompt text; reliable option interaction is described in the next section. Unsupported MCP form/URL requests remain handled locally.
|
|
115
116
|
- **Merging and removal**: preserves permissions, environment variables, and other hooks; repeated installation does not accumulate. This tool's hooks in the same user-level settings are updated to the current Node, CLI, and notification configuration paths after confirmation. Only handlers carrying this tool's markers and matching argument structure are removed; the settings file and credentials are not deleted.
|
|
116
117
|
- **Coexistence with old tools**: old, project-level, or plugin hooks are not automatically deleted and may cause duplicate notifications. Installation reports other handlers in the current user-level file; old integrations must be disabled by the user's own decision.
|
|
@@ -201,6 +202,10 @@ The old prototype used `backend: "system"`, which on Windows is a DPAPI file and
|
|
|
201
202
|
{
|
|
202
203
|
"version": 1,
|
|
203
204
|
"language": "zh",
|
|
205
|
+
"notifications": {
|
|
206
|
+
"events": {"stop": true, "waiting": true},
|
|
207
|
+
"projects": {"allow": [], "deny": []}
|
|
208
|
+
},
|
|
204
209
|
"telegram": {"enabled": true, "chat_ids": [], "token_env": "TELEGRAM_BOT_TOKEN"},
|
|
205
210
|
"feishu": {"enabled": true, "webhook_env": "FEISHU_WEBHOOK", "secret_env": "FEISHU_SECRET"}
|
|
206
211
|
}
|
|
@@ -210,6 +215,37 @@ The old prototype used `backend: "system"`, which on Windows is a DPAPI file and
|
|
|
210
215
|
|
|
211
216
|
`--lang zh|en` switches the interface language for a single command, and `DEV_NOTIFY_LANG=zh|en` sets it for the current environment; the persistent setting is stored in the `language` field of the configuration (`zh` or `en`). Precedence is `--lang` > `DEV_NOTIFY_LANG` > configuration > default `zh`. Run `dev-notify language` to view the effective language, and `dev-notify language zh|en` to save a change. CLI output, error messages, notification content, interaction buttons, and reply bridge messages all use the selected language; `setup` saves the current language when creating a new configuration.
|
|
212
217
|
|
|
218
|
+
### Notification filtering and quiet hours
|
|
219
|
+
|
|
220
|
+
The `notifications` section only affects Claude Code completion notifications and waiting reminders; the `send` command, reply bridge, question answering, and tool-permission handling are unaffected. Every field is optional; omitting it keeps the default of sending everything.
|
|
221
|
+
|
|
222
|
+
- `events.stop` controls Stop completion notifications and `events.waiting` controls waiting reminders such as permission requests; both default to `true`.
|
|
223
|
+
- `projects.allow` / `projects.deny` use absolute path prefixes, with `deny` taking precedence. When `allow` is non-empty, only notifications from those directories are sent; when the project path is missing, `allow` does not match and `deny` does not match. Paths are resolved for the local platform; Windows comparisons are case-insensitive.
|
|
224
|
+
- `quiet_hours` uses local time and supports ranges that cross midnight; identical `start` and `end` disable it. During quiet hours, ordinary notifications and reply buttons are suppressed; the bridge, local question answering, and permission handling still run, but waiting reminders are suppressed. Do not rely on notifications during quiet hours if you need to handle permission requests remotely.
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
"notifications": {
|
|
228
|
+
"events": {"stop": true, "waiting": false},
|
|
229
|
+
"projects": {"allow": ["/Users/me/work"], "deny": []},
|
|
230
|
+
"quiet_hours": {"start": "22:00", "end": "08:00"}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Message templates
|
|
235
|
+
|
|
236
|
+
`templates.footer` replaces the project, topic, and session lines at the bottom of Claude Code notifications. Omit it to use the built-in bilingual format; set it to an empty string to hide the footer entirely. Templates are limited to 1000 characters, support newlines, and accept these variables:
|
|
237
|
+
|
|
238
|
+
- `{project}`: absolute project path
|
|
239
|
+
- `{topic}`: session name (when the local session registry provides one)
|
|
240
|
+
- `{session}`: session ID
|
|
241
|
+
- `{time}`: time in `YYYY-MM-DD HH:mm:ss` format. Stop notifications first read the timestamp of the last assistant record at the end of the local transcript, which is when Claude Code actually finished; if the transcript is unreadable or the time is implausible, it falls back to the hook trigger time rather than the time the notification is sent. Notification events use the hook trigger time. The transcript is read only when the template contains `{time}`.
|
|
242
|
+
|
|
243
|
+
Unknown placeholders are left as-is. Variable values have control characters removed and are truncated with an ellipsis when they exceed their limits.
|
|
244
|
+
|
|
245
|
+
```json
|
|
246
|
+
"templates": {"footer": "Project: {project}\nFinished: {time}\nSession: {session}"}
|
|
247
|
+
```
|
|
248
|
+
|
|
213
249
|
### Credentials and recipients
|
|
214
250
|
|
|
215
251
|
- `TELEGRAM_BOT_TOKEN`: Telegram bot token.
|
|
@@ -270,6 +306,6 @@ npm pack --dry-run
|
|
|
270
306
|
|
|
271
307
|
The npm publish whitelist contains only `dist/*.js`, README, SECURITY, CHANGELOG, LICENSE, and the required package.json; tests, configuration, and ciphertext are not included. 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.
|
|
272
308
|
|
|
273
|
-
The local verification environment is Windows x64 with Node 24. Automated regression has
|
|
309
|
+
The local verification environment is Windows x64 with Node 24. Automated regression has 203 items: 202 passed, 0 failed, and 1 native credential test requiring explicit enablement was skipped. Coverage includes temporary hooks, reply/authorization/expiry/anti-replay, single-select/multi-select/custom, 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. Separately, the user completed real-machine acceptance on Windows native Claude Code 2.1.316 with real Telegram for local-first, remote-first, multi-select, custom input, competing different answers, and delayed general reminder deduplication. Isolated compilation and tests do not cover the `dist` referenced by real hooks; automated tests for the Windows native input helper class only verify compilation/layout and do not perform a real Send. macOS's two terminal adapters have only simulation verification; macOS real-machine testing, the minimum Node version, version manager switching, tool permission answering on a real machine, and a full public release audit have not yet been accepted.
|
|
274
310
|
|
|
275
311
|
Licensed under MIT, see [LICENSE](LICENSE). Security boundaries are in [SECURITY.en.md](SECURITY.en.md). Version updates must be published separately to npm; pushing to GitHub does not automatically update the npm package.
|
package/README.md
CHANGED
|
@@ -57,17 +57,18 @@ dev-notify update --no-post # 只更新包,不执行 hooks/桥接维护
|
|
|
57
57
|
|
|
58
58
|
- Telegram、飞书双渠道通知,按渲染后的长度分段,不会悄悄截断内容。
|
|
59
59
|
- 文本、严格 UTF-8 文件或标准输入;支持渠道选择、通知级别、辅助信息。
|
|
60
|
-
-
|
|
60
|
+
- 交互配置(含 Telegram 接收人自动发现)、隐藏输入、系统凭据存储、环境变量覆盖。
|
|
61
61
|
- 配置更新冲突检测、失败恢复、凭据清理。
|
|
62
62
|
- 安装时默认随汇总确认发送测试通知,可用 `--no-test` 跳过;测试失败不会撤销已保存的配置。
|
|
63
63
|
- 默认随汇总确认安装 Claude Code 集成:最终回复、等待通知;可用 `--no-hooks` 跳过,支持独立查询和移除。
|
|
64
|
+
- 可按完成/等待事件、项目绝对路径过滤 Claude Code 通知,并支持本机时间的静默时段。
|
|
64
65
|
- 独立授权的 Telegram 按钮回复、直接回复消息、飞书跳转;逐段映射到具体会话。
|
|
65
66
|
- 普通回复本地排队、终端提交与 `UserPromptSubmit` 收到确认,状态不混淆。
|
|
66
67
|
- `AskUserQuestion` 原始选项、单选/多选、自定义答案,通过官方 hook JSON 直接回传。
|
|
67
68
|
- 另外选择启用后,可逐次允许或拒绝工具权限,或填写自定义拒绝说明;不写永久权限规则。
|
|
68
69
|
- `paths`、`doctor`、`status`、`send --dry-run` 离线诊断;`reply start/status/stop/disable/run` 管理桥接。
|
|
69
70
|
|
|
70
|
-
**通用通知不依赖 Claude Code。** npm 安装不会修改其他应用的配置,也不会启动后台服务。通知 hooks
|
|
71
|
+
**通用通知不依赖 Claude Code。** npm 安装不会修改其他应用的配置,也不会启动后台服务。通知 hooks 和远程输入是两层独立授权:默认只发送通知,授权后才启用回复桥接。没有自动授权配对、远程 shell 执行器或系统开机自启。
|
|
71
72
|
|
|
72
73
|
## 配置向导
|
|
73
74
|
|
|
@@ -77,7 +78,7 @@ node dist/cli.js setup
|
|
|
77
78
|
|
|
78
79
|
1. 选择 Telegram、飞书或两者;编辑已有配置时显示当前选择,直接回车保留。
|
|
79
80
|
2. Token、webhook 和签名密钥均以隐藏输入读取(不回显)。终端无法安全隐藏输入时,向导会停止,不会改用明文输入。
|
|
80
|
-
3. Telegram
|
|
81
|
+
3. Telegram 会先尝试读取 Bot 最近的更新,列出最近与 Bot 对话的私聊、群组或频道(只提取 chat_id 和名称,不显示或保存消息正文),可输入 `#1,#3` 选择;也可以直接填写数字 chat_id,多个用逗号分隔。桥接正在运行、Bot 已配置 webhook 或网络失败时会跳过自动发现并回退手动填写。用户需要先启动 Bot 并给 Bot 发一条消息。
|
|
81
82
|
4. 汇总展示渠道、接收人数、测试通知、Claude Code 用户级设置路径和发送范围,一次确认后保存。输入错误可以重试,Ctrl+C 取消。
|
|
82
83
|
5. 确认后默认发送固定内容的测试通知(`--no-test` 跳过)。测试失败不会撤销已成功保存的配置。
|
|
83
84
|
6. 确认后默认合并安装 Claude Code 通知 hooks(`--no-hooks` 跳过)。已有集成保持原样,不重复添加。
|
|
@@ -110,7 +111,7 @@ dev-notify integration claude-code remove
|
|
|
110
111
|
源码验证时将上述 `dev-notify` 替换为 `node dist/cli.js`。安装可用 `--config <绝对或相对路径>` 绑定指定通知配置;执行时将其解析为绝对路径并保存到 hook 参数。自定义 `DEV_NOTIFY_HOME` 的使用者应在安装时设置该变量。
|
|
111
112
|
|
|
112
113
|
- **配置范围**:默认用户级 `~/.claude/settings.json`,设置 `CLAUDE_CONFIG_DIR` 时使用该绝对目录下的 `settings.json`,覆盖该 Claude Code 配置目录下的全部项目。不会扫描或修改每个项目的设置。
|
|
113
|
-
- **发送范围**:Stop 直接使用事件的 `last_assistant_message`
|
|
114
|
+
- **发送范围**:Stop 直接使用事件的 `last_assistant_message` 转发最终回复;没有正文时会发送明确的完成提示,不会用可能滞后的 transcript 补正文。仅当 footer 模板使用 `{time}` 时,才读取 transcript 尾部最后一条 assistant 记录的时间戳,不读取消息正文。不会发送思考过程或工具结果。通知底部附带项目绝对路径、会话名称(本地会话注册表可读取时)和会话 ID。启用远程回复后还会发送结构化问题、选项,以及另行授权的工具权限请求参数;它们可能含敏感信息,启用前请检查接收目标。
|
|
114
115
|
- **等待提醒**:转发 `permission_prompt`、`elicitation_dialog`、`elicitation_url_dialog`、`agent_needs_input`;忽略 `idle_prompt`、认证成功等其他通知。当前会话最后一项调用为选择题、没有其他未结束调用,且结构化通知已完整投递时,按接收人抑制重复的 `permission_prompt`;飞书会单独核对投递成功及目标。短期保留已结束题目的覆盖状态,以处理迟到提醒;新一轮输入、后续其他工具调用、会话切换或交互有效期结束后不再沿用。没有覆盖到的接收人、只投递了一部分或投递失败、以及无法核验的情况,仍会发送通用提醒;其他通知类型不受影响。Notification 事件本身不包含完整选项,因此不会根据提示文字猜测答案;可靠的选项交互见下节。不支持的 MCP 表单/URL 等请求保留在本机处理。
|
|
115
116
|
- **合并与移除**:保留权限、环境变量和其他 hooks;重复安装不会累加。同一用户级设置中的本工具 hooks 会在确认后更新到当前 Node、CLI 和通知配置路径。只移除带本工具标记及匹配参数结构的处理器,不删除设置文件或凭据。
|
|
116
117
|
- **与旧工具共存**:旧版、项目级或插件 hooks 不会被自动删除,可能造成重复通知。安装时会提示当前用户级文件中的其他处理器;旧集成需由用户自行确认停用。
|
|
@@ -201,6 +202,10 @@ Node 版使用 **Windows Credential Manager / macOS Keychain**,通过原生 No
|
|
|
201
202
|
{
|
|
202
203
|
"version": 1,
|
|
203
204
|
"language": "zh",
|
|
205
|
+
"notifications": {
|
|
206
|
+
"events": {"stop": true, "waiting": true},
|
|
207
|
+
"projects": {"allow": [], "deny": []}
|
|
208
|
+
},
|
|
204
209
|
"telegram": {"enabled": true, "chat_ids": [], "token_env": "TELEGRAM_BOT_TOKEN"},
|
|
205
210
|
"feishu": {"enabled": true, "webhook_env": "FEISHU_WEBHOOK", "secret_env": "FEISHU_SECRET"}
|
|
206
211
|
}
|
|
@@ -210,6 +215,37 @@ Node 版使用 **Windows Credential Manager / macOS Keychain**,通过原生 No
|
|
|
210
215
|
|
|
211
216
|
`--lang zh|en` 可临时切换单次命令的界面语言,`DEV_NOTIFY_LANG=zh|en` 可为当前环境设置语言;持久设置保存在配置的 `language` 字段(`zh` 或 `en`)。优先级为 `--lang` > `DEV_NOTIFY_LANG` > 配置 > 默认 `zh`。运行 `dev-notify language` 查看当前生效语言,`dev-notify language zh|en` 保存切换。CLI 输出、错误提示、通知内容、交互按钮和回复桥接消息都会使用所选语言;`setup` 创建新配置时会保存当前语言。
|
|
212
217
|
|
|
218
|
+
### 通知过滤与静默时段
|
|
219
|
+
|
|
220
|
+
`notifications` 段只影响 Claude Code 的普通完成通知和等待提醒;`send` 命令、远程回复桥接、答题和工具权限处理不受影响。所有字段都是可选的,省略时保持默认全量发送。
|
|
221
|
+
|
|
222
|
+
- `events.stop` 控制 Stop 完成通知,`events.waiting` 控制权限请求等等待提醒;默认都为 `true`。
|
|
223
|
+
- `projects.allow` / `projects.deny` 使用绝对路径前缀匹配,`deny` 优先。`allow` 非空时只发送这些目录下的通知;项目路径缺失时 `allow` 不放行、`deny` 不匹配。路径按本机平台解析,Windows 不区分大小写。
|
|
224
|
+
- `quiet_hours` 使用本机本地时间,支持跨午夜;`start` 与 `end` 相同表示不启用。静默期间不发送普通通知,也不会生成回复按钮;桥接、本地答题和权限处理仍会运行,但等待提醒会被抑制。需要远程处理权限请求时,请不要依赖静默时段内的通知。
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
"notifications": {
|
|
228
|
+
"events": {"stop": true, "waiting": false},
|
|
229
|
+
"projects": {"allow": ["/Users/me/work"], "deny": []},
|
|
230
|
+
"quiet_hours": {"start": "22:00", "end": "08:00"}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### 消息模板
|
|
235
|
+
|
|
236
|
+
`templates.footer` 可替换 Claude Code 通知底部的项目、主题和会话信息。省略时使用内置的双语格式;设为空字符串会完全隐藏底部信息。模板最多 1000 个字符,支持换行,可用变量:
|
|
237
|
+
|
|
238
|
+
- `{project}`:项目绝对路径
|
|
239
|
+
- `{topic}`:会话名称(本地会话注册表可读取时)
|
|
240
|
+
- `{session}`:会话 ID
|
|
241
|
+
- `{time}`:时间,格式为 `YYYY-MM-DD HH:mm:ss`。Stop 通知优先读取本机 transcript 尾部最后一条 assistant 记录的时间,即 Claude Code 实际结束时间;transcript 不可读或时间异常时回退到 hook 触发时间,而不是发送通知的时间。Notification 使用 hook 触发时间。只有模板包含 `{time}` 时才会读取 transcript。
|
|
242
|
+
|
|
243
|
+
未知占位符保持原样。变量值会清理控制字符,超过长度限制的部分截断并加省略号。
|
|
244
|
+
|
|
245
|
+
```json
|
|
246
|
+
"templates": {"footer": "项目:{project}\n结束:{time}\n会话:{session}"}
|
|
247
|
+
```
|
|
248
|
+
|
|
213
249
|
### 凭据与接收人
|
|
214
250
|
|
|
215
251
|
- `TELEGRAM_BOT_TOKEN`:Telegram Bot Token。
|
|
@@ -270,6 +306,6 @@ npm pack --dry-run
|
|
|
270
306
|
|
|
271
307
|
npm 发布白名单只有 `dist/*.js`、README、SECURITY、CHANGELOG、LICENSE 和必需的 package.json;不包含测试、配置或密文。没有 postinstall、自动启动或自写下载脚本;预编译原生依赖由 npm 的平台 optional 包解析,**请勿使用 `--omit=optional`**。macOS x64/arm64 和 Windows x64/arm64 预编译包由依赖提供,提供包并不等同于本项目已完成实机验收。
|
|
272
308
|
|
|
273
|
-
本机验证环境为 Windows x64、Node 24。自动化回归
|
|
309
|
+
本机验证环境为 Windows x64、Node 24。自动化回归 203 项:202 通过、0 失败、1 项需显式启用的原生凭据测试跳过。覆盖临时 hooks、回复/授权/过期/防重放、单选/多选/自定义、单次权限 JSON、双端竞争、反馈异常及按接收人的重复提醒去重。实际 CLI 子进程完成后台 start → PreToolUse 登记 → PermissionRequest → 假 Bot 按钮 → JSON 答案 → 模拟 PostToolUse → 最终反馈 → 通知去重/后续工具提醒 → stop;该闭环使用模拟网络和进程元数据,不能替代实机输入验证。另由用户在 Windows 原生 Claude Code 2.1.316 与真实 Telegram 完成本机优先、远端优先、多选、自定义、不同答案竞争,以及延迟通用提醒去重的实机验收。隔离编译和测试不会覆盖真实 hooks 引用的 dist;Windows 原生输入帮助类的自动化测试只做编译/布局验证,不会执行真实 Send。macOS 两条终端适配器只有模拟验证;macOS 实机、Node 最低版本、版本管理器切换、工具权限应答实机及完整公开发布审计尚未完成验收。
|
|
274
310
|
|
|
275
311
|
采用 MIT,见 [LICENSE](LICENSE)。安全边界见 [SECURITY.md](SECURITY.md)。版本更新需单独发布到 npm,GitHub 推送不会自动更新 npm 安装包。
|
package/SECURITY.en.md
CHANGED
|
@@ -36,7 +36,7 @@ The current source is `0.1.3` and the npm package name is `@quecue/dev-notify`;
|
|
|
36
36
|
## Claude Code integration
|
|
37
37
|
|
|
38
38
|
- `setup` includes notification hooks by default in the same summary confirmation; before confirmation it shows the sending scope, target configuration, and user-level settings path. `--no-hooks` saves only the notification configuration. It writes to `~/.claude/settings.json` by default and respects an absolute-path `CLAUDE_CONFIG_DIR`. Once enabled, it applies to all projects under that configuration directory; final replies may contain secrets or business-sensitive content, and the program does not automatically redact them. Recipients and sending scope must be confirmed by the user.
|
|
39
|
-
- Hooks directly use `last_assistant_message`, do not re-read
|
|
39
|
+
- Hooks directly use `last_assistant_message`, do not re-read transcript body text, and do not actively upload thinking processes or tool results; when body text is missing, only an explicit completion notice is sent. Only when the footer template uses `{time}` does a hook read up to the last 2 MiB of the corresponding transcript under the Claude config directory's `projects` folder to extract the `timestamp` of the last `assistant` record; body text is neither read nor sent. The notification footer includes the absolute `cwd` path, session name, and session ID; the session name is read only from the local Claude Code session registry. Input is limited to 8 MiB with a 15-second read timeout; over-limit is rejected, not truncated. Notifications such as permission prompts may also contain sensitive content.
|
|
40
40
|
- Generates `command` + `args` in a no-shell-exec form, and paths are not expanded through a shell; a fixed installation uses absolute paths for Node/CLI/configuration, and command arguments contain no secrets. Requires a newer Claude Code supporting this form and `async`; after switching Node or moving the installation directory, reinstall hooks.
|
|
41
41
|
- Retains other settings and hooks, deduplicates updates on repeated installation, and removal matches only this tool's markers and argument structure. Old, project-level, or plugin hooks are not automatically deleted and may cause duplicate sending. `status` reflects only local state and does not involve global policy or actual event delivery acceptance; it does not lift disablement, lower permissions, or bypass managed restrictions.
|
|
42
42
|
- Writes use a dedicated exclusive lock, before/after snapshots, and atomic replacement with same-directory temporary files; corrupted JSON is rejected. External editors do not honor this lock, so unconditional race elimination is not claimed; force-kill may leave locks or temporary files, and the program does not clean up locks it did not create in the current attempt.
|
package/SECURITY.md
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
## Claude Code 集成
|
|
37
37
|
|
|
38
38
|
- `setup` 默认把通知 hooks 纳入同一次汇总确认;确认前会展示发送范围、目标配置及用户级设置路径。`--no-hooks` 可以只保存通知配置。默认写入 `~/.claude/settings.json`,并尊重绝对路径 `CLAUDE_CONFIG_DIR`。启用后作用于该配置目录下的所有项目,最终回复可能含密钥或业务敏感内容,程序不会自动脱敏;接收人与发送范围需要用户自行确认。
|
|
39
|
-
- hook 直接使用 `last_assistant_message`,不会回读 transcript
|
|
39
|
+
- hook 直接使用 `last_assistant_message`,不会回读 transcript 正文,也不会主动上传思考过程或工具结果;缺少正文时只发送明确的完成提示。仅当 footer 模板使用 `{time}` 时,才读取 Claude 配置目录 `projects` 下对应 transcript 文件尾部最多 2 MiB,提取最后一条 `assistant` 记录的 `timestamp`;不读取、不发送正文。通知底部包含 `cwd` 绝对路径、会话名称与会话 ID;会话名称只从本地 Claude Code 会话注册表读取。输入最大 8 MiB、读取超时 15 秒,超限时会拒绝,不会截断。权限等 Notification 也可能含敏感内容。
|
|
40
40
|
- 生成 `command` + `args` 的无 shell exec 形式,路径不经过 shell 展开;固定安装时使用 Node/CLI/配置的绝对路径,命令参数不含秘密。需要支持该形式与 `async` 的新版 Claude Code;切换 Node 或移动安装目录后,请重新安装 hooks。
|
|
41
41
|
- 保留其他设置和 hooks,重复安装会去重更新,移除时只匹配本工具标记及参数结构。旧版、项目或插件 hooks 不会被自动删除,可能造成重复发送。`status` 只反映本地状态,不涉及全局策略或实际事件投递验收;不会解除禁用、降低权限或绕过托管限制。
|
|
42
42
|
- 写入使用专用排他锁、前后快照及同目录临时文件原子替换;损坏的 JSON 会被拒绝。外部编辑器不遵守这个锁,因此不会宣称无条件消除竞态;强杀可能留下锁或临时文件,程序不会清理不属于本次创建的锁。
|
package/dist/claude-hook.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { ConfigError, decodeUtf8, loadConfig, ready } from './config.js';
|
|
2
3
|
import { makePlan } from './channels.js';
|
|
3
|
-
import { NOTIFICATION_MATCHER } from './claude-code.js';
|
|
4
|
+
import { claudeSettingsPath, NOTIFICATION_MATCHER } from './claude-code.js';
|
|
4
5
|
import { t } from './i18n.js';
|
|
6
|
+
import { shouldNotify } from './notifications.js';
|
|
7
|
+
import { transcriptEndTime } from './hook-time.js';
|
|
5
8
|
import { findSessionName } from './reply-platform.js';
|
|
6
9
|
import { lifecycleHook, interactionHook, attachReply, mapReplies, notificationCoverage } from './reply-hooks.js';
|
|
7
10
|
export const MAX_HOOK_BYTES = 8 * 1024 * 1024;
|
|
@@ -31,12 +34,32 @@ function metadata(value, limit) {
|
|
|
31
34
|
const chars = Array.from(clean);
|
|
32
35
|
return chars.length > limit ? chars.slice(0, limit).join('') + '…' : clean;
|
|
33
36
|
}
|
|
37
|
+
function footerValues(data, sessionName, time = '') {
|
|
38
|
+
return {
|
|
39
|
+
project: typeof data.cwd === 'string' && data.cwd.trim() ? metadata(data.cwd, 300) : '',
|
|
40
|
+
topic: typeof sessionName === 'string' && sessionName.trim() ? metadata(sessionName.trim(), 120) : '',
|
|
41
|
+
session: typeof data.session_id === 'string' && data.session_id.trim() ? metadata(data.session_id, 120) : '',
|
|
42
|
+
time,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
34
45
|
function footer(data, sessionName) {
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
46
|
+
const values = footerValues(data, sessionName);
|
|
47
|
+
const project = values.project ? t('hook.footerProject', { value: values.project }) : '';
|
|
48
|
+
const topic = values.topic ? t('hook.footerTopic', { value: values.topic }) : '';
|
|
49
|
+
const session = values.session ? t('hook.footerSession', { value: values.session }) : '';
|
|
38
50
|
return [project, topic, session].filter(Boolean).join('\n');
|
|
39
51
|
}
|
|
52
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
53
|
+
/** 使用本机本地时间,格式固定为 YYYY-MM-DD HH:mm:ss。 */
|
|
54
|
+
export function formatTime(time) {
|
|
55
|
+
return `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())} `
|
|
56
|
+
+ `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`;
|
|
57
|
+
}
|
|
58
|
+
/** 只替换已知变量;未知占位符保持原样,便于发现模板拼写错误。 */
|
|
59
|
+
export function renderFooter(template, data, sessionName, time = '') {
|
|
60
|
+
const values = footerValues(data, sessionName, time);
|
|
61
|
+
return template.replace(/\{(project|topic|session|time)\}/g, (_, key) => values[key]);
|
|
62
|
+
}
|
|
40
63
|
export function claudeHookMessage(bytes, sessionName = '') {
|
|
41
64
|
if (bytes.byteLength > MAX_HOOK_BYTES)
|
|
42
65
|
throw new ConfigError(t('hook.tooLarge'));
|
|
@@ -73,6 +96,7 @@ export function claudeHookMessage(bytes, sessionName = '') {
|
|
|
73
96
|
export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
74
97
|
const message = claudeHookMessage(bytes);
|
|
75
98
|
const data = JSON.parse(decodeUtf8(bytes));
|
|
99
|
+
const now = context.now?.() ?? new Date();
|
|
76
100
|
const options = { send: context.send, ...context.reply, env: context.env, err: context.err, response: context.response };
|
|
77
101
|
try {
|
|
78
102
|
await lifecycleHook(data, configFile, options);
|
|
@@ -83,6 +107,11 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
83
107
|
}
|
|
84
108
|
if (!message)
|
|
85
109
|
return;
|
|
110
|
+
const config = await loadConfig(configFile, true, context.env);
|
|
111
|
+
const event = data.hook_event_name === 'Stop' ? 'stop' : 'waiting';
|
|
112
|
+
// 静默或项目过滤只影响普通通知;不启动桥接、不生成回复按钮。
|
|
113
|
+
if (!shouldNotify(config, event, data.cwd, now))
|
|
114
|
+
return;
|
|
86
115
|
let sessionName = '';
|
|
87
116
|
try {
|
|
88
117
|
sessionName = await findSessionName(data.session_id, { env: context.env });
|
|
@@ -91,6 +120,21 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
91
120
|
context.err(t('hook.sessionNameFailed'));
|
|
92
121
|
}
|
|
93
122
|
const notification = sessionName ? claudeHookMessage(bytes, sessionName) ?? message : message;
|
|
123
|
+
if (config.templates.footer !== null) {
|
|
124
|
+
let time = '';
|
|
125
|
+
if (config.templates.footer.includes('{time}')) {
|
|
126
|
+
// Stop 优先使用 transcript 最后一条 assistant 记录的时间;读取不到时回退到 hook 触发时间,而不是发送时间。
|
|
127
|
+
let ended = null;
|
|
128
|
+
if (data.hook_event_name === 'Stop') {
|
|
129
|
+
try {
|
|
130
|
+
ended = await transcriptEndTime(data.transcript_path, path.dirname(claudeSettingsPath(context.env)), now);
|
|
131
|
+
}
|
|
132
|
+
catch { /* 配置目录不可用时使用上面的回退时间。 */ }
|
|
133
|
+
}
|
|
134
|
+
time = formatTime(ended ?? now);
|
|
135
|
+
}
|
|
136
|
+
notification.footer = renderFooter(config.templates.footer, data, sessionName, time);
|
|
137
|
+
}
|
|
94
138
|
let binding = null;
|
|
95
139
|
try {
|
|
96
140
|
binding = await attachReply(notification, data, configFile, options);
|
|
@@ -98,7 +142,6 @@ export async function dispatchClaudeHook(bytes, configFile, context) {
|
|
|
98
142
|
catch {
|
|
99
143
|
context.err(t('hook.replyNotReady'));
|
|
100
144
|
}
|
|
101
|
-
const config = await loadConfig(configFile, true, context.env);
|
|
102
145
|
let covered = { telegram: [], feishu: false };
|
|
103
146
|
try {
|
|
104
147
|
covered = await notificationCoverage(data, configFile, config, options);
|
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.
|
|
19
|
+
export const VERSION = '0.2.0';
|
|
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/config.js
CHANGED
|
@@ -8,6 +8,7 @@ export class ConfigError extends Error {
|
|
|
8
8
|
export const example = {
|
|
9
9
|
version: 1,
|
|
10
10
|
language: 'zh',
|
|
11
|
+
notifications: { events: { stop: true, waiting: true }, projects: { allow: [], deny: [] } },
|
|
11
12
|
telegram: { enabled: true, chat_ids: [], token_env: 'TELEGRAM_BOT_TOKEN' },
|
|
12
13
|
feishu: { enabled: true, webhook_env: 'FEISHU_WEBHOOK', secret_env: 'FEISHU_SECRET' },
|
|
13
14
|
};
|
|
@@ -54,8 +55,61 @@ function secret(section, field, fallback, env, stored) {
|
|
|
54
55
|
return (env[name] ?? '').trim();
|
|
55
56
|
return enabled(section) ? stored().trim() : '';
|
|
56
57
|
}
|
|
58
|
+
function notificationSettings(value) {
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
return {
|
|
61
|
+
events: { stop: true, waiting: true }, projects: { allow: [], deny: [] }, quietHours: null
|
|
62
|
+
};
|
|
63
|
+
const data = object(value, ['events', 'projects', 'quiet_hours'], 'notifications');
|
|
64
|
+
const events = data.events === undefined ? {} : object(data.events, ['stop', 'waiting'], 'notifications.events');
|
|
65
|
+
for (const key of ['stop', 'waiting'])
|
|
66
|
+
if (events[key] !== undefined && typeof events[key] !== 'boolean')
|
|
67
|
+
throw new ConfigError(t('config.notificationBoolean'));
|
|
68
|
+
const projects = data.projects === undefined ? {} : object(data.projects, ['allow', 'deny'], 'notifications.projects');
|
|
69
|
+
const paths = (key) => {
|
|
70
|
+
const list = projects[key] ?? [];
|
|
71
|
+
if (!Array.isArray(list))
|
|
72
|
+
throw new ConfigError(t('config.projectPaths'));
|
|
73
|
+
return [...new Set(list.map(item => {
|
|
74
|
+
if (typeof item !== 'string' || !item.trim() || !path.isAbsolute(item.trim()))
|
|
75
|
+
throw new ConfigError(t('config.projectPathAbsolute'));
|
|
76
|
+
return item.trim();
|
|
77
|
+
}))];
|
|
78
|
+
};
|
|
79
|
+
let quietHours = null;
|
|
80
|
+
if (data.quiet_hours !== undefined && data.quiet_hours !== null) {
|
|
81
|
+
const quiet = object(data.quiet_hours, ['start', 'end'], 'notifications.quiet_hours');
|
|
82
|
+
const time = (key) => {
|
|
83
|
+
const entry = quiet[key];
|
|
84
|
+
if (typeof entry !== 'string' || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(entry))
|
|
85
|
+
throw new ConfigError(t('config.quietFormat'));
|
|
86
|
+
return entry;
|
|
87
|
+
};
|
|
88
|
+
const start = time('start'), end = time('end');
|
|
89
|
+
// 起止相同表示不启用静默,避免误配成全天免打扰。
|
|
90
|
+
if (start !== end)
|
|
91
|
+
quietHours = { start, end };
|
|
92
|
+
}
|
|
93
|
+
return { events: { stop: events.stop !== false, waiting: events.waiting !== false },
|
|
94
|
+
projects: { allow: paths('allow'), deny: paths('deny') }, quietHours };
|
|
95
|
+
}
|
|
96
|
+
function templateSettings(value) {
|
|
97
|
+
if (value === undefined)
|
|
98
|
+
return { footer: null };
|
|
99
|
+
const data = object(value, ['footer'], 'templates');
|
|
100
|
+
if (data.footer === undefined || data.footer === null)
|
|
101
|
+
return { footer: null };
|
|
102
|
+
if (typeof data.footer !== 'string')
|
|
103
|
+
throw new ConfigError(t('config.templateString'));
|
|
104
|
+
if (Array.from(data.footer).length > 1000)
|
|
105
|
+
throw new ConfigError(t('config.templateTooLong'));
|
|
106
|
+
// 允许换行和制表符,拒绝其余控制字符。
|
|
107
|
+
if (/[\x00-\x08\x0b-\x1f\x7f]/.test(data.footer))
|
|
108
|
+
throw new ConfigError(t('config.templateControl'));
|
|
109
|
+
return { footer: data.footer };
|
|
110
|
+
}
|
|
57
111
|
export function parseConfig(input, env = process.env, resolver = resolveReference) {
|
|
58
|
-
const data = object(input, ['version', 'language', 'telegram', 'feishu', 'credentials'], t('config.rootName'));
|
|
112
|
+
const data = object(input, ['version', 'language', 'notifications', 'templates', 'telegram', 'feishu', 'credentials'], t('config.rootName'));
|
|
59
113
|
if ((data.version ?? 1) !== 1)
|
|
60
114
|
throw new ConfigError(t('config.unsupportedVersion'));
|
|
61
115
|
const language = data.language ?? 'zh';
|
|
@@ -106,7 +160,8 @@ export function parseConfig(input, env = process.env, resolver = resolveReferenc
|
|
|
106
160
|
if (!valid)
|
|
107
161
|
throw new ConfigError(t('config.feishuWebhook'));
|
|
108
162
|
}
|
|
109
|
-
return { language,
|
|
163
|
+
return { language, notifications: notificationSettings(data.notifications), templates: templateSettings(data.templates),
|
|
164
|
+
telegram: { enabled: tgEnabled, token, chatIds }, feishu: { enabled: fsEnabled, webhook,
|
|
110
165
|
secret: secret(fs, 'secret_env', 'FEISHU_SECRET', env, () => stored('feishu_secret')) } };
|
|
111
166
|
}
|
|
112
167
|
export function ready(config, channel) {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { open, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MAX_TAIL = 2 * 1024 * 1024;
|
|
4
|
+
const MAX_AGE = 5 * 60 * 1000;
|
|
5
|
+
const MAX_SKEW = 60 * 1000;
|
|
6
|
+
function inside(file, directory) {
|
|
7
|
+
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
8
|
+
const base = normalize(file), root = normalize(directory);
|
|
9
|
+
return base.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
10
|
+
}
|
|
11
|
+
/** 只读取 transcript 尾部,返回最后一条 assistant 记录的时间;不读取或输出消息内容。 */
|
|
12
|
+
export async function transcriptEndTime(file, configRoot, now = new Date()) {
|
|
13
|
+
if (typeof file !== 'string' || !file || !path.isAbsolute(file) || !file.endsWith('.jsonl'))
|
|
14
|
+
return null;
|
|
15
|
+
let resolved, projects;
|
|
16
|
+
try {
|
|
17
|
+
resolved = await realpath(file);
|
|
18
|
+
projects = await realpath(path.join(configRoot, 'projects'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (!inside(resolved, projects))
|
|
24
|
+
return null;
|
|
25
|
+
let handle;
|
|
26
|
+
try {
|
|
27
|
+
handle = await open(resolved, 'r');
|
|
28
|
+
const stat = await handle.stat();
|
|
29
|
+
if (!stat.isFile() || stat.size === 0)
|
|
30
|
+
return null;
|
|
31
|
+
const length = Math.min(stat.size, MAX_TAIL);
|
|
32
|
+
const buffer = Buffer.alloc(length);
|
|
33
|
+
await handle.read(buffer, 0, length, stat.size - length);
|
|
34
|
+
const lines = buffer.toString('utf8').split('\n');
|
|
35
|
+
// 从文件中间开始读取时,首行可能被截断,跳过它。
|
|
36
|
+
const first = stat.size > length ? 1 : 0;
|
|
37
|
+
for (let index = lines.length - 1; index >= first; index--) {
|
|
38
|
+
const line = lines[index].trim();
|
|
39
|
+
if (!line)
|
|
40
|
+
continue;
|
|
41
|
+
let record;
|
|
42
|
+
try {
|
|
43
|
+
record = JSON.parse(line);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (record?.type !== 'assistant' || typeof record.timestamp !== 'string')
|
|
49
|
+
continue;
|
|
50
|
+
const time = new Date(record.timestamp);
|
|
51
|
+
if (!Number.isFinite(time.getTime()))
|
|
52
|
+
continue;
|
|
53
|
+
const age = now.getTime() - time.getTime();
|
|
54
|
+
// 防止把上一轮或异常时钟当成这次结束时间。
|
|
55
|
+
if (age > MAX_AGE || age < -MAX_SKEW)
|
|
56
|
+
return null;
|
|
57
|
+
return time;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
await handle?.close();
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
package/dist/i18n.js
CHANGED
|
@@ -16,6 +16,13 @@ const zh = {
|
|
|
16
16
|
'config.chatIdNonZero': '接收人需要是非零的数字 chat_id。',
|
|
17
17
|
'config.telegramToken': 'Telegram Token 格式不正确。',
|
|
18
18
|
'config.feishuWebhook': '飞书 webhook 需要是 open.feishu.cn 的 HTTPS 自定义机器人地址。',
|
|
19
|
+
'config.notificationBoolean': 'notifications.events 的 stop 和 waiting 需要是布尔值。',
|
|
20
|
+
'config.projectPaths': 'notifications.projects 的 allow 和 deny 需要是路径数组。',
|
|
21
|
+
'config.projectPathAbsolute': 'notifications.projects 的路径需要使用绝对路径。',
|
|
22
|
+
'config.quietFormat': 'notifications.quiet_hours 的 start 和 end 需要使用 HH:MM 格式。',
|
|
23
|
+
'config.templateString': 'templates.footer 需要是字符串。',
|
|
24
|
+
'config.templateTooLong': 'templates.footer 不能超过 1000 个字符。',
|
|
25
|
+
'config.templateControl': 'templates.footer 包含不支持的控制字符。',
|
|
19
26
|
'config.utf8': '输入需要是有效的 UTF-8 文本。',
|
|
20
27
|
'config.unreadable': '配置文件缺失、无法读取,或不是合法的 UTF-8 JSON。',
|
|
21
28
|
'config.exists': '配置已经存在,没有覆盖。Node 版不会修改旧版原型的配置。',
|
|
@@ -76,9 +83,28 @@ const zh = {
|
|
|
76
83
|
'setup.secretOptionalNew': '{label}(隐藏输入,不回显;可选;不会从环境变量自动填充;输入 - 清除):',
|
|
77
84
|
'setup.requiredBlank': '这一项不能为空,请重新输入;按 Ctrl+C 可退出。',
|
|
78
85
|
'setup.invalidFormat': '格式不正确,请重新输入(输入内容不会显示)。',
|
|
79
|
-
'setup.telegramChatInstruction': '请先让接收人给 Bot 发一条消息,然后填写数字 chat_id
|
|
86
|
+
'setup.telegramChatInstruction': '请先让接收人给 Bot 发一条消息,然后填写数字 chat_id;多个用逗号分隔。',
|
|
80
87
|
'setup.telegramChatPrompt': '接收人 chat_id,多个用逗号分隔(留空保留现有名单):',
|
|
81
88
|
'setup.telegramChatInvalid': '请填写至少一个有效的数字 chat_id;按 Ctrl+C 可退出。',
|
|
89
|
+
'setup.discoveryFound': '已发现以下最近与 Bot 对话的会话(只提取 chat_id 和名称,不显示或保存消息正文):',
|
|
90
|
+
'setup.discoveryEmpty': '没有发现最近会话。请让接收人先给 Bot 发一条消息,再重新运行 setup,或直接填写 chat_id。',
|
|
91
|
+
'setup.discoveryBridgeRunning': '远程回复桥接正在运行,已跳过自动发现;请先运行 dev-notify reply stop,或直接填写 chat_id。',
|
|
92
|
+
'setup.discoveryFailed': '自动获取接收人失败:{reason} 请手动填写 chat_id。',
|
|
93
|
+
'setup.discoverySelectionInvalid': '编号超出范围,请重新输入。',
|
|
94
|
+
'setup.discoveryKind.private': '私聊',
|
|
95
|
+
'setup.discoveryKind.group': '群组',
|
|
96
|
+
'setup.discoveryKind.supergroup': '超级群组',
|
|
97
|
+
'setup.discoveryKind.channel': '频道',
|
|
98
|
+
'setup.discoveryKind.unknown': '未知类型',
|
|
99
|
+
'setup.telegramChatInstructionDiscovered': '请选择上面的编号(如 #1,#3),或直接填写数字 chat_id。',
|
|
100
|
+
'setup.telegramChatPromptDiscovered': '接收人编号或 chat_id,多个用逗号分隔(留空保留现有名单):',
|
|
101
|
+
'setup.telegramChatInvalidDiscovered': '请输入有效的编号(如 #1)或至少一个数字 chat_id;按 Ctrl+C 可退出。',
|
|
102
|
+
'discovery.unnamed': '未命名会话',
|
|
103
|
+
'discovery.noToken': 'Bot Token 为空,无法自动获取接收人。',
|
|
104
|
+
'discovery.network': '网络请求失败,无法读取 Bot 更新。',
|
|
105
|
+
'discovery.unauthorized': 'Bot Token 被拒绝,请检查 Token。',
|
|
106
|
+
'discovery.conflict': '该 Bot 已配置 webhook,或其他程序正在读取它的更新;请先移除 webhook 或停止其他消费者,也可以直接填写 chat_id。',
|
|
107
|
+
'discovery.failed': 'Telegram 拒绝了读取更新的请求。',
|
|
82
108
|
'setup.channelsEnabled': '将启用:{channels}',
|
|
83
109
|
'setup.envPriority': '环境变量的优先级更高,空值也会覆盖这里的设置;测试通知使用最终生效的配置。',
|
|
84
110
|
'setup.envOverrides': '以下环境变量正在覆盖配置:{names}',
|
|
@@ -477,6 +503,13 @@ const en = {
|
|
|
477
503
|
'config.chatIdNonZero': 'Recipients must use non-zero numeric chat_id values.',
|
|
478
504
|
'config.telegramToken': 'The Telegram token format is invalid.',
|
|
479
505
|
'config.feishuWebhook': 'The Feishu webhook must be an HTTPS custom bot URL on open.feishu.cn.',
|
|
506
|
+
'config.notificationBoolean': 'notifications.events stop and waiting must be booleans.',
|
|
507
|
+
'config.projectPaths': 'notifications.projects allow and deny must be arrays of paths.',
|
|
508
|
+
'config.projectPathAbsolute': 'notifications.projects paths must be absolute.',
|
|
509
|
+
'config.quietFormat': 'notifications.quiet_hours start and end must use the HH:MM format.',
|
|
510
|
+
'config.templateString': 'templates.footer must be a string.',
|
|
511
|
+
'config.templateTooLong': 'templates.footer must not exceed 1000 characters.',
|
|
512
|
+
'config.templateControl': 'templates.footer contains unsupported control characters.',
|
|
480
513
|
'config.utf8': 'Input must be valid UTF-8 text.',
|
|
481
514
|
'config.unreadable': 'The configuration file is missing, unreadable, or not valid UTF-8 JSON.',
|
|
482
515
|
'config.exists': 'Configuration already exists and was not overwritten. The Node version will not modify old-prototype configuration.',
|
|
@@ -537,9 +570,28 @@ const en = {
|
|
|
537
570
|
'setup.secretOptionalNew': '{label} (hidden input, not echoed; optional; not filled from environment variables; enter - to clear): ',
|
|
538
571
|
'setup.requiredBlank': 'This field cannot be empty. Enter it again; press Ctrl+C to exit.',
|
|
539
572
|
'setup.invalidFormat': 'The format is invalid. Enter it again (input is not displayed).',
|
|
540
|
-
'setup.telegramChatInstruction': 'Have the recipient send the bot a message first, then enter the numeric chat_id;
|
|
573
|
+
'setup.telegramChatInstruction': 'Have the recipient send the bot a message first, then enter the numeric chat_id; separate multiple values with commas.',
|
|
541
574
|
'setup.telegramChatPrompt': 'Recipient chat_id, separated by commas (leave blank to keep the current list): ',
|
|
542
575
|
'setup.telegramChatInvalid': 'Enter at least one valid numeric chat_id; press Ctrl+C to exit.',
|
|
576
|
+
'setup.discoveryFound': 'Recent chats with this bot (only chat_id and name are extracted; message text is never displayed or saved):',
|
|
577
|
+
'setup.discoveryEmpty': 'No recent chats found. Have the recipient send the bot a message and run setup again, or enter the chat_id directly.',
|
|
578
|
+
'setup.discoveryBridgeRunning': 'The remote-reply bridge is running, so automatic discovery was skipped; run dev-notify reply stop first, or enter the chat_id directly.',
|
|
579
|
+
'setup.discoveryFailed': 'Could not fetch recipients automatically: {reason} Enter the chat_id manually.',
|
|
580
|
+
'setup.discoverySelectionInvalid': 'The number is out of range; try again.',
|
|
581
|
+
'setup.discoveryKind.private': 'private',
|
|
582
|
+
'setup.discoveryKind.group': 'group',
|
|
583
|
+
'setup.discoveryKind.supergroup': 'supergroup',
|
|
584
|
+
'setup.discoveryKind.channel': 'channel',
|
|
585
|
+
'setup.discoveryKind.unknown': 'unknown type',
|
|
586
|
+
'setup.telegramChatInstructionDiscovered': 'Choose a number above (for example #1,#3), or enter the numeric chat_id directly.',
|
|
587
|
+
'setup.telegramChatPromptDiscovered': 'Recipient number or chat_id, separated by commas (leave blank to keep the current list): ',
|
|
588
|
+
'setup.telegramChatInvalidDiscovered': 'Enter a valid number (for example #1) or at least one numeric chat_id; press Ctrl+C to exit.',
|
|
589
|
+
'discovery.unnamed': 'Unnamed chat',
|
|
590
|
+
'discovery.noToken': 'The bot token is empty, so recipients cannot be fetched automatically.',
|
|
591
|
+
'discovery.network': 'The network request failed; bot updates could not be read.',
|
|
592
|
+
'discovery.unauthorized': 'The bot token was rejected; check the token.',
|
|
593
|
+
'discovery.conflict': 'This bot has a webhook configured, or another program is reading its updates; remove the webhook, stop the other consumer, or enter the chat_id directly.',
|
|
594
|
+
'discovery.failed': 'Telegram rejected the request to read updates.',
|
|
543
595
|
'setup.channelsEnabled': 'Will enable: {channels}',
|
|
544
596
|
'setup.envPriority': 'Environment variables take precedence, and empty values also override these settings; the test notification uses the effective configuration.',
|
|
545
597
|
'setup.envOverrides': 'These environment variables are overriding configuration: {names}',
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const minutesOf = (value) => Number(value.slice(0, 2)) * 60 + Number(value.slice(3));
|
|
3
|
+
/** 按本机本地时间判断;start > end 表示跨午夜,start === end 已在配置解析时视为未启用。 */
|
|
4
|
+
export function quietNow(quietHours, now = new Date()) {
|
|
5
|
+
if (!quietHours)
|
|
6
|
+
return false;
|
|
7
|
+
const start = minutesOf(quietHours.start), end = minutesOf(quietHours.end);
|
|
8
|
+
if (start === end)
|
|
9
|
+
return false;
|
|
10
|
+
const minutes = now.getHours() * 60 + now.getMinutes();
|
|
11
|
+
return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
|
|
12
|
+
}
|
|
13
|
+
function normalize(value) {
|
|
14
|
+
const resolved = path.resolve(value);
|
|
15
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
16
|
+
}
|
|
17
|
+
function under(cwd, prefix) {
|
|
18
|
+
const base = normalize(cwd), target = normalize(prefix);
|
|
19
|
+
if (base === target)
|
|
20
|
+
return true;
|
|
21
|
+
return base.startsWith(target.endsWith(path.sep) ? target : target + path.sep);
|
|
22
|
+
}
|
|
23
|
+
/** deny 优先;allow 非空时只放行匹配的项目;缺少 cwd 时 allow 不匹配,deny 不匹配。 */
|
|
24
|
+
export function projectAllowed(projects, cwd) {
|
|
25
|
+
const value = typeof cwd === 'string' ? cwd.trim() : '';
|
|
26
|
+
if (!value)
|
|
27
|
+
return projects.allow.length === 0;
|
|
28
|
+
if (projects.deny.some(prefix => under(value, prefix)))
|
|
29
|
+
return false;
|
|
30
|
+
return projects.allow.length === 0 || projects.allow.some(prefix => under(value, prefix));
|
|
31
|
+
}
|
|
32
|
+
/** 只决定是否发送普通通知;回复桥接、答题和工具权限事件不受这里影响。 */
|
|
33
|
+
export function shouldNotify(config, event, cwd, now = new Date()) {
|
|
34
|
+
if (!config.notifications.events[event])
|
|
35
|
+
return false;
|
|
36
|
+
if (!projectAllowed(config.notifications.projects, cwd))
|
|
37
|
+
return false;
|
|
38
|
+
return !quietNow(config.notifications.quietHours, now);
|
|
39
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -5,8 +5,17 @@ import { ConfigError, decodeUtf8, example, parseConfig, ready } from './config.j
|
|
|
5
5
|
import { encodeBundle, nativeStore } from './credentials.js';
|
|
6
6
|
import { getLocale, joinList, t } from './i18n.js';
|
|
7
7
|
import { terminalPrompts } from './prompts.js';
|
|
8
|
+
import { bridgeStatus } from './reply-bridge.js';
|
|
9
|
+
import { discoverChats } from './telegram-discovery.js';
|
|
8
10
|
const yes = (text) => /^(y|yes)$/i.test(text.trim());
|
|
9
11
|
const defaultYes = (text) => !text.trim() || yes(text);
|
|
12
|
+
const KIND_KEYS = {
|
|
13
|
+
private: 'setup.discoveryKind.private',
|
|
14
|
+
group: 'setup.discoveryKind.group',
|
|
15
|
+
supergroup: 'setup.discoveryKind.supergroup',
|
|
16
|
+
channel: 'setup.discoveryKind.channel',
|
|
17
|
+
unknown: 'setup.discoveryKind.unknown',
|
|
18
|
+
};
|
|
10
19
|
export async function snapshot(file) {
|
|
11
20
|
try {
|
|
12
21
|
return await readFile(file);
|
|
@@ -131,6 +140,7 @@ export async function commitConfig(file, data, expected, store, values, options
|
|
|
131
140
|
}
|
|
132
141
|
export async function wizard(file, options = {}) {
|
|
133
142
|
const prompts = options.prompts ?? terminalPrompts();
|
|
143
|
+
const env = options.env ?? process.env;
|
|
134
144
|
const before = await snapshot(file), data = readRaw(before);
|
|
135
145
|
const { out } = prompts;
|
|
136
146
|
if (before === null)
|
|
@@ -193,10 +203,48 @@ export async function wizard(file, options = {}) {
|
|
|
193
203
|
};
|
|
194
204
|
if (data.telegram.enabled) {
|
|
195
205
|
values.telegram_token = await readValue('Telegram Bot Token', 'telegram_token', true);
|
|
196
|
-
|
|
206
|
+
// 与保存后的运行时一致:显式存在的 token 环境变量优先,空值同样覆盖。
|
|
207
|
+
const tokenEnv = typeof data.telegram.token_env === 'string' ? data.telegram.token_env : 'TELEGRAM_BOT_TOKEN';
|
|
208
|
+
const discoveryToken = Object.hasOwn(env, tokenEnv) ? (env[tokenEnv] ?? '').trim() : values.telegram_token;
|
|
209
|
+
let candidates = [];
|
|
210
|
+
let discoveryRan = false;
|
|
211
|
+
try {
|
|
212
|
+
if ((await bridgeStatus(file)).running)
|
|
213
|
+
out(t('setup.discoveryBridgeRunning'));
|
|
214
|
+
else {
|
|
215
|
+
candidates = await (options.discover ?? discoverChats)(discoveryToken);
|
|
216
|
+
discoveryRan = true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
out(t('setup.discoveryFailed', { reason: error instanceof ConfigError ? error.message : t('common.failure') }));
|
|
221
|
+
}
|
|
222
|
+
if (candidates.length) {
|
|
223
|
+
out(t('setup.discoveryFound'));
|
|
224
|
+
candidates.forEach((chat, index) => out(` #${index + 1} ${chat.id} ${chat.title} (${t(KIND_KEYS[chat.kind])})`));
|
|
225
|
+
out(t('setup.telegramChatInstructionDiscovered'));
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
if (discoveryRan)
|
|
229
|
+
out(t('setup.discoveryEmpty'));
|
|
230
|
+
out(t('setup.telegramChatInstruction'));
|
|
231
|
+
}
|
|
232
|
+
const chatPrompt = t(candidates.length ? 'setup.telegramChatPromptDiscovered' : 'setup.telegramChatPrompt');
|
|
197
233
|
while (true) {
|
|
198
|
-
const entered = await prompts.text(
|
|
199
|
-
|
|
234
|
+
const entered = await prompts.text(chatPrompt);
|
|
235
|
+
let ids;
|
|
236
|
+
if (!entered)
|
|
237
|
+
ids = data.telegram.chat_ids ?? [];
|
|
238
|
+
else if (candidates.length && /^#\d+(?:\s*,\s*#\d+)*$/.test(entered)) {
|
|
239
|
+
const indexes = entered.split(',').map(item => Number(item.trim().slice(1)));
|
|
240
|
+
if (indexes.some(value => !Number.isSafeInteger(value) || value < 1 || value > candidates.length)) {
|
|
241
|
+
out(t('setup.discoverySelectionInvalid'));
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
ids = [...new Set(indexes.map(value => candidates[value - 1].id))];
|
|
245
|
+
}
|
|
246
|
+
else
|
|
247
|
+
ids = entered.split(',').map(item => item.trim());
|
|
200
248
|
try {
|
|
201
249
|
if (!ids.length)
|
|
202
250
|
throw new Error();
|
|
@@ -205,7 +253,7 @@ export async function wizard(file, options = {}) {
|
|
|
205
253
|
break;
|
|
206
254
|
}
|
|
207
255
|
catch {
|
|
208
|
-
out(t('setup.telegramChatInvalid'));
|
|
256
|
+
out(t(candidates.length ? 'setup.telegramChatInvalidDiscovered' : 'setup.telegramChatInvalid'));
|
|
209
257
|
}
|
|
210
258
|
}
|
|
211
259
|
}
|
|
@@ -216,7 +264,6 @@ export async function wizard(file, options = {}) {
|
|
|
216
264
|
encodeBundle(values);
|
|
217
265
|
const preview = { ...data, credentials: { backend: 'keyring', id: '0'.repeat(32) } };
|
|
218
266
|
parseConfig(preview, {}, () => values);
|
|
219
|
-
const env = options.env ?? process.env;
|
|
220
267
|
const channels = [];
|
|
221
268
|
if (data.telegram.enabled)
|
|
222
269
|
channels.push(t('setup.channelTelegram', { count: data.telegram.chat_ids.length }));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { requestJson } from './channels.js';
|
|
2
|
+
import { ConfigError } from './config.js';
|
|
3
|
+
import { t } from './i18n.js';
|
|
4
|
+
const KINDS = new Set(['private', 'group', 'supergroup', 'channel']);
|
|
5
|
+
const MAX_CANDIDATES = 20;
|
|
6
|
+
function clean(value, limit) {
|
|
7
|
+
if (typeof value !== 'string')
|
|
8
|
+
return '';
|
|
9
|
+
return Array.from(value.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').trim()).slice(0, limit).join('');
|
|
10
|
+
}
|
|
11
|
+
function label(chat, kind) {
|
|
12
|
+
const name = kind === 'private'
|
|
13
|
+
? [clean(chat.first_name, 60), clean(chat.last_name, 60)].filter(Boolean).join(' ')
|
|
14
|
+
: clean(chat.title, 80);
|
|
15
|
+
const username = clean(chat.username, 60);
|
|
16
|
+
const value = [name, username ? '@' + username : ''].filter(Boolean).join(' ');
|
|
17
|
+
return value || t('discovery.unnamed');
|
|
18
|
+
}
|
|
19
|
+
/** 只从更新中提取 chat 元数据;消息正文、发件人等内容不会进入结果或日志。 */
|
|
20
|
+
export function chatsFromUpdates(updates) {
|
|
21
|
+
if (!Array.isArray(updates))
|
|
22
|
+
return [];
|
|
23
|
+
const seen = new Map();
|
|
24
|
+
for (const update of updates) {
|
|
25
|
+
if (!update || typeof update !== 'object')
|
|
26
|
+
continue;
|
|
27
|
+
const message = update.message ?? update.channel_post;
|
|
28
|
+
const chat = message?.chat;
|
|
29
|
+
if (!chat || !Number.isSafeInteger(chat.id) || chat.id === 0)
|
|
30
|
+
continue;
|
|
31
|
+
const kind = KINDS.has(chat.type) ? chat.type : 'unknown';
|
|
32
|
+
const id = String(chat.id);
|
|
33
|
+
// 同一会话只保留最后一次出现的位置,最近活跃的会话排在前面。
|
|
34
|
+
seen.delete(id);
|
|
35
|
+
seen.set(id, { id, title: label(chat, kind), kind });
|
|
36
|
+
}
|
|
37
|
+
return [...seen.values()].slice(-MAX_CANDIDATES).reverse();
|
|
38
|
+
}
|
|
39
|
+
async function call(token, method, payload, request) {
|
|
40
|
+
let response;
|
|
41
|
+
try {
|
|
42
|
+
response = await request(`https://api.telegram.org/bot${token}/${method}`, payload);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new ConfigError(t('discovery.network'));
|
|
46
|
+
}
|
|
47
|
+
if (response?.ok === true)
|
|
48
|
+
return response.result;
|
|
49
|
+
if (response?.error_code === 401)
|
|
50
|
+
throw new ConfigError(t('discovery.unauthorized'));
|
|
51
|
+
if (response?.error_code === 409)
|
|
52
|
+
throw new ConfigError(t('discovery.conflict'));
|
|
53
|
+
throw new ConfigError(t('discovery.failed'));
|
|
54
|
+
}
|
|
55
|
+
export async function discoverChats(token, request = requestJson) {
|
|
56
|
+
if (!token)
|
|
57
|
+
throw new ConfigError(t('discovery.noToken'));
|
|
58
|
+
// 不确认 offset,也不保存消息正文;只用于让用户从最近会话中选择接收人。
|
|
59
|
+
const updates = await call(token, 'getUpdates', { limit: 50, timeout: 0, allowed_updates: ['message'] }, request);
|
|
60
|
+
if (!Array.isArray(updates))
|
|
61
|
+
throw new ConfigError(t('discovery.failed'));
|
|
62
|
+
return chatsFromUpdates(updates);
|
|
63
|
+
}
|