lark-relay 0.5.1 → 0.6.1
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 +38 -23
- package/bin/lark-relay-collect.js +15 -34
- package/bin/lark-relay.js +26 -147
- package/lib/args.js +10 -58
- package/lib/collect.js +49 -52
- package/lib/filter.js +3 -7
- package/lib/help.js +18 -120
- package/lib/lock.js +27 -63
- package/lib/log.js +18 -143
- package/lib/paths.js +3 -6
- package/lib/render.js +4 -13
- package/lib/status.js +92 -89
- package/lib/store.js +110 -56
- package/lib/take.js +3 -14
- package/package.json +1 -1
- package/lib/cursors.js +0 -277
- package/lib/dedup.js +0 -69
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Lark(飞书)事件中继站。两件事:**收下来** / **我来取**。
|
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
9
|
npm i -g lark-relay # 装出两个命令:lark-relay-collect(常驻)/ lark-relay(在场)
|
|
10
|
-
lark-relay
|
|
10
|
+
lark-relay take # 空参输出完整的照做指导
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## 为什么需要它
|
|
@@ -39,17 +39,36 @@ Lark 事件是**流式**的 -- 进程不在的时刻,消息**永久丢失**,不
|
|
|
39
39
|
lark-relay-collect # 底座:全部 profile 各起 consume -> 原子落盘(常驻服务)
|
|
40
40
|
|
|
41
41
|
lark-relay take … # 阻塞等一批 -> 输出 -> 退出
|
|
42
|
-
lark-relay status # 谁在跑 / 各 app
|
|
43
|
-
lark-relay guide # 一页用法
|
|
42
|
+
lark-relay status # 谁在跑 / 各 app 收了多少 / 游标在哪
|
|
44
43
|
```
|
|
45
44
|
|
|
45
|
+
就这两件事。踩坑史见 `docs/lessons.md`,代码里只留指针。
|
|
46
|
+
|
|
46
47
|
### lark-relay-collect
|
|
47
48
|
|
|
48
49
|
零参数、零配置。app 列表实时读 `lark-cli profile list`,新增 profile 自动纳入。
|
|
49
50
|
未授权或授权过期的 profile 自动跳过并 warn(永久失败,重试是死循环)。
|
|
50
51
|
|
|
51
|
-
每 app
|
|
52
|
-
|
|
52
|
+
每 app 一个子进程,某个挂了单独重启不影响其他。同一 message_id 只落一次。
|
|
53
|
+
回收在本进程内每小时自查(store 删超期日期目录、游标删空闲超期的)-- 不另起 gc 单元/timer。
|
|
54
|
+
|
|
55
|
+
日志直写 stderr,每行带 ISO 时间戳(launchd 场景重定向到
|
|
56
|
+
`~/.lark-relay/logs/collect.err.log`)。不做轮转 —— 实测约 10MB/年,
|
|
57
|
+
嫌大就 `> ~/.lark-relay/logs/collect.err.log` 截断(launchd 持有 fd,别 rm)。
|
|
58
|
+
### 常驻部署(macOS / launchd)
|
|
59
|
+
|
|
60
|
+
要点三条,plist 自己写十来行就够:
|
|
61
|
+
|
|
62
|
+
- 用 **LaunchDaemon**(装 `/Library/LaunchDaemons/`)+ `UserName` 降权到你自己。
|
|
63
|
+
不用 LaunchAgent —— 它要 GUI 登录才加载、登出即停,而事件零回放,停摆=丢消息
|
|
64
|
+
- `ProgramArguments` 写绝对路径(`which lark-relay-collect`),并显式设
|
|
65
|
+
`EnvironmentVariables` 里的 `HOME` 与 `PATH` —— launchd 不展开 `~`/`$VAR`,
|
|
66
|
+
也不继承 shell 环境
|
|
67
|
+
- `KeepAlive` 用 `SuccessfulExit=false`;`StandardErrorPath` 指到一个**父目录已存在**
|
|
68
|
+
的路径,launchd 不建目录
|
|
69
|
+
|
|
70
|
+
装:`sudo launchctl bootstrap system /Library/LaunchDaemons/<label>.plist`。
|
|
71
|
+
改了 plist 必须 bootout + bootstrap —— `kickstart -k` 只重启进程、不重读 plist。
|
|
53
72
|
|
|
54
73
|
### lark-relay take
|
|
55
74
|
|
|
@@ -64,6 +83,8 @@ lark-relay take --app <app> --chats oc_xxx --render text
|
|
|
64
83
|
起来后 stderr 先打一行「监听中」-- 没这行就是没起来,看 stderr 报错。
|
|
65
84
|
退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
|
|
66
85
|
参数拼错(如 `--chat-id`)会硬失败并给出正确写法,不静默吞掉。
|
|
86
|
+
枚举参数(`--render text|ndjson`、`--since now|all`)也走白名单,且在**阻塞之前**校验 --
|
|
87
|
+
`--render` 只在吐批时生效,拼错若不当场拦下要等满一批才看得出格式不对。
|
|
67
88
|
|
|
68
89
|
业务过滤只有 `--filter`(jq 表达式)一个口子,不为每个业务加参数。
|
|
69
90
|
|
|
@@ -80,24 +101,13 @@ lark-relay take --app <app> --chats oc_xxx --render text
|
|
|
80
101
|
多消费者**共享 store、各持游标**,不做「处理完即删」-- 谁都不能替别人删。
|
|
81
102
|
盯同一 app 请用不同 `--name` 隔离游标。
|
|
82
103
|
|
|
83
|
-
**游标会自动回收**:空闲超过 2
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
```bash
|
|
88
|
-
lark-relay cursors # ● 在跑 / ○ 空闲 Nh / ⚠ 空闲超期待回收
|
|
89
|
-
lark-relay cursors --forget <name> # 不等超期,现在就清
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
为什么是空闲时长而不是「落后多少条」:落后量是**遗弃的症状,不是反面** ——
|
|
93
|
-
被遗弃的游标落后会一路涨,拿它当判据会把该清的保护起来、该留的删掉
|
|
94
|
-
(2026-09-06 实测)。空闲时长与遗弃程度同向单调。
|
|
95
|
-
|
|
96
|
-
为什么 2 天够:take 是**实时场景**的取用口。空闲一两天以上再重挂,
|
|
97
|
-
下一次消费一定从新的开始;要历史消息有专门的接口,不靠游标回放。
|
|
104
|
+
**游标会自动回收**:空闲超过 2 天的由 collect 删掉(每小时自查,与 store 同一个 gc)。
|
|
105
|
+
判据是「多久没人用过它」(mtime)—— 不需要你记得收尾,也不需要判断该不该删。
|
|
106
|
+
看状态 `lark-relay status`;等不及就 `rm -rf ~/.lark-relay/cursors`。
|
|
98
107
|
改期限:`LARK_RELAY_CURSOR_TTL_DAYS=<天>`。
|
|
99
108
|
|
|
100
|
-
|
|
109
|
+
为什么不是「落后多少条」:那个方向是反的,这条判据错过两次 --
|
|
110
|
+
见 `docs/lessons.md#游标判据`。
|
|
101
111
|
|
|
102
112
|
## 设计取舍
|
|
103
113
|
|
|
@@ -106,11 +116,16 @@ lark-relay cursors --forget <name> # 不等超期,现在就清
|
|
|
106
116
|
- **一事件一文件 + 按天分目录**:不用追加式 NDJSON。因为多消费者共享 store 时
|
|
107
117
|
两种方案都不能「处理完即删」,NDJSON 最大的优势(删除简单)失效,而它的 gc
|
|
108
118
|
要按大小滚动 + 保留 N 个文件,反而比整目录删复杂
|
|
109
|
-
-
|
|
110
|
-
collect 重启或 bus
|
|
119
|
+
- **去重做在落盘侧**:collect 持 `Set<message_id>`,同一条消息在盘上只出现一次。
|
|
120
|
+
collect 重启或 bus 重投时它是**新文件**,游标拦不住 —— 所以不能只靠游标。
|
|
121
|
+
做在落盘侧而非每个消费者各存一份,游标目录才能只放游标
|
|
111
122
|
- **`--chats` 只接 `oc_` 开头的 chat_id,不接群名**:群名可能匹配多个或匹配错群,
|
|
112
123
|
而监听错群是**静默失败**(一直等一个永不来的消息)
|
|
113
124
|
- **`--since` 默认 `now`**:不重放历史,消灭「预热游标」这一步
|
|
125
|
+
- **不设扫描窗口**:消费侧扫盘上还在的全部日期。曾有个 3 天窗口,省 4ms,
|
|
126
|
+
换来「窗口必须正好等于 retain」的跨进程不变量
|
|
127
|
+
- **体积预算**:`lib`+`bin` 超 1900 行 `npm test` 就红(`test/budget.test.js`)。
|
|
128
|
+
这个包只做两件事,防的是治理层长回来
|
|
114
129
|
|
|
115
130
|
## 许可
|
|
116
131
|
|
|
@@ -2,24 +2,17 @@
|
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
4
|
// lark-relay-collect —— 事件采集底座,常驻服务专用入口。
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// 这是个 LaunchDaemon,服务管理器展示的是 ProgramArguments[0]。
|
|
8
|
-
// 挤在通用 CLI 里时 `launchctl print` 的 program 栏是 `lark-relay`、`ps` 的 COMM
|
|
9
|
-
// 是裸 `node` —— 看不出这是常驻服务。独立后 bin 名与 launchd label
|
|
10
|
-
// (com.adaex.lark-relay-collect)同名,好搜好认。
|
|
11
|
-
//
|
|
5
|
+
// 独立成可执行文件而非子命令:launchd 展示的是 ProgramArguments[0],
|
|
6
|
+
// 与 label(com.adaex.lark-relay-collect)同名才认得出。
|
|
12
7
|
// 采集逻辑全在 lib/collect.js,本文件只做参数解析与进程身份。
|
|
13
8
|
|
|
14
9
|
require('../lib/utf8').ensureUtf8()
|
|
15
10
|
|
|
16
|
-
// ps/top 里显示成自己的名字,不再是裸 node
|
|
17
|
-
// 排障第一步常是 `ps | grep`,进程认不出来会先浪费一轮
|
|
11
|
+
// ps/top 里显示成自己的名字,不再是裸 node -- 排障第一步常是 `ps | grep`
|
|
18
12
|
process.title = 'lark-relay-collect'
|
|
19
13
|
|
|
20
14
|
const { parseArgs, numStrict, list, rejectUnknown } = require('../lib/args')
|
|
21
15
|
const help = require('../lib/help')
|
|
22
|
-
const store = require('../lib/store')
|
|
23
16
|
|
|
24
17
|
const VERSION = require('../package.json').version
|
|
25
18
|
const PROG = 'lark-relay-collect'
|
|
@@ -34,11 +27,9 @@ async function main() {
|
|
|
34
27
|
return 0
|
|
35
28
|
}
|
|
36
29
|
|
|
37
|
-
// ⚠️ 短横线别名与 help 必须在 parseArgs
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// 那会抢线上 daemon 的 event bus(同一 app 服务端只放行一个),线上那个要
|
|
41
|
-
// 退避重连数分钟。**帮助命令导致丢消息**,反直觉到不会有人怀疑
|
|
30
|
+
// ⚠️ 短横线别名与 help 必须在 parseArgs **之前**拦:parseArgs 只认 `--xxx`,
|
|
31
|
+
// `-h` 会一路穿到 runCollect() 起一个 daemon 去抢线上的 bus。
|
|
32
|
+
// **帮助命令导致丢消息** -- 见 docs/lessons.md#静默失败
|
|
42
33
|
if (argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
|
|
43
34
|
process.stdout.write(`${help.COLLECT_HELP}\n`)
|
|
44
35
|
return 0
|
|
@@ -51,32 +42,22 @@ async function main() {
|
|
|
51
42
|
return 0
|
|
52
43
|
}
|
|
53
44
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// 最可能被敲的正是 `lark-relay-collect collect`:人记得有个 collect,
|
|
57
|
-
// 而它若静默起 daemon,又是一次「求助反而丢消息」
|
|
45
|
+
// 本命令没有子命令,位置参数一律是敲错了。最可能被敲的正是
|
|
46
|
+
// `lark-relay-collect collect`,它若静默起 daemon 又是一次「求助反而丢消息」
|
|
58
47
|
if (a._.length) {
|
|
59
48
|
const extra = a._.join(' ')
|
|
60
|
-
const hint = /^(collect|take|status
|
|
61
|
-
? `\n采集就是本命令自己,不带子命令;take/status
|
|
49
|
+
const hint = /^(collect|take|status)$/.test(a._[0])
|
|
50
|
+
? `\n采集就是本命令自己,不带子命令;take/status 在 \`lark-relay\` 那边\n`
|
|
62
51
|
: '\n'
|
|
63
52
|
process.stderr.write(`${PROG} 不接位置参数,多了:${extra}${hint}跑 \`${PROG} --help\` 看用法\n`)
|
|
64
53
|
return 2
|
|
65
54
|
}
|
|
66
55
|
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// 两个值在两个进程里,只能在这里把不变量钉死:装完新版直接起不来,好过悄悄丢
|
|
73
|
-
const retain = numStrict(a.retain, store.SCAN_DAYS)
|
|
74
|
-
if (retain === null || retain !== store.SCAN_DAYS) {
|
|
75
|
-
process.stderr.write(
|
|
76
|
-
`--retain 必须等于消费侧扫描窗口 ${store.SCAN_DAYS} 天,当前:${a.retain}\n` +
|
|
77
|
-
`要改保留期就同时改两边:LARK_RELAY_SCAN_DAYS=<N> 且 --retain <N>。\n` +
|
|
78
|
-
`留得比窗口久 -> 事件在盘上却扫不到;短于窗口 -> 空目录被当成「还能续接」\n`,
|
|
79
|
-
)
|
|
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`)
|
|
80
61
|
return 2
|
|
81
62
|
}
|
|
82
63
|
|
package/bin/lark-relay.js
CHANGED
|
@@ -3,34 +3,23 @@
|
|
|
3
3
|
|
|
4
4
|
require('../lib/utf8').ensureUtf8()
|
|
5
5
|
|
|
6
|
-
const { parseArgs, numStrict,
|
|
6
|
+
const { parseArgs, numStrict, rejectUnknown } = require('../lib/args')
|
|
7
7
|
const help = require('../lib/help')
|
|
8
8
|
|
|
9
9
|
const VERSION = require('../package.json').version
|
|
10
10
|
|
|
11
11
|
// 带值参数白名单(布尔开关在 parseArgs 的 flags 里)。
|
|
12
|
-
// 不在白名单的 --xxx 一律硬报错 ——
|
|
13
|
-
// --chat-id 拼错 -> 打印用法退出,看着像「正常退出但没消息」,漏看 bot 第一条 Working
|
|
12
|
+
// 不在白名单的 --xxx 一律硬报错 —— 拼错参数静默吞掉是实测事故,见 docs/lessons.md#静默失败
|
|
14
13
|
const TAKE_KEYS = ['app', 'chats', 'name', 'filter', 'debounce', 'max-wait', 'timeout', 'render', 'since']
|
|
15
14
|
|
|
16
|
-
const COMMANDS = ['take', 'status', 'cursors', 'guide']
|
|
17
|
-
|
|
18
|
-
// collect 搬家提示。能力是**搬走了不是删了**,所以不能只说「未知命令」
|
|
19
|
-
const MOVED_HINT =
|
|
20
|
-
`collect 已拆成独立命令(同一个包就带,不用另外装):\n` +
|
|
21
|
-
` lark-relay-collect --help # 前台调试与常驻部署\n`
|
|
22
|
-
|
|
23
15
|
const USAGE = `lark-relay ${VERSION} -- Lark 事件中继站(在场取用)
|
|
24
16
|
|
|
25
17
|
lark-relay take ... 在场取用:阻塞等一批 -> 输出 -> 退出
|
|
26
|
-
lark-relay status 谁在跑 / 各 app
|
|
27
|
-
lark-relay cursors 游标状态:谁在值守 / 空闲多久(超期自动回收)
|
|
28
|
-
lark-relay guide 一页用法(装完先读这个)
|
|
18
|
+
lark-relay status 谁在跑 / 各 app 收了多少 / 游标在哪
|
|
29
19
|
|
|
30
|
-
|
|
20
|
+
采集底座是独立的常驻命令:lark-relay-collect
|
|
31
21
|
|
|
32
|
-
|
|
33
|
-
各命令详情 -> lark-relay <命令> --help`
|
|
22
|
+
用法详情 -> lark-relay take(空参就是完整指导)`
|
|
34
23
|
|
|
35
24
|
async function main() {
|
|
36
25
|
const argv = process.argv.slice(2)
|
|
@@ -47,33 +36,17 @@ async function main() {
|
|
|
47
36
|
}
|
|
48
37
|
|
|
49
38
|
switch (cmd) {
|
|
50
|
-
case 'guide':
|
|
51
|
-
process.stdout.write(`${help.GUIDE}\n`)
|
|
52
|
-
return 0
|
|
53
39
|
case 'take':
|
|
54
40
|
return await cmdTake(rest)
|
|
55
41
|
case 'status':
|
|
56
42
|
return await cmdStatus(rest)
|
|
57
|
-
|
|
58
|
-
return cmdCursors(rest)
|
|
59
|
-
// collect 拆成了独立可执行文件,这里专门拦一下告诉人改敲什么。
|
|
60
|
-
// 不能落到 default 分支:suggest('collect', ['take','status','guide']) 必然返回
|
|
61
|
-
// null(编辑距离都 >2、前缀 coll 也不匹配),只会打出干巴巴的「未知命令:collect」--
|
|
62
|
-
// 那会让人以为采集功能被删了。**能力是搬家,不是消失,提示必须给出新名字**
|
|
43
|
+
// 能力是**搬家不是删除**,提示必须给出新名字,否则会被当成功能没了
|
|
63
44
|
case 'collect':
|
|
64
|
-
process.stderr.write(
|
|
45
|
+
process.stderr.write('采集是独立命令(同一个包就带):lark-relay-collect --help\n')
|
|
65
46
|
return 2
|
|
66
|
-
default:
|
|
67
|
-
|
|
68
|
-
// 给「未知命令」等于让人以为功能没了。判据:编辑距离 <=2 且不像任何现有命令
|
|
69
|
-
if (suggest(cmd, ['collect']) === 'collect' && !suggest(cmd, COMMANDS)) {
|
|
70
|
-
process.stderr.write(`未知命令:${cmd}。${MOVED_HINT}`)
|
|
71
|
-
return 2
|
|
72
|
-
}
|
|
73
|
-
const s = suggest(cmd, COMMANDS)
|
|
74
|
-
process.stderr.write(`未知命令:${cmd}${s ? `。你是不是想写 ${s}?` : ''}\n\n${USAGE}\n`)
|
|
47
|
+
default:
|
|
48
|
+
process.stderr.write(`未知命令:${cmd}\n\n${USAGE}\n`)
|
|
75
49
|
return 2
|
|
76
|
-
}
|
|
77
50
|
}
|
|
78
51
|
}
|
|
79
52
|
|
|
@@ -84,7 +57,7 @@ async function cmdTake(argv) {
|
|
|
84
57
|
|
|
85
58
|
// 空参数输出完整照做指导,而非报错 -- AI 的真实行为是「先空参数跑一下看看」。
|
|
86
59
|
// 但带了参数又缺 app/chats 的走下面短错误 -- 打印完整用法和 --help 长得一样,
|
|
87
|
-
// 会被当成「正常退出但没消息」
|
|
60
|
+
// 会被当成「正常退出但没消息」
|
|
88
61
|
if (a.help || argv.length === 0) {
|
|
89
62
|
let apps = []
|
|
90
63
|
try {
|
|
@@ -130,38 +103,31 @@ async function cmdTake(argv) {
|
|
|
130
103
|
return 3
|
|
131
104
|
}
|
|
132
105
|
|
|
133
|
-
// --since 只有 now|all
|
|
134
|
-
// 其余全当 all」,于是 `--since Now`
|
|
135
|
-
// 与本仓「拼错参数当场硬失败」的铁律一致(args.js 开头那条)
|
|
106
|
+
// --since 只有 now|all 两个取值,必须白名单校验:曾经是「跟 'now' 比一次,
|
|
107
|
+
// 其余全当 all」,于是 `--since Now` 静默变成全量重放
|
|
136
108
|
const since = a.since === true ? 'now' : a.since || 'now'
|
|
137
109
|
if (since !== 'now' && since !== 'all') {
|
|
138
110
|
process.stderr.write(`--since 只能是 now 或 all,当前:${since}\n`)
|
|
139
111
|
return 2
|
|
140
112
|
}
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// 不再需要「位置早于窗口」那条告知了 —— 遗弃的游标由 collect 按空闲时长自动回收
|
|
152
|
-
// (见 lib/cursors.js),不靠位置推断,也不靠人清。
|
|
113
|
+
// --render 同理,且必须在阻塞之前校验:`--render tex` 曾静默退回 NDJSON,
|
|
114
|
+
// 而那要等满一批(最长 12h)才看得出来 —— 到时候批次已经吐成了错的格式
|
|
115
|
+
const mode = a.render === true ? 'text' : a.render || 'ndjson'
|
|
116
|
+
if (mode !== 'text' && mode !== 'ndjson') {
|
|
117
|
+
process.stderr.write(`--render 只能是 text 或 ndjson,当前:${mode}\n`)
|
|
118
|
+
return 2
|
|
119
|
+
}
|
|
120
|
+
// 没有游标就按 --since now 起头。已有的一律原样保留 ——
|
|
121
|
+
// 别「自愈」到最新,那会静默丢掉全部积压(见 docs/lessons.md#游标判据)
|
|
153
122
|
if (since === 'now') {
|
|
154
123
|
if (store.readCursor(name, a.app) === null) store.seedCursorNow(name, a.app)
|
|
155
124
|
}
|
|
156
125
|
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// 空手超时/被 SIGTERM 都不写(实测起一个 take 再 kill,mtime 一动不动)。
|
|
160
|
-
// 不 touch 就会把「安静的群盯了两天没消息」误判成遗弃并回收
|
|
126
|
+
// 标记「有人来取过」:gc 判据是 mtime,而 take 空手退出时不写游标。
|
|
127
|
+
// 不 touch 会把「盯着一个安静的群」误判成遗弃
|
|
161
128
|
store.touchCursor(name, a.app)
|
|
162
129
|
|
|
163
130
|
// 时长参数校验:负数/非数字过去会静默退回默认值或原样生效
|
|
164
|
-
// (实测 --debounce -5 被接受并打印「防抖=-5s」,--timeout abc 悄悄变成 12 小时)
|
|
165
131
|
const dur = (val, def, label) => {
|
|
166
132
|
const n = numStrict(val, def)
|
|
167
133
|
if (n === null || !(n >= 0)) {
|
|
@@ -177,7 +143,7 @@ async function cmdTake(argv) {
|
|
|
177
143
|
const timeoutMs = dur(a.timeout, 12 * 3600, 'timeout')
|
|
178
144
|
|
|
179
145
|
// 起来先打一行「监听中」-- 没这行就是没起来。
|
|
180
|
-
//
|
|
146
|
+
// 实测:进程静默退出(参数错)被当成「在监听」,漏看 bot 第一条 Working
|
|
181
147
|
const fmtDur = (ms) => (ms >= 3600000 ? `${ms / 3600000}h` : `${ms / 1000}s`)
|
|
182
148
|
process.stderr.write(
|
|
183
149
|
`监听中:app=${a.app} 群=${chats.length} 个(${chats.join(',')}) 游标=${name} ` +
|
|
@@ -205,96 +171,11 @@ async function cmdTake(argv) {
|
|
|
205
171
|
return res.reason === 'timeout' ? 4 : 0
|
|
206
172
|
}
|
|
207
173
|
|
|
208
|
-
const mode = a.render === true ? 'text' : a.render
|
|
209
174
|
process.stdout.write(`${render(res.events, mode)}\n`)
|
|
210
175
|
process.stderr.write(`${res.events.length} 条,游标身份 ${name}\n`)
|
|
211
176
|
return 0
|
|
212
177
|
}
|
|
213
178
|
|
|
214
|
-
// cursors:**只读展示 + 一个提前忘掉的逃生舱**。
|
|
215
|
-
//
|
|
216
|
-
// ⚠️ 这里曾有 --prune / --force 与一套「该不该删」的判断(2026-09-06 删)。
|
|
217
|
-
// 删的理由不是简化:那套东西要求人拿着一个数字做破坏性决定,而**判据错了两次**
|
|
218
|
-
// (先按游标日期、后按待消费数,方向都是反的 —— 见 lib/cursors.js 文件头)。
|
|
219
|
-
// 现在回收由 collect 按空闲时长自动做,人不必判断,所以那些档位和确认步骤
|
|
220
|
-
// 一起失去了存在理由。留一个 --forget 给「现在就想清干净」。
|
|
221
|
-
function cmdCursors(argv) {
|
|
222
|
-
const CURSORS_FLAGS = ['forget', 'json', 'help']
|
|
223
|
-
const a = parseArgs(argv, { flags: CURSORS_FLAGS, keys: [] })
|
|
224
|
-
if (a._unknown.length) return rejectUnknown('lark-relay cursors', a._unknown, CURSORS_FLAGS)
|
|
225
|
-
if (a.help) {
|
|
226
|
-
process.stdout.write(`${help.CURSORS_HELP}\n`)
|
|
227
|
-
return 0
|
|
228
|
-
}
|
|
229
|
-
const cursors = require('../lib/cursors')
|
|
230
|
-
const ttlDays = cursors.IDLE_TTL_DAYS
|
|
231
|
-
|
|
232
|
-
// 人话的空闲时长。回收判据就是这个数,所以它必须是最显眼的一列
|
|
233
|
-
const idleTxt = (ms) => {
|
|
234
|
-
if (!Number.isFinite(ms)) return '未知'
|
|
235
|
-
// 向下取整:这个数是回收判据,宁可少报不可多报 --
|
|
236
|
-
// Math.round 会把 2.5 天显示成「3d」,看着像已经超了 2 天的 TTL 更多
|
|
237
|
-
const m = Math.floor(ms / 60000)
|
|
238
|
-
if (m < 60) return `${m}m`
|
|
239
|
-
const h = Math.floor(m / 60)
|
|
240
|
-
return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// 只读模式:列清单。删数据必须显式 --forget
|
|
244
|
-
if (!a.forget) {
|
|
245
|
-
const rows = cursors.listCursors()
|
|
246
|
-
if (a.json) {
|
|
247
|
-
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`)
|
|
248
|
-
return 0
|
|
249
|
-
}
|
|
250
|
-
if (!rows.length) {
|
|
251
|
-
process.stdout.write('没有游标(还没跑过 take,或已全部回收)\n')
|
|
252
|
-
return 0
|
|
253
|
-
}
|
|
254
|
-
const mark = { live: '●', idle: '○', expiring: '⚠' }
|
|
255
|
-
const note = {
|
|
256
|
-
live: (r) => `在跑 (pid ${r.pid}, 待消费 ${r.pending})`,
|
|
257
|
-
idle: (r) => `空闲 ${idleTxt(r.idleMs)}(待消费 ${r.pending},重挂 take 接着盯)`,
|
|
258
|
-
expiring: (r) => `空闲 ${idleTxt(r.idleMs)} 超 ${ttlDays} 天 -> 下轮 gc 自动回收`,
|
|
259
|
-
}
|
|
260
|
-
for (const r of rows) {
|
|
261
|
-
process.stdout.write(`${mark[r.state]} ${r.name} app=${r.app} ${note[r.state](r)}\n`)
|
|
262
|
-
}
|
|
263
|
-
const expiring = rows.filter((r) => r.state === 'expiring').length
|
|
264
|
-
if (expiring) {
|
|
265
|
-
process.stdout.write(
|
|
266
|
-
`\n${expiring} 个已超 ${ttlDays} 天空闲,collect 每小时自查时会回收 —— 不用手动清\n`,
|
|
267
|
-
)
|
|
268
|
-
}
|
|
269
|
-
return 0
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// --forget <name>...:不等 TTL,现在就清
|
|
273
|
-
const names = a._
|
|
274
|
-
if (!names.length) {
|
|
275
|
-
process.stderr.write(
|
|
276
|
-
`--forget 需要点名要忘掉哪个身份:lark-relay cursors --forget <name>...\n` +
|
|
277
|
-
`(不点名的批量清理已经不需要了 —— 空闲超 ${ttlDays} 天的由 collect 自动回收)\n`,
|
|
278
|
-
)
|
|
279
|
-
return 2
|
|
280
|
-
}
|
|
281
|
-
const res = cursors.forget(names)
|
|
282
|
-
|
|
283
|
-
// removed 的元素是 `name/app` —— 删的单位是 app,一个身份可能盯多个
|
|
284
|
-
for (const n of res.removed) process.stdout.write(`已忘掉 ${n}\n`)
|
|
285
|
-
for (const n of res.missing) process.stderr.write(`没有这个游标身份:${n}\n`)
|
|
286
|
-
for (const r of res.refused) {
|
|
287
|
-
process.stderr.write(
|
|
288
|
-
`跳过 ${r.name}:有 take 正在用它(pid ${r.pid})。\n` +
|
|
289
|
-
` 要清它先停掉那个 take(kill ${r.pid}),否则它会把游标立刻写回来。\n`,
|
|
290
|
-
)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (res.missing.length) return 2
|
|
294
|
-
if (res.refused.length && !res.removed.length) return 3
|
|
295
|
-
return 0
|
|
296
|
-
}
|
|
297
|
-
|
|
298
179
|
async function cmdStatus(argv) {
|
|
299
180
|
const a = parseArgs(argv, { flags: ['json', 'help'], keys: [] })
|
|
300
181
|
if (a._unknown.length) return rejectUnknown('lark-relay status', a._unknown, ['json'])
|
|
@@ -311,10 +192,8 @@ async function cmdStatus(argv) {
|
|
|
311
192
|
|
|
312
193
|
main()
|
|
313
194
|
.then((code) => {
|
|
314
|
-
// ⚠️ 不能用 process.exit()
|
|
315
|
-
//
|
|
316
|
-
// 65536(管道缓冲上限)且切在 JSON 中途,而 stderr 照报「400 条」。
|
|
317
|
-
// 设 exitCode 让事件循环自然排空,退出码语义不变
|
|
195
|
+
// ⚠️ 不能用 process.exit():stdout 是管道时它会丢掉未 flush 的缓冲
|
|
196
|
+
// (实测 676KB 只收到 65536 且切在 JSON 中途)。见 docs/lessons.md#管道截断
|
|
318
197
|
process.exitCode = code || 0
|
|
319
198
|
})
|
|
320
199
|
.catch((err) => {
|
package/lib/args.js
CHANGED
|
@@ -67,67 +67,19 @@ function list(v) {
|
|
|
67
67
|
.filter(Boolean)
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const n = b.length
|
|
73
|
-
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)])
|
|
74
|
-
for (let j = 0; j <= n; j++) dp[0][j] = j
|
|
75
|
-
for (let i = 1; i <= m; i++) {
|
|
76
|
-
for (let j = 1; j <= n; j++) {
|
|
77
|
-
dp[i][j] = Math.min(
|
|
78
|
-
dp[i - 1][j] + 1,
|
|
79
|
-
dp[i][j - 1] + 1,
|
|
80
|
-
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
81
|
-
)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return dp[m][n]
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// 拼错参数时给个候选:编辑距离 <= 2,或前 4 字符前缀相同(--chat-id -> --chats)。
|
|
88
|
-
// 都不沾边返回 null,不硬猜。
|
|
89
|
-
function suggest(unknown, allowed) {
|
|
90
|
-
let best = null
|
|
91
|
-
let bestD = Infinity
|
|
92
|
-
for (const k of allowed) {
|
|
93
|
-
const d = levenshtein(unknown, k)
|
|
94
|
-
if (d < bestD) {
|
|
95
|
-
bestD = d
|
|
96
|
-
best = k
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (bestD <= 2) return best
|
|
100
|
-
if (unknown.length >= 4) {
|
|
101
|
-
const hit = allowed.find((k) => k.startsWith(unknown.slice(0, 4)))
|
|
102
|
-
if (hit) return hit
|
|
103
|
-
}
|
|
104
|
-
return null
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// 未知参数 -> stderr 短错误 + 候选提示,exit 2。
|
|
108
|
-
// 不打印完整用法 -- 那看着像 --help 成功,会被当成「正常退出但没消息」
|
|
70
|
+
// 未知参数 -> stderr 短错误 + 合法参数清单,exit 2。
|
|
71
|
+
// 不打印完整用法 -- 那看着像 --help 成功,会被当成「正常退出但没消息」。
|
|
109
72
|
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// (如已拆走的 `lark-relay collect`)会把人引向死路。
|
|
113
|
-
// lark-relay 传 `lark-relay <子命令>`,lark-relay-collect 传自己的名字。
|
|
73
|
+
// 直接列出全部合法参数,不做编辑距离猜测:参数总共 9 个,列出来比猜一个更有用
|
|
74
|
+
// (曾有 50 行 levenshtein + suggest 干这件事)。
|
|
114
75
|
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// 再拼一条完整命令行属于替调用方猜意图,而那 35 行要长期跟着参数表走
|
|
76
|
+
// ⚠️ `prog` 必须由调用方传入:两个 bin 共用这段,而提示里那行是要照抄执行的 --
|
|
77
|
+
// 打出一个不存在的命令会把人引向死路
|
|
118
78
|
function rejectUnknown(prog, unknown, allowed, out = process.stderr) {
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
for (const k of names) {
|
|
123
|
-
const s = suggest(k, allowed)
|
|
124
|
-
if (s) {
|
|
125
|
-
hint = `\n你是不是想写 --${s}?`
|
|
126
|
-
break
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
out.write(`未知参数:${shown}${hint}\n跑 \`${prog}\` 看完整用法\n`)
|
|
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`)
|
|
130
82
|
return 2
|
|
131
83
|
}
|
|
132
84
|
|
|
133
|
-
module.exports = { parseArgs, numStrict, list,
|
|
85
|
+
module.exports = { parseArgs, numStrict, list, rejectUnknown }
|