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 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 guide # 装完先读这个
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
- 删超期的整个日期目录 -- 不另起 gc 单元/timer。
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 天的游标由 collect 删掉(每小时自查一次,
84
- 与 store 的日期目录回收同一个 gc)。判据是「多久没人用过它」——
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
- 在跑的游标(有 take 持锁)一律不回收 —— take 会立刻把它写回来。
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
- - **两层去重**:cursor 管进度,`message_id` 管幂等(TTL 6h)。
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
- // 为什么独立成一个可执行文件、不做 `lark-relay collect` 子命令:
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(实测 macOS 与 Linux 都生效)。
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
- // parseArgs 只认 `--xxx`:`-h` 既不置 a.help、也不进 _unknown(不以 -- 开头),
39
- // 会一路穿到 runCollect() —— 实测「敲 -h 求帮助,结果起了一个 daemon」。
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
- // (见 lib/args.js:4-6:拼错参数必须当场失败)。
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|guide)$/.test(a._[0])
61
- ? `\n采集就是本命令自己,不带子命令;take/status/guide 在 \`lark-relay\` 那边\n`
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
- // retain 必须**正好等于**消费侧的扫描窗口。两个方向都是事故:
68
- // retain > 窗口:事件留在盘上却永远扫不到(store.js 那条注释警告的)
69
- // retain < 窗口:窗口最老那几天已被 gc 删空,却仍被算作「窗口内」——
70
- // 停在那里的游标判成「可续接」,而数据一条都不在了
71
- // (2026-09-06 实测 retain=3 / 窗口=4 的 D-3 就是这种空档)
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, suggest, rejectUnknown } = require('../lib/args')
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
- 采集底座是独立命令(常驻服务):lark-relay-collect
20
+ 采集底座是独立的常驻命令:lark-relay-collect
31
21
 
32
- 第一次用 -> lark-relay guide
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
- case 'cursors':
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(MOVED_HINT)
45
+ process.stderr.write('采集是独立命令(同一个包就带):lark-relay-collect --help\n')
65
46
  return 2
66
- default: {
67
- // 拼错 collect(collectt / colect)同样走搬家提示 —— 意图明显是采集,
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
- // 会被当成「正常退出但没消息」(实测漏看 bot 第一条 Working)。
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 两个取值。必须白名单校验:曾经是「跟 'now' 比一次,
134
- // 其余全当 all」,于是 `--since Now`(大写)、`--since 乱码` 都静默变成全量重放。
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
- // 游标初始化:没有游标就按 --since now 起头(消灭旧架构「预热游标」那一步)。
142
- //
143
- // ⚠️ 2026-09-06 删掉了这里的「stale 自愈」。它曾在游标位置超出扫描窗口时
144
- // 把游标**重置到当前最新**,理由写的是「续接它和 seed now 行为完全一样」。
145
- // 那个理由是错的,实测证伪:afterCursor 比的是「事件日期 > 游标日期」,
146
- // 游标越老放行的事件越多。实测游标停在 D-5、窗口内有 3 条未消费时,
147
- // 保留游标 -> 取到 3 条 重置到 now -> 取到 0 条
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
- // 标记「有人来取过」。回收判据是空闲时长,而它读 mtime --
158
- // ⚠️ 必须在这里显式 touch:take 的正常路径只在**有批次**时写游标,
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
- // 反馈实测:进程静默退出(参数错)被当成「在监听」,漏看 bot 第一条 Working
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() —— stdout 是管道时它会丢掉未 flush 的缓冲。
315
- // 实测:400 条 NDJSON 重定向到文件得 676070 字节,同样数据接管道只收到
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
- function levenshtein(a, b) {
71
- const m = a.length
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
- // ⚠️ `prog` 必须由调用方传入,不能写死 'lark-relay':两个 bin 共用这段,
111
- // 而提示里那行是要照抄执行的 —— 打出一个不存在的命令
112
- // (如已拆走的 `lark-relay collect`)会把人引向死路。
113
- // lark-relay 传 `lark-relay <子命令>`,lark-relay-collect 传自己的名字。
73
+ // 直接列出全部合法参数,不做编辑距离猜测:参数总共 9 个,列出来比猜一个更有用
74
+ // (曾有 50 行 levenshtein + suggest 干这件事)。
114
75
  //
115
- // 注:2026-09-06 删掉了 rewriteArgv(把整条 argv 重写成可照跑的命令行,约 35 行)。
116
- // 它是纯投机 —— 参数总共 9 个,`suggest` 已经点名了该写哪个,
117
- // 再拼一条完整命令行属于替调用方猜意图,而那 35 行要长期跟着参数表走
76
+ // ⚠️ `prog` 必须由调用方传入:两个 bin 共用这段,而提示里那行是要照抄执行的 --
77
+ // 打出一个不存在的命令会把人引向死路
118
78
  function rejectUnknown(prog, unknown, allowed, out = process.stderr) {
119
- const names = [...new Set(unknown)]
120
- const shown = names.map((k) => `--${k}`).join(', ')
121
- let hint = ''
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, suggest, rejectUnknown }
85
+ module.exports = { parseArgs, numStrict, list, rejectUnknown }