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 +56 -102
- package/bin/lark-relay-collect.js +13 -60
- package/bin/lark-relay.js +54 -120
- package/lib/args.js +29 -77
- package/lib/collect.js +16 -62
- package/lib/filter.js +18 -19
- package/lib/help.js +46 -77
- package/lib/larkcli.js +2 -18
- package/lib/lock.js +5 -15
- package/lib/log.js +2 -27
- package/lib/render.js +4 -8
- package/lib/status.js +7 -23
- package/lib/store.js +5 -82
- package/lib/take.js +16 -29
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,130 +1,84 @@
|
|
|
1
1
|
# lark-relay
|
|
2
2
|
|
|
3
|
-
Lark(飞书)
|
|
3
|
+
Lark(飞书)消息中继:常驻采集落盘,按需取出一批
|
|
4
4
|
|
|
5
|
-
依赖 [
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
lark-relay
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
回收在本进程内每小时自查(store 删超期日期目录、游标删空闲超期的)-- 不另起 gc 单元/timer。
|
|
30
|
+
## 取批规则
|
|
54
31
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
41
|
+
退出码:0=有一批/帮助/信号退出,2=参数错,3=消费身份被占用,4=超时无消息
|
|
42
|
+
`--timeout 0` 立即扫描一次并返回,有消息仍会输出
|
|
60
43
|
|
|
61
|
-
|
|
44
|
+
## 断点与保留期
|
|
62
45
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
53
|
+
多消费者共享事件存储,各持独立断点
|
|
54
|
+
已有断点始终续接,不按空闲时长过期;首次取用由 `--since now|all` 决定起点,默认 `now`
|
|
55
|
+
需要从新的起点消费时使用新的 `--name`
|
|
75
56
|
|
|
76
|
-
|
|
77
|
-
|
|
57
|
+
采集端 `--retain` 指定事件保留天数(默认 3 个自然日,含今天),启动时及每小时回收超期目录
|
|
58
|
+
断点只定位仍在盘上的消息,无法恢复已被回收的事件
|
|
78
59
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
60
|
+
取批使用 at-most-once 交付:攒批期间只推进内存游标,交付前才提交磁盘断点
|
|
61
|
+
被过滤掉的消息在没有待交付批次时直接推进断点
|
|
62
|
+
消费者崩溃发生在提交之后时,业务可能漏处理;不提供处理确认或重试队列
|
|
82
63
|
|
|
83
|
-
|
|
84
|
-
起来后 stderr 先打一行「监听中」-- 没这行就是没起来,看 stderr 报错。
|
|
85
|
-
退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
|
|
86
|
-
参数拼错(如 `--chat-id`)会硬失败并给出正确写法,不静默吞掉。
|
|
64
|
+
## 存储取舍
|
|
87
65
|
|
|
88
|
-
|
|
66
|
+
- 一事件一文件,同目录写临时文件后 rename,消费者只扫描完整事件文件
|
|
67
|
+
- 按日期及数值文件名排序;stamp 在采集进程内严格递增
|
|
68
|
+
- 按 message_id 去重,启动时从当天文件恢复集合,跨天重置
|
|
69
|
+
- 每 500ms 扫描保留的全部日期,不引入额外扫描窗口
|
|
70
|
+
- 参数由 Node 内置解析器处理,不兼容已移除的命令或参数
|
|
89
71
|
|
|
90
|
-
##
|
|
72
|
+
## macOS 常驻部署
|
|
91
73
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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,
|
|
15
|
-
const
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
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
|
-
|
|
77
|
-
|
|
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,
|
|
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
|
|
13
|
+
lark-relay take ... 阻塞等一批 -> 输出 -> 退出
|
|
14
|
+
lark-relay status 采集状态 / 事件数量 / 消费断点
|
|
15
|
+
lark-relay-collect 常驻采集
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
用法详情 -> lark-relay take(空参就是完整指导)`
|
|
17
|
+
用法详情: lark-relay take --help`
|
|
23
18
|
|
|
24
19
|
async function main() {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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, {
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
76
|
-
process.stderr.write('需要 --app <name
|
|
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
|
|
80
|
-
if (!chats.length) {
|
|
81
|
-
process.stderr.write('需要 --chats oc_xxx
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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})
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const flags =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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,
|
|
37
|
+
module.exports = { parseArgs, numberArg }
|