lark-relay 0.6.0 → 0.6.2

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/README.md CHANGED
@@ -1,130 +1,84 @@
1
1
  # lark-relay
2
2
 
3
- Lark(飞书)事件中继站。两件事:**收下来** / **我来取**。
3
+ Lark(飞书)消息中继:常驻采集落盘,按需取出一批
4
4
 
5
- 依赖 [`lark-cli`](https://www.npmjs.com/package/@lark-base-open/lark-cli) 提供认证与
6
- 事件总线;本包只负责缓冲与批次,零 npm 依赖。
5
+ 依赖 Node.js >=22 和 [lark-cli](https://www.npmjs.com/package/@lark-base-open/lark-cli)
6
+ 使用 `--filter` 时还需要 jq,无 npm 运行依赖
7
7
 
8
8
  ```bash
9
- npm i -g lark-relay # 装出两个命令:lark-relay-collect(常驻)/ lark-relay(在场)
10
- lark-relay take # 空参输出完整的照做指导
9
+ npm i -g lark-relay
10
+ lark-relay take --help
11
+ lark-relay-collect --help
11
12
  ```
12
13
 
13
- ## 为什么需要它
14
+ ## 采集与消费
14
15
 
15
- Lark 事件是**流式**的 -- 进程不在的时刻,消息**永久丢失**,不是延迟送达
16
- (实测:消息发出 8 秒后才起 consumer,收到 0 条)。
17
-
18
- 所以要有一个常驻进程只管把事件收下来落盘,消费侧崩了、AI 跑了半小时、
19
- 会话关了几小时,都不丢消息。这就是 `lark-relay-collect`。
20
-
21
- ## 两段分离
22
-
23
- 采集与消费必须分离:`lark-relay-collect` 只管把事件收下来落盘,`lark-relay take`
24
- 在你需要时取走一批。消费侧崩了、AI 跑了半小时、会话关了几小时,采集都不能停 --
25
- 停摆的每一秒都在丢消息。
26
-
27
- 分离到**可执行文件**这一层,不是两个子命令:采集是常驻服务,服务管理器
28
- (launchd)展示的是可执行文件名,与 label 同名才认得出是谁。
29
-
30
- `take` 的形态:
31
-
32
- - **长命的是 AI 会话**(人设、上下文连续),**短命的是消息**(来一批处理一批)
33
- - **AI 等消息**,不是消息推给 AI —— 控制权在 AI 手里,自己决定何时再取
34
- - 任何 agent 都能干(Claude Code / Codex / 手敲),纯参数、无配置文件
35
-
36
- ## 命令
16
+ Lark 事件流不回放离线期间的消息,采集必须独立于消费会话持续运行
17
+ `lark-relay-collect` 通过 lark-cli 订阅 `im.message.receive_v1`,将消息原子写入磁盘
18
+ `lark-relay take` 从磁盘读取,攒出一批后输出并退出,调用方处理后再次取用
37
19
 
38
20
  ```bash
39
- lark-relay-collect # 底座:全部 profile 各起 consume -> 原子落盘(常驻服务)
40
-
41
- lark-relay take … # 阻塞等一批 -> 输出 -> 退出
42
- lark-relay status # 谁在跑 / 各 app 收了多少 / 游标在哪
21
+ lark-relay-collect
22
+ lark-relay take --app <app> --chats oc_xxx --render text
23
+ lark-relay status
43
24
  ```
44
25
 
45
- 就这两件事。踩坑史见 `docs/lessons.md`,代码里只留指针。
46
-
47
- ### lark-relay-collect
48
-
49
- 零参数、零配置。app 列表实时读 `lark-cli profile list`,新增 profile 自动纳入。
50
- 未授权或授权过期的 profile 自动跳过并 warn(永久失败,重试是死循环)。
26
+ 采集在启动时读取 `lark-cli profile list`,跳过排除项以及未授权/授权过期的 profile
27
+ 新增 profile 或重新授权后需重启采集,每个 app 的 consumer 独立退避重连
28
+ 同一 app 的事件总线只允许一个实例,本地互斥锁按 `LARK_RELAY_HOME` 隔离
51
29
 
52
- 每 app 一个子进程,某个挂了单独重启不影响其他。同一 message_id 只落一次。
53
- 回收在本进程内每小时自查(store 删超期日期目录、游标删空闲超期的)-- 不另起 gc 单元/timer。
30
+ ## 取批规则
54
31
 
55
- 日志直写 stderr,每行带 ISO 时间戳(launchd 场景重定向到
56
- `~/.lark-relay/logs/collect.err.log`)。不做轮转 —— 实测约 10MB/年,
57
- 嫌大就 `> ~/.lark-relay/logs/collect.err.log` 截断(launchd 持有 fd,别 rm)。
32
+ - `--app` 是 lark-cli profile 名,`--chats` 是逗号分隔的 chat_id 或 `@文件`(首列)
33
+ - 监听要求 bot 已在群内,查群与验成员的完整命令见 `take --help`
34
+ - `--name` 标识消费断点,默认按 app+chats 派生,并行消费使用不同名字
35
+ - `--filter` 是唯一业务过滤入口,使用 jq 表达式;语法错误在启动前拒绝
36
+ - 消息数据导致 jq 求值失败时,该次扫描到的消息告警并放行,避免阻塞断点
37
+ - 默认防抖 5 秒,从第一条消息起最多攒 25 秒,本次取用总超时 12 小时
38
+ - `--render` 支持 `ndjson`(默认)和按群/话题分组的 `text`
39
+ - 参数校验完成后 stderr 输出「监听中」;未知参数、位置参数、缺值和空值均报错
58
40
 
59
- ### 常驻部署(macOS / launchd)
41
+ 退出码:0=有一批/帮助/信号退出,2=参数错,3=消费身份被占用,4=超时无消息
42
+ `--timeout 0` 立即扫描一次并返回,有消息仍会输出
60
43
 
61
- 要点三条,plist 自己写十来行就够:
44
+ ## 断点与保留期
62
45
 
63
- - 用 **LaunchDaemon**(装 `/Library/LaunchDaemons/`)+ `UserName` 降权到你自己。
64
- 不用 LaunchAgent —— 它要 GUI 登录才加载、登出即停,而事件零回放,停摆=丢消息
65
- - `ProgramArguments` 写绝对路径(`which lark-relay-collect`),并显式设
66
- `EnvironmentVariables` 里的 `HOME` 与 `PATH` —— launchd 不展开 `~`/`$VAR`,
67
- 也不继承 shell 环境
68
- - `KeepAlive` 用 `SuccessfulExit=false`;`StandardErrorPath` 指到一个**父目录已存在**
69
- 的路径,launchd 不建目录
70
-
71
- 装:`sudo launchctl bootstrap system /Library/LaunchDaemons/<label>.plist`。
72
- 改了 plist 必须 bootout + bootstrap —— `kickstart -k` 只重启进程、不重读 plist。
46
+ ```
47
+ ~/.lark-relay/
48
+ store/<app>/<YYYY-MM-DD>/<stamp>_<pid>_<seq>.json
49
+ cursors/<name>/<app>
50
+ run/<key>.lock/
51
+ ```
73
52
 
74
- ### lark-relay take
53
+ 多消费者共享事件存储,各持独立断点
54
+ 已有断点始终续接,不按空闲时长过期;首次取用由 `--since now|all` 决定起点,默认 `now`
55
+ 需要从新的起点消费时使用新的 `--name`
75
56
 
76
- 只有一种形态:**阻塞等 -> 有一批就输出 -> 退出**。循环交给 harness --
77
- Claude Code 用 `run_in_background` 起,进程退出会主动通知 AI,这才是事件驱动。
57
+ 采集端 `--retain` 指定事件保留天数(默认 3 个自然日,含今天),启动时及每小时回收超期目录
58
+ 断点只定位仍在盘上的消息,无法恢复已被回收的事件
78
59
 
79
- ```bash
80
- lark-relay take --app <app> --chats oc_xxx --render text
81
- ```
60
+ 取批使用 at-most-once 交付:攒批期间只推进内存游标,交付前才提交磁盘断点
61
+ 被过滤掉的消息在没有待交付批次时直接推进断点
62
+ 消费者崩溃发生在提交之后时,业务可能漏处理;不提供处理确认或重试队列
82
63
 
83
- 空参数跑一下会输出完整的照做指导(查群 ID -> 验 bot 在群 -> 监听三步),exit 0。
84
- 起来后 stderr 先打一行「监听中」-- 没这行就是没起来,看 stderr 报错。
85
- 退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
86
- 参数拼错(如 `--chat-id`)会硬失败并给出正确写法,不静默吞掉。
64
+ ## 存储取舍
87
65
 
88
- 业务过滤只有 `--filter`(jq 表达式)一个口子,不为每个业务加参数。
66
+ - 一事件一文件,同目录写临时文件后 rename,消费者只扫描完整事件文件
67
+ - 按日期及数值文件名排序;stamp 在采集进程内严格递增
68
+ - 按 message_id 去重,启动时从当天文件恢复集合,跨天重置
69
+ - 每 500ms 扫描保留的全部日期,不引入额外扫描窗口
70
+ - 参数由 Node 内置解析器处理,不兼容已移除的命令或参数
89
71
 
90
- ## 存储布局
72
+ ## macOS 常驻部署
91
73
 
92
- ```
93
- ~/.lark-relay/
94
- store/<app>/<YYYY-MM-DD>/<纳秒>_<pid>_<seq>.json 原始事件(可随时删,丢了自愈)
95
- cursors/<name>/<app> 各消费者游标
96
- ```
74
+ - 使用 LaunchDaemon + UserName,开机即起,不依赖 GUI 登录
75
+ - ProgramArguments 写可执行文件的绝对路径,显式设置 HOME/PATH/UTF-8 locale
76
+ - KeepAlive 使用 SuccessfulExit=false;日志路径父目录须预先创建
77
+ - 代码升级后用 `kickstart -k`,修改 plist 后用 `bootout`、等 label 释放、再 `bootstrap`
97
78
 
98
- 判据:能随时删掉重建的才放这里 -- 上面两样都能。
99
-
100
- 多消费者**共享 store、各持游标**,不做「处理完即删」-- 谁都不能替别人删。
101
- 盯同一 app 请用不同 `--name` 隔离游标。
102
-
103
- **游标会自动回收**:空闲超过 2 天的由 collect 删掉(每小时自查,与 store 同一个 gc)。
104
- 判据是「多久没人用过它」(mtime)—— 不需要你记得收尾,也不需要判断该不该删。
105
- 看状态 `lark-relay status`;等不及就 `rm -rf ~/.lark-relay/cursors`。
106
- 改期限:`LARK_RELAY_CURSOR_TTL_DAYS=<天>`。
107
-
108
- 为什么不是「落后多少条」:那个方向是反的,这条判据错过两次 --
109
- 见 `docs/lessons.md#游标判据`。
110
-
111
- ## 设计取舍
112
-
113
- - **原子落盘**:collect 读 stdout NDJSON 后自己 `写 .tmp -> rename`(同目录原子)。
114
- 不用 `--output-dir` -- 它先建 0 字节再填充,消费侧游标可能跨过半成品导致事件永久丢失
115
- - **一事件一文件 + 按天分目录**:不用追加式 NDJSON。因为多消费者共享 store 时
116
- 两种方案都不能「处理完即删」,NDJSON 最大的优势(删除简单)失效,而它的 gc
117
- 要按大小滚动 + 保留 N 个文件,反而比整目录删复杂
118
- - **去重做在落盘侧**:collect 持 `Set<message_id>`,同一条消息在盘上只出现一次。
119
- collect 重启或 bus 重投时它是**新文件**,游标拦不住 —— 所以不能只靠游标。
120
- 做在落盘侧而非每个消费者各存一份,游标目录才能只放游标
121
- - **`--chats` 只接 `oc_` 开头的 chat_id,不接群名**:群名可能匹配多个或匹配错群,
122
- 而监听错群是**静默失败**(一直等一个永不来的消息)
123
- - **`--since` 默认 `now`**:不重放历史,消灭「预热游标」这一步
124
- - **不设扫描窗口**:消费侧扫盘上还在的全部日期。曾有个 3 天窗口,省 4ms,
125
- 换来「窗口必须正好等于 retain」的跨进程不变量
126
- - **体积预算**:`lib`+`bin` 超 1900 行 `npm test` 就红(`test/budget.test.js`)。
127
- 这个包只做两件事,防的是治理层长回来
79
+ 日志直写 stderr,每行带 ISO 时间戳,不做轮转
80
+ 需要缩减日志时截断文件,不要删除或替换 launchd 持有的文件
81
+ `status` 中 collect running 只说明主进程在跑,各 app 是否连接成功需查看采集日志
128
82
 
129
83
  ## 许可
130
84
 
@@ -1,81 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
- // lark-relay-collect —— 事件采集底座,常驻服务专用入口。
5
- // 独立成可执行文件而非子命令:launchd 展示的是 ProgramArguments[0],
6
- // 与 label(com.adaex.lark-relay-collect)同名才认得出。
7
- // 采集逻辑全在 lib/collect.js,本文件只做参数解析与进程身份。
8
-
9
4
  require('../lib/utf8').ensureUtf8()
10
-
11
- // ps/top 里显示成自己的名字,不再是裸 node -- 排障第一步常是 `ps | grep`
12
5
  process.title = 'lark-relay-collect'
13
6
 
14
- const { parseArgs, numStrict, list, rejectUnknown } = require('../lib/args')
15
- const help = require('../lib/help')
16
-
7
+ const { parseArgs, numberArg } = require('../lib/args')
8
+ const { COLLECT_HELP } = require('../lib/help')
17
9
  const VERSION = require('../package.json').version
18
- const PROG = 'lark-relay-collect'
19
- const KEYS = ['exclude', 'retain']
20
10
 
21
11
  async function main() {
22
- const argv = process.argv.slice(2)
23
-
24
- // --version 要有:deploy-launchd.sh 用它打印「全局命令版本」作部署前后对照
25
- if (argv[0] === '--version' || argv[0] === '-v' || argv[0] === 'version') {
26
- process.stdout.write(`${VERSION}\n`)
27
- return 0
28
- }
29
-
30
- // ⚠️ 短横线别名与 help 必须在 parseArgs **之前**拦:parseArgs 只认 `--xxx`,
31
- // `-h` 会一路穿到 runCollect() 起一个 daemon 去抢线上的 bus。
32
- // **帮助命令导致丢消息** -- 见 docs/lessons.md#静默失败
33
- if (argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
34
- process.stdout.write(`${help.COLLECT_HELP}\n`)
35
- return 0
36
- }
37
-
38
- const a = parseArgs(argv, { flags: ['help'], keys: KEYS })
39
- if (a._unknown.length) return rejectUnknown(PROG, a._unknown, KEYS)
40
- if (a.help) {
41
- process.stdout.write(`${help.COLLECT_HELP}\n`)
12
+ const a = parseArgs(process.argv.slice(2), { keys: ['exclude', 'retain'], flags: ['version'] })
13
+ if (a.help || a.version) {
14
+ process.stdout.write(`${a.version ? VERSION : COLLECT_HELP}\n`)
42
15
  return 0
43
16
  }
44
-
45
- // 本命令没有子命令,位置参数一律是敲错了。最可能被敲的正是
46
- // `lark-relay-collect collect`,它若静默起 daemon 又是一次「求助反而丢消息」
47
- if (a._.length) {
48
- const extra = a._.join(' ')
49
- const hint = /^(collect|take|status)$/.test(a._[0])
50
- ? `\n采集就是本命令自己,不带子命令;take/status 在 \`lark-relay\` 那边\n`
51
- : '\n'
52
- process.stderr.write(`${PROG} 不接位置参数,多了:${extra}${hint}跑 \`${PROG} --help\` 看用法\n`)
53
- return 2
54
- }
55
-
56
- // store 保留天数。消费侧扫盘上还在的全部日期,所以这里改多少都自洽,
57
- // 不再有「窗口必须正好等于 retain」那条跨进程不变量(见 docs/lessons.md#扫描窗口)
58
- const retain = numStrict(a.retain, 3)
59
- if (retain === null || !(retain >= 1)) {
60
- process.stderr.write(`--retain 需要 >= 1 的天数,当前:${a.retain === true ? '(没给值)' : a.retain}\n`)
17
+ const retain = numberArg(a.retain, 3, 'retain')
18
+ if (!Number.isSafeInteger(retain) || retain < 1) {
19
+ process.stderr.write('--retain 需要 >= 1 的整数天数\n')
61
20
  return 2
62
21
  }
63
-
64
22
  const { runCollect } = require('../lib/collect')
65
- return await runCollect({
66
- exclude: list(a.exclude),
23
+ return runCollect({
24
+ exclude: a.exclude ? a.exclude.split(',').map((s) => s.trim()).filter(Boolean) : [],
67
25
  retain,
68
26
  })
69
27
  }
70
28
 
71
29
  main()
72
- .then((code) => {
73
- process.exitCode = code || 0
74
- })
30
+ .then((code) => { process.exitCode = code })
75
31
  .catch((err) => {
76
- // 注:runCollect 内部的错误走 lib/log.js 的 logLine(带时间戳,常驻服务要的)。
77
- // 这里只兜住它之前/之外的意外,故是裸 stderr
78
- if (err && err.userFacing) process.stderr.write(`${err.message}\n`)
79
- else process.stderr.write(`${PROG}: ${(err && err.stack) || err}\n`)
80
- process.exitCode = 1
32
+ process.stderr.write(`${err.userFacing ? err.message : err.stack}\n`)
33
+ process.exitCode = err.exitCode || 1
81
34
  })
package/bin/lark-relay.js CHANGED
@@ -3,195 +3,129 @@
3
3
 
4
4
  require('../lib/utf8').ensureUtf8()
5
5
 
6
- const { parseArgs, numStrict, rejectUnknown } = require('../lib/args')
6
+ const { parseArgs, numberArg } = require('../lib/args')
7
7
  const help = require('../lib/help')
8
-
9
8
  const VERSION = require('../package.json').version
10
-
11
- // 带值参数白名单(布尔开关在 parseArgs 的 flags 里)。
12
- // 不在白名单的 --xxx 一律硬报错 —— 拼错参数静默吞掉是实测事故,见 docs/lessons.md#静默失败
13
9
  const TAKE_KEYS = ['app', 'chats', 'name', 'filter', 'debounce', 'max-wait', 'timeout', 'render', 'since']
14
10
 
15
- const USAGE = `lark-relay ${VERSION} -- Lark 事件中继站(在场取用)
11
+ const USAGE = `lark-relay ${VERSION} -- Lark 事件中继站
16
12
 
17
- lark-relay take ... 在场取用:阻塞等一批 -> 输出 -> 退出
18
- lark-relay status 谁在跑 / 各 app 收了多少 / 游标在哪
13
+ lark-relay take ... 阻塞等一批 -> 输出 -> 退出
14
+ lark-relay status 采集状态 / 事件数量 / 消费断点
15
+ lark-relay-collect 常驻采集
19
16
 
20
- 采集底座是独立的常驻命令:lark-relay-collect
21
-
22
- 用法详情 -> lark-relay take(空参就是完整指导)`
17
+ 用法详情: lark-relay take --help`
23
18
 
24
19
  async function main() {
25
- const argv = process.argv.slice(2)
26
- const cmd = argv[0]
27
- const rest = argv.slice(1)
28
-
29
- if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
30
- process.stdout.write(`${USAGE}\n`)
31
- return 0
32
- }
33
- if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
34
- process.stdout.write(`${VERSION}\n`)
35
- return 0
36
- }
37
-
38
- switch (cmd) {
39
- case 'take':
40
- return await cmdTake(rest)
41
- case 'status':
42
- return await cmdStatus(rest)
43
- // 能力是**搬家不是删除**,提示必须给出新名字,否则会被当成功能没了
44
- case 'collect':
45
- process.stderr.write('采集是独立命令(同一个包就带):lark-relay-collect --help\n')
46
- return 2
47
- default:
48
- process.stderr.write(`未知命令:${cmd}\n\n${USAGE}\n`)
49
- return 2
20
+ const [cmd, ...rest] = process.argv.slice(2)
21
+ if (cmd === 'take') return cmdTake(rest)
22
+ if (cmd === 'status') return cmdStatus(rest)
23
+ if (cmd && !cmd.startsWith('-')) {
24
+ process.stderr.write(`未知命令:${cmd}\n${USAGE}\n`)
25
+ return 2
50
26
  }
27
+ const a = parseArgs(process.argv.slice(2), { flags: ['version'] })
28
+ process.stdout.write(`${a.version ? VERSION : USAGE}\n`)
29
+ return 0
51
30
  }
52
31
 
53
32
  async function cmdTake(argv) {
54
- const a = parseArgs(argv, { flags: ['help'], keys: TAKE_KEYS })
55
- if (a._unknown.length) return rejectUnknown('lark-relay take', a._unknown, TAKE_KEYS)
56
- const larkcli = require('../lib/larkcli')
57
-
58
- // 空参数输出完整照做指导,而非报错 -- AI 的真实行为是「先空参数跑一下看看」。
59
- // 但带了参数又缺 app/chats 的走下面短错误 -- 打印完整用法和 --help 长得一样,
60
- // 会被当成「正常退出但没消息」
33
+ const a = parseArgs(argv, { keys: TAKE_KEYS })
61
34
  if (a.help || argv.length === 0) {
62
- let apps = []
63
- try {
64
- apps = (await larkcli.listProfiles()).filter((p) => p.usable).map((p) => p.name)
65
- } catch {}
66
- process.stdout.write(`${help.takeHelp(apps)}\n`)
67
- return 0 // 空参 = 求教,和 --help 一样是成功路径
35
+ process.stdout.write(`${help.TAKE_HELP}\n`)
36
+ return 0
68
37
  }
69
38
 
70
39
  const store = require('../lib/store')
71
40
  const { takeBatch, deriveName, parseChats } = require('../lib/take')
41
+ const { validateFilter } = require('../lib/filter')
72
42
  const { render } = require('../lib/render')
73
43
  const { acquireExclusive } = require('../lib/lock')
74
44
 
75
- if (!a.app || a.app === true) {
76
- process.stderr.write('需要 --app <name>(= lark-cli profile 名)。完整步骤跑 `lark-relay take`\n')
45
+ if (!a.app) {
46
+ process.stderr.write('需要 --app <name>,跑 `lark-relay take --help` 看用法\n')
77
47
  return 2
78
48
  }
79
- const chats = parseChats(a.chats === true ? '' : a.chats)
80
- if (!chats.length) {
81
- process.stderr.write('需要 --chats oc_xxx。跑 `lark-relay take` 看完整用法\n')
49
+ const chats = parseChats(a.chats)
50
+ if (!chats.length || chats.some((c) => !c.startsWith('oc_'))) {
51
+ process.stderr.write('需要 --chats oc_xxx,多个 chat_id 用逗号分隔\n')
82
52
  return 2
83
53
  }
84
- const bad = chats.filter((c) => !c.startsWith('oc_'))
85
- if (bad.length) {
86
- process.stderr.write(
87
- `--chats 只接受 oc_ 开头的 chat_id,这些不是:${bad.join(' ')}\n` +
88
- `群名会匹配错群,而监听错群是静默失败(你会一直等一个永不来的消息)。\n` +
89
- `查 ID:lark-cli --profile ${a.app} im +chat-search --as user --query "<群名>"\n`,
90
- )
54
+ const name = a.name ?? deriveName(a.app, chats)
55
+ const since = a.since ?? 'now'
56
+ const mode = a.render ?? 'ndjson'
57
+ if (!['now', 'all'].includes(since)) {
58
+ process.stderr.write(`--since 只能是 now 或 all,当前:${since}\n`)
91
59
  return 2
92
60
  }
61
+ if (!['text', 'ndjson'].includes(mode)) {
62
+ process.stderr.write(`--render 只能是 text 或 ndjson,当前:${mode}\n`)
63
+ return 2
64
+ }
65
+ const duration = (key, fallback) => numberArg(a[key], fallback, key) * 1000
66
+ const debounceMs = duration('debounce', 5)
67
+ const maxWaitMs = duration('max-wait', 25)
68
+ const timeoutMs = duration('timeout', 12 * 3600)
69
+ if (![debounceMs, maxWaitMs, timeoutMs].every(Number.isFinite)) {
70
+ process.stderr.write('时长超出可用范围\n')
71
+ return 2
72
+ }
73
+ validateFilter(a.filter)
93
74
 
94
- const name = a.name && a.name !== true ? a.name : deriveName(a.app, chats)
95
-
96
- // 同一 --name 不该有两个 take 在跑:两个进程互相推游标会撕批次
75
+ // 参数全部有效后才持锁或创建断点
97
76
  const lock = acquireExclusive(`take-${name}`)
98
77
  if (!lock.ok) {
99
- process.stderr.write(
100
- `已有一个 take 在用游标身份 "${name}"(pid ${lock.holder})。\n` +
101
- `要并行盯同一 app 请用不同 --name,否则两个进程会互相推游标撕批次。\n`,
102
- )
78
+ process.stderr.write(`游标身份 "${name}" 被占用(pid ${lock.holder}),并行消费请换 --name\n`)
103
79
  return 3
104
80
  }
105
-
106
- // --since 只有 now|all 两个取值,必须白名单校验:曾经是「跟 'now' 比一次,
107
- // 其余全当 all」,于是 `--since Now` 静默变成全量重放
108
- const since = a.since === true ? 'now' : a.since || 'now'
109
- if (since !== 'now' && since !== 'all') {
110
- process.stderr.write(`--since 只能是 now 或 all,当前:${since}\n`)
111
- return 2
112
- }
113
- // 没有游标就按 --since now 起头。已有的一律原样保留 ——
114
- // 别「自愈」到最新,那会静默丢掉全部积压(见 docs/lessons.md#游标判据)
115
- if (since === 'now') {
116
- if (store.readCursor(name, a.app) === null) store.seedCursorNow(name, a.app)
81
+ if (since === 'now' && store.readCursor(name, a.app) === null) {
82
+ store.seedCursorNow(name, a.app)
117
83
  }
118
84
 
119
- // 标记「有人来取过」:gc 判据是 mtime,而 take 空手退出时不写游标。
120
- // 不 touch 会把「盯着一个安静的群」误判成遗弃
121
- store.touchCursor(name, a.app)
122
-
123
- // 时长参数校验:负数/非数字过去会静默退回默认值或原样生效
124
- const dur = (val, def, label) => {
125
- const n = numStrict(val, def)
126
- if (n === null || !(n >= 0)) {
127
- const e = new Error(`--${label} 需要 >= 0 的秒数,当前:${val === true ? '(没给值)' : val}`)
128
- e.userFacing = true
129
- e.exitCode = 2
130
- throw e
131
- }
132
- return n * 1000
133
- }
134
- const debounceMs = dur(a.debounce, 5, 'debounce')
135
- const maxWaitMs = dur(a['max-wait'], 25, 'max-wait')
136
- const timeoutMs = dur(a.timeout, 12 * 3600, 'timeout')
137
-
138
- // 起来先打一行「监听中」-- 没这行就是没起来。
139
- // 实测:进程静默退出(参数错)被当成「在监听」,漏看 bot 第一条 Working
140
85
  const fmtDur = (ms) => (ms >= 3600000 ? `${ms / 3600000}h` : `${ms / 1000}s`)
141
86
  process.stderr.write(
142
87
  `监听中:app=${a.app} 群=${chats.length} 个(${chats.join(',')}) 游标=${name} ` +
143
88
  `防抖=${debounceMs / 1000}s 最长等=${fmtDur(timeoutMs)}\n`,
144
89
  )
145
-
146
90
  const ac = new AbortController()
147
91
  const onSig = () => ac.abort()
148
92
  process.on('SIGTERM', onSig)
149
93
  process.on('SIGINT', onSig)
150
-
151
94
  const res = await takeBatch({
152
95
  app: a.app,
153
96
  chats,
154
97
  name,
155
- filter: a.filter === true ? null : a.filter,
98
+ filter: a.filter,
156
99
  debounceMs,
157
100
  maxWaitMs,
158
101
  timeoutMs,
159
102
  signal: ac.signal,
160
103
  })
161
-
162
104
  if (!res.events.length) {
163
- process.stderr.write(`没等到消息(${res.reason})。游标身份 ${name},正常退出,再起一个继续\n`)
105
+ process.stderr.write(`没等到消息(${res.reason}),再起一个继续\n`)
164
106
  return res.reason === 'timeout' ? 4 : 0
165
107
  }
166
-
167
- const mode = a.render === true ? 'text' : a.render
168
108
  process.stdout.write(`${render(res.events, mode)}\n`)
169
109
  process.stderr.write(`${res.events.length} 条,游标身份 ${name}\n`)
170
110
  return 0
171
111
  }
172
112
 
173
113
  async function cmdStatus(argv) {
174
- const a = parseArgs(argv, { flags: ['json', 'help'], keys: [] })
175
- if (a._unknown.length) return rejectUnknown('lark-relay status', a._unknown, ['json'])
114
+ const a = parseArgs(argv, { flags: ['json'] })
176
115
  if (a.help) {
177
116
  process.stdout.write(`${help.STATUS_HELP}\n`)
178
117
  return 0
179
118
  }
180
119
  const { buildStatus, formatStatus } = require('../lib/status')
181
120
  const s = await buildStatus()
182
- if (a.json) process.stdout.write(`${JSON.stringify(s, null, 2)}\n`)
183
- else process.stdout.write(`${formatStatus(s)}\n`)
121
+ process.stdout.write(`${a.json ? JSON.stringify(s, null, 2) : formatStatus(s)}\n`)
184
122
  return 0
185
123
  }
186
124
 
125
+ // 自然排空 stdout,避免大批次经管道输出时被截断
187
126
  main()
188
- .then((code) => {
189
- // ⚠️ 不能用 process.exit():stdout 是管道时它会丢掉未 flush 的缓冲
190
- // (实测 676KB 只收到 65536 且切在 JSON 中途)。见 docs/lessons.md#管道截断
191
- process.exitCode = code || 0
192
- })
127
+ .then((code) => { process.exitCode = code })
193
128
  .catch((err) => {
194
- if (err && err.userFacing) process.stderr.write(`${err.message}\n`)
195
- else process.stderr.write(`lark-relay: ${(err && err.stack) || err}\n`)
196
- process.exitCode = (err && err.exitCode) || 1
129
+ process.stderr.write(`${err.userFacing ? err.message : err.stack}\n`)
130
+ process.exitCode = err.exitCode || 1
197
131
  })
package/lib/args.js CHANGED
@@ -1,85 +1,37 @@
1
1
  'use strict'
2
2
 
3
- // 零依赖 argv 解析。只支持 --key value / --key=value / --flag。
4
- // spec.flags:布尔开关;spec.keys:带值选项。不在两者里的 --xxx 进 out._unknown,
5
- // 由调用侧硬报错 —— 拼错参数必须当场失败,不能静默吞掉
6
- // (实测:--chat-id 拼错 -> 打印用法退出,看着像「正常退出但没消息」,漏看 bot 第一条 Working)
7
- function parseArgs(argv, spec = {}) {
8
- const flags = new Set(spec.flags || [])
9
- const keys = new Set(spec.keys || [])
10
- const known = (k) => flags.has(k) || keys.has(k)
11
- const out = { _: [], _unknown: [] }
12
- for (let i = 0; i < argv.length; i++) {
13
- const a = argv[i]
14
- if (a === '--') {
15
- out._.push(...argv.slice(i + 1))
16
- break
17
- }
18
- if (!a.startsWith('--')) {
19
- out._.push(a)
20
- continue
21
- }
22
- const eq = a.indexOf('=')
23
- if (eq !== -1) {
24
- const k = a.slice(2, eq)
25
- if (known(k)) out[k] = a.slice(eq + 1)
26
- else out._unknown.push(k)
27
- continue
28
- }
29
- const key = a.slice(2)
30
- if (!known(key)) {
31
- out._unknown.push(key)
32
- // 吞掉它的值,避免下一个非 -- 参数错位成位置参数
33
- const next = argv[i + 1]
34
- if (next !== undefined && !next.startsWith('--')) i++
35
- continue
36
- }
37
- if (flags.has(key)) {
38
- out[key] = true
39
- continue
40
- }
41
- const next = argv[i + 1]
42
- if (next === undefined || next.startsWith('--')) {
43
- out[key] = true // 缺值,由调用侧校验
44
- } else {
45
- out[key] = next
46
- i++
3
+ const { parseArgs: parse } = require('node:util')
4
+
5
+ function parseArgs(args, { keys = [], flags = [] } = {}) {
6
+ const options = { help: { type: 'boolean', short: 'h' } }
7
+ for (const key of keys) options[key] = { type: 'string' }
8
+ for (const flag of flags) options[flag] = { type: 'boolean' }
9
+ if (options.version) options.version.short = 'v'
10
+ try {
11
+ const { values } = parse({ args, options, strict: true, allowPositionals: false })
12
+ for (const key of keys) {
13
+ if (values[key] !== undefined && !values[key].trim()) {
14
+ throw new Error(`--${key} 不能为空`)
15
+ }
47
16
  }
17
+ return values
18
+ } catch (err) {
19
+ err.message += `\n可用参数:${Object.keys(options).map((k) => `--${k}`).join(' ')}`
20
+ err.userFacing = true
21
+ err.exitCode = 2
22
+ throw err
48
23
  }
49
- return out
50
24
  }
51
25
 
52
- // 数值参数:省略 -> 默认值;给了但不是有效数字 -> null,由调用方硬失败。
53
- // ⚠️ 不要退化成「非法值悄悄用默认值」—— 那样 `--timeout abc` 就成了
54
- // 「跑起来但行为不对」,与本仓「拼错当场失败」的铁律相悖(实测它会静默变成 12 小时)
55
- function numStrict(v, dflt) {
56
- if (v === undefined) return dflt
57
- if (v === true) return null // `--timeout` 后面没跟值
58
- const n = Number(v)
59
- return Number.isFinite(n) ? n : null
60
- }
61
-
62
- function list(v) {
63
- if (!v || v === true) return []
64
- return String(v)
65
- .split(',')
66
- .map((s) => s.trim())
67
- .filter(Boolean)
68
- }
69
-
70
- // 未知参数 -> stderr 短错误 + 合法参数清单,exit 2。
71
- // 不打印完整用法 -- 那看着像 --help 成功,会被当成「正常退出但没消息」。
72
- //
73
- // 直接列出全部合法参数,不做编辑距离猜测:参数总共 9 个,列出来比猜一个更有用
74
- // (曾有 50 行 levenshtein + suggest 干这件事)。
75
- //
76
- // ⚠️ `prog` 必须由调用方传入:两个 bin 共用这段,而提示里那行是要照抄执行的 --
77
- // 打出一个不存在的命令会把人引向死路
78
- function rejectUnknown(prog, unknown, allowed, out = process.stderr) {
79
- const shown = [...new Set(unknown)].map((k) => `--${k}`).join(', ')
80
- const list = allowed.map((k) => `--${k}`).join(' ')
81
- out.write(`未知参数:${shown}\n可用参数:${list}\n跑 \`${prog}\` 看完整用法\n`)
82
- return 2
26
+ function numberArg(value, fallback, label) {
27
+ const n = value === undefined ? fallback : Number(value)
28
+ if (!Number.isFinite(n) || n < 0) {
29
+ throw Object.assign(new Error(`--${label} 需要有限的非负数,当前:${value}`), {
30
+ userFacing: true,
31
+ exitCode: 2,
32
+ })
33
+ }
34
+ return n
83
35
  }
84
36
 
85
- module.exports = { parseArgs, numStrict, list, rejectUnknown }
37
+ module.exports = { parseArgs, numberArg }