lark-relay 0.4.6 → 0.4.7

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,13 +1,13 @@
1
1
  # lark-relay
2
2
 
3
- Lark(飞书)事件中继站。三个命令:**收下来** / **我来取** / **派给别人**。
3
+ Lark(飞书)事件中继站。两件事:**收下来** / **我来取**。
4
4
 
5
5
  依赖 [`lark-cli`](https://www.npmjs.com/package/@lark-base-open/lark-cli) 提供认证与
6
- 事件总线;本包只负责缓冲、批次与派活,零 npm 依赖。
6
+ 事件总线;本包只负责缓冲与批次,零 npm 依赖。
7
7
 
8
8
  ```bash
9
- npm i -g lark-relay
10
- lark-relay guide # 装完先读这个
9
+ npm i -g lark-relay # 装出两个命令:lark-relay-collect(常驻)/ lark-relay(在场)
10
+ lark-relay guide # 装完先读这个
11
11
  ```
12
12
 
13
13
  ## 为什么需要它
@@ -16,14 +16,17 @@ Lark 事件是**流式**的 -- 进程不在的时刻,消息**永久丢失**,不
16
16
  (实测:消息发出 8 秒后才起 consumer,收到 0 条)。
17
17
 
18
18
  所以要有一个常驻进程只管把事件收下来落盘,消费侧崩了、AI 跑了半小时、
19
- 会话关了几小时,都不丢消息。这就是 `collect`。
19
+ 会话关了几小时,都不丢消息。这就是 `lark-relay-collect`。
20
20
 
21
21
  ## 两段分离
22
22
 
23
- 采集与消费必须分离:`collect` 只管把事件收下来落盘,`take` 在你需要时取走一批。
24
- 消费侧崩了、AI 跑了半小时、会话关了几小时,采集都不能停 --
23
+ 采集与消费必须分离:`lark-relay-collect` 只管把事件收下来落盘,`lark-relay take`
24
+ 在你需要时取走一批。消费侧崩了、AI 跑了半小时、会话关了几小时,采集都不能停 --
25
25
  停摆的每一秒都在丢消息。
26
26
 
27
+ 分离到**可执行文件**这一层,不是两个子命令:采集是常驻服务,服务管理器
28
+ (systemd / launchd)展示的是可执行文件名,与单元名同名才认得出是谁。
29
+
27
30
  | | |
28
31
  | --- | --- |
29
32
  | 长命的 | AI 会话(人设、上下文连续) |
@@ -36,13 +39,14 @@ Lark 事件是**流式**的 -- 进程不在的时刻,消息**永久丢失**,不
36
39
  ## 命令
37
40
 
38
41
  ```bash
39
- lark-relay collect # 底座:全部 profile 各起 consume -> 原子落盘(常驻服务)
42
+ lark-relay-collect # 底座:全部 profile 各起 consume -> 原子落盘(常驻服务)
43
+
40
44
  lark-relay take … # 阻塞等一批 -> 输出 -> 退出
41
45
  lark-relay status # 谁在跑 / 各 app 积压 / 各游标位置
42
46
  lark-relay guide # 一页用法
43
47
  ```
44
48
 
45
- ### collect
49
+ ### lark-relay-collect
46
50
 
47
51
  零参数、零配置。app 列表实时读 `lark-cli profile list`,新增 profile 自动纳入。
48
52
  未授权或授权过期的 profile 自动跳过并 warn(永久失败,重试是死循环)。
@@ -50,7 +54,7 @@ lark-relay guide # 一页用法
50
54
  每 app 一个子进程,某个挂了单独重启不影响其他。回收在本进程内每小时自查,
51
55
  删超期的整个日期目录 -- 不另起 gc 单元/timer。
52
56
 
53
- ### take
57
+ ### lark-relay take
54
58
 
55
59
  只有一种形态:**阻塞等 -> 有一批就输出 -> 退出**。循环交给 harness --
56
60
  Claude Code 用 `run_in_background` 起,进程退出会主动通知 AI,这才是事件驱动。
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // lark-relay-collect —— 事件采集底座,常驻服务专用入口。
5
+ //
6
+ // 为什么独立成一个可执行文件、不做 `lark-relay collect` 子命令:
7
+ // 这是个 LaunchDaemon / systemd 单元,服务管理器展示的是 ProgramArguments[0]。
8
+ // 挤在通用 CLI 里时 `launchctl print` 的 program 栏是 `lark-relay`、`ps` 的 COMM
9
+ // 是裸 `node` —— 看不出这是常驻服务。独立后 bin 名与 launchd label
10
+ // (com.adaex.lark-relay-collect)、systemd 单元名三处同名,好搜好认。
11
+ //
12
+ // 采集逻辑全在 lib/collect.js,本文件只做参数解析与进程身份。
13
+
14
+ require('../lib/utf8').ensureUtf8()
15
+
16
+ // ps/top 里显示成自己的名字,不再是裸 node(实测 macOS 与 Linux 都生效)。
17
+ // 排障第一步常是 `ps | grep`,进程认不出来会先浪费一轮
18
+ process.title = 'lark-relay-collect'
19
+
20
+ const { parseArgs, num, list, rejectUnknown } = require('../lib/args')
21
+ const help = require('../lib/help')
22
+
23
+ const VERSION = require('../package.json').version
24
+ const PROG = 'lark-relay-collect'
25
+ const KEYS = ['exclude', 'retain']
26
+
27
+ async function main() {
28
+ const argv = process.argv.slice(2)
29
+
30
+ // --version 要有:deploy-launchd.sh 用它打印「全局命令版本」作部署前后对照
31
+ if (argv[0] === '--version' || argv[0] === '-v' || argv[0] === 'version') {
32
+ process.stdout.write(`${VERSION}\n`)
33
+ return 0
34
+ }
35
+
36
+ const a = parseArgs(argv, { flags: ['help'], keys: KEYS })
37
+ if (a._unknown.length) return rejectUnknown(PROG, argv, a._unknown, KEYS)
38
+ if (a.help || argv[0] === 'help') {
39
+ process.stdout.write(`${help.COLLECT_HELP}\n`)
40
+ return 0
41
+ }
42
+
43
+ const { runCollect } = require('../lib/collect')
44
+ return await runCollect({
45
+ exclude: list(a.exclude),
46
+ retain: num(a.retain, 3),
47
+ })
48
+ }
49
+
50
+ main()
51
+ .then((code) => process.exit(code || 0))
52
+ .catch((err) => {
53
+ // 注:runCollect 内部的错误走 lib/log.js 的 logLine(带时间戳,常驻服务要的)。
54
+ // 这里只兜住它之前/之外的意外,故是裸 stderr
55
+ if (err && err.userFacing) process.stderr.write(`${err.message}\n`)
56
+ else process.stderr.write(`${PROG}: ${(err && err.stack) || err}\n`)
57
+ process.exit(1)
58
+ })
package/bin/lark-relay.js CHANGED
@@ -1,88 +1,35 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
- // UTF-8 兜底:systemd 默认 C locale。虽然 Node 的字符串处理不像 bash 那样
5
- // 按字节切片,但子进程(lark-cli / claude)会继承 locale,中文仍会被切坏。
6
- if (!/utf-?8/i.test(process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || '')) {
7
- process.env.LANG = 'C.UTF-8'
8
- process.env.LC_ALL = 'C.UTF-8'
9
- }
4
+ require('../lib/utf8').ensureUtf8()
10
5
 
11
- const { parseArgs, num, list, suggest } = require('../lib/args')
6
+ const { parseArgs, num, suggest, rejectUnknown } = require('../lib/args')
12
7
  const help = require('../lib/help')
13
8
 
14
9
  const VERSION = require('../package.json').version
15
10
 
16
- // 各命令的带值参数白名单(布尔开关在 parseArgs 的 flags 里)。
11
+ // 带值参数白名单(布尔开关在 parseArgs 的 flags 里)。
17
12
  // 不在白名单的 --xxx 一律硬报错 —— 拼错参数静默吞掉是实测事故:
18
13
  // --chat-id 拼错 -> 打印用法退出,看着像「正常退出但没消息」,漏看 bot 第一条 Working
19
14
  const TAKE_KEYS = ['app', 'chats', 'name', 'filter', 'debounce', 'max-wait', 'timeout', 'render', 'since']
20
- const COLLECT_KEYS = ['exclude', 'retain']
21
-
22
- // 未知参数 -> stderr 短错误 + 候选提示 + 改写后的命令,exit 2。
23
- // 不打印完整用法 -- 那看着像 --help 成功,会被当成「正常退出但没消息」
24
- function rejectUnknown(cmd, argv, unknown, allowed) {
25
- const names = [...new Set(unknown)]
26
- const shown = names.map((k) => `--${k}`).join(', ')
27
- let hint = ''
28
- for (const k of names) {
29
- const s = suggest(k, allowed)
30
- if (s) {
31
- hint = `\n你是不是想写 --${s}?`
32
- break
33
- }
34
- }
35
- const rewrite = rewriteArgv(argv, names, allowed)
36
- const tail = rewrite.length
37
- ? `\n你是不是想跑这个?\n lark-relay ${cmd} ${rewrite.join(' ')}`
38
- : ''
39
- process.stderr.write(`未知参数:${shown}${hint}${tail}\n跑 \`lark-relay ${cmd}\` 看完整用法\n`)
40
- return 2
41
- }
42
15
 
43
- // 把未知参数替换成候选,拼出可照跑的命令行;无候选的未知参数(连同它的值)丢弃
44
- function rewriteArgv(argv, unknown, allowed) {
45
- const map = new Map()
46
- for (const k of unknown) {
47
- const s = suggest(k, allowed)
48
- if (s && !map.has(k)) map.set(k, s)
49
- }
50
- if (!map.size) return []
51
- const out = []
52
- for (let i = 0; i < argv.length; i++) {
53
- const a = argv[i]
54
- if (a.startsWith('--') && a !== '--') {
55
- const eq = a.indexOf('=')
56
- const k = eq !== -1 ? a.slice(2, eq) : a.slice(2)
57
- if (map.has(k)) {
58
- out.push(eq !== -1 ? `--${map.get(k)}=${a.slice(eq + 1)}` : `--${map.get(k)}`)
59
- if (eq === -1) {
60
- const next = argv[i + 1]
61
- if (next !== undefined && !next.startsWith('--')) {
62
- out.push(next)
63
- i++
64
- }
65
- }
66
- continue
67
- }
68
- if (eq === -1) {
69
- const next = argv[i + 1]
70
- if (next !== undefined && !next.startsWith('--')) i++
71
- }
72
- continue
73
- }
74
- out.push(a)
75
- }
76
- return out
77
- }
16
+ const COMMANDS = ['take', 'status', 'guide']
17
+
18
+ // collect 搬家提示。能力是**搬走了不是删了**,所以不能只说「未知命令」
19
+ const MOVED_HINT =
20
+ `collect 已拆成独立命令 —— 它是常驻服务,不该和在场取用挤在一个入口:\n` +
21
+ ` lark-relay-collect # 前台跑(调试)\n` +
22
+ ` lark-relay-collect --help # 参数与常驻部署\n` +
23
+ `同一个包就带,不用另外装。\n`
78
24
 
79
- const USAGE = `lark-relay ${VERSION} —— Lark 事件中继站
25
+ const USAGE = `lark-relay ${VERSION} —— Lark 事件中继站(在场取用)
80
26
 
81
- lark-relay collect 底座:全部 profile 各起 consume → 原子落盘(常驻服务)
82
27
  lark-relay take … 在场取用:阻塞等一批 → 输出 → 退出
83
28
  lark-relay status 谁在跑 / 各 app 积压 / 各游标位置
84
29
  lark-relay guide 一页用法(装完先读这个)
85
30
 
31
+ 采集底座是独立命令(常驻服务):lark-relay-collect
32
+
86
33
  第一次用 → lark-relay guide
87
34
  各命令详情 → lark-relay <命令> --help`
88
35
 
@@ -104,37 +51,34 @@ async function main() {
104
51
  case 'guide':
105
52
  process.stdout.write(`${help.GUIDE}\n`)
106
53
  return 0
107
- case 'collect':
108
- return await cmdCollect(rest)
109
54
  case 'take':
110
55
  return await cmdTake(rest)
111
56
  case 'status':
112
57
  return await cmdStatus(rest)
58
+ // collect 拆成了独立可执行文件,这里专门拦一下告诉人改敲什么。
59
+ // 不能落到 default 分支:suggest('collect', ['take','status','guide']) 必然返回
60
+ // null(编辑距离都 >2、前缀 coll 也不匹配),只会打出干巴巴的「未知命令:collect」--
61
+ // 那会让人以为采集功能被删了。**能力是搬家,不是消失,提示必须给出新名字**
62
+ case 'collect':
63
+ process.stderr.write(MOVED_HINT)
64
+ return 2
113
65
  default: {
114
- const s = suggest(cmd, ['collect', 'take', 'status', 'guide'])
66
+ // 拼错 collect(collectt / colect)同样走搬家提示 —— 意图明显是采集,
67
+ // 给「未知命令」等于让人以为功能没了。判据:编辑距离 <=2 且不像任何现有命令
68
+ if (cmd !== 'collect' && suggest(cmd, ['collect']) === 'collect' && !suggest(cmd, COMMANDS)) {
69
+ process.stderr.write(`未知命令:${cmd}。${MOVED_HINT}`)
70
+ return 2
71
+ }
72
+ const s = suggest(cmd, COMMANDS)
115
73
  process.stderr.write(`未知命令:${cmd}${s ? `。你是不是想写 ${s}?` : ''}\n\n${USAGE}\n`)
116
74
  return 2
117
75
  }
118
76
  }
119
77
  }
120
78
 
121
- async function cmdCollect(argv) {
122
- const a = parseArgs(argv, { flags: ['help'], keys: COLLECT_KEYS })
123
- if (a._unknown.length) return rejectUnknown('collect', argv, a._unknown, COLLECT_KEYS)
124
- if (a.help) {
125
- process.stdout.write(`${help.COLLECT_HELP}\n`)
126
- return 0
127
- }
128
- const { runCollect } = require('../lib/collect')
129
- return await runCollect({
130
- exclude: list(a.exclude),
131
- retain: num(a.retain, 3),
132
- })
133
- }
134
-
135
79
  async function cmdTake(argv) {
136
80
  const a = parseArgs(argv, { flags: ['help'], keys: TAKE_KEYS })
137
- if (a._unknown.length) return rejectUnknown('take', argv, a._unknown, TAKE_KEYS)
81
+ if (a._unknown.length) return rejectUnknown('lark-relay take', argv, a._unknown, TAKE_KEYS)
138
82
  const larkcli = require('../lib/larkcli')
139
83
 
140
84
  // 空参数输出完整照做指导,而非报错 -- AI 的真实行为是「先空参数跑一下看看」。
@@ -231,7 +175,7 @@ async function cmdTake(argv) {
231
175
 
232
176
  async function cmdStatus(argv) {
233
177
  const a = parseArgs(argv, { flags: ['json', 'help'], keys: [] })
234
- if (a._unknown.length) return rejectUnknown('status', argv, a._unknown, ['json'])
178
+ if (a._unknown.length) return rejectUnknown('lark-relay status', argv, a._unknown, ['json'])
235
179
  if (a.help) {
236
180
  process.stdout.write(`${help.STATUS_HELP}\n`)
237
181
  return 0
package/lib/args.js CHANGED
@@ -100,4 +100,64 @@ function suggest(unknown, allowed) {
100
100
  return null
101
101
  }
102
102
 
103
- module.exports = { parseArgs, num, list, suggest }
103
+ // 未知参数 -> stderr 短错误 + 候选提示 + 改写后的命令,exit 2。
104
+ // 不打印完整用法 -- 那看着像 --help 成功,会被当成「正常退出但没消息」
105
+ //
106
+ // ⚠️ `prog` 必须由调用方传入,不能写死 'lark-relay':两个 bin 共用这段,
107
+ // 而提示里那行「你是不是想跑这个」是要照抄执行的 —— 打出一个不存在的命令
108
+ // (如已拆走的 `lark-relay collect`)会把人引向死路。
109
+ // lark-relay 传 `lark-relay <子命令>`,lark-relay-collect 传自己的名字。
110
+ function rejectUnknown(prog, argv, unknown, allowed, out = process.stderr) {
111
+ const names = [...new Set(unknown)]
112
+ const shown = names.map((k) => `--${k}`).join(', ')
113
+ let hint = ''
114
+ for (const k of names) {
115
+ const s = suggest(k, allowed)
116
+ if (s) {
117
+ hint = `\n你是不是想写 --${s}?`
118
+ break
119
+ }
120
+ }
121
+ const rewrite = rewriteArgv(argv, names, allowed)
122
+ const tail = rewrite.length ? `\n你是不是想跑这个?\n ${prog} ${rewrite.join(' ')}` : ''
123
+ out.write(`未知参数:${shown}${hint}${tail}\n跑 \`${prog}\` 看完整用法\n`)
124
+ return 2
125
+ }
126
+
127
+ // 把未知参数替换成候选,拼出可照跑的命令行;无候选的未知参数(连同它的值)丢弃
128
+ function rewriteArgv(argv, unknown, allowed) {
129
+ const map = new Map()
130
+ for (const k of unknown) {
131
+ const s = suggest(k, allowed)
132
+ if (s && !map.has(k)) map.set(k, s)
133
+ }
134
+ if (!map.size) return []
135
+ const out = []
136
+ for (let i = 0; i < argv.length; i++) {
137
+ const a = argv[i]
138
+ if (a.startsWith('--') && a !== '--') {
139
+ const eq = a.indexOf('=')
140
+ const k = eq !== -1 ? a.slice(2, eq) : a.slice(2)
141
+ if (map.has(k)) {
142
+ out.push(eq !== -1 ? `--${map.get(k)}=${a.slice(eq + 1)}` : `--${map.get(k)}`)
143
+ if (eq === -1) {
144
+ const next = argv[i + 1]
145
+ if (next !== undefined && !next.startsWith('--')) {
146
+ out.push(next)
147
+ i++
148
+ }
149
+ }
150
+ continue
151
+ }
152
+ if (eq === -1) {
153
+ const next = argv[i + 1]
154
+ if (next !== undefined && !next.startsWith('--')) i++
155
+ }
156
+ continue
157
+ }
158
+ out.push(a)
159
+ }
160
+ return out
161
+ }
162
+
163
+ module.exports = { parseArgs, num, list, suggest, rejectUnknown, rewriteArgv }
package/lib/collect.js CHANGED
@@ -162,7 +162,7 @@ async function runCollect(opts) {
162
162
  }
163
163
 
164
164
  logLine(
165
- `lark-relay collect: ${chosen.length}/${profiles.length} profiles → ${paths.store}\n` +
165
+ `lark-relay-collect: ${chosen.length}/${profiles.length} profiles → ${paths.store}\n` +
166
166
  ` ${chosen.map((p) => p.name).join(' ')}\n` +
167
167
  ` event_key=${EVENT_KEY} store 保留 ${opts.retain}天`,
168
168
  )
package/lib/help.js CHANGED
@@ -52,9 +52,12 @@ function takeHelp(apps) {
52
52
 
53
53
  const COLLECT_HELP = `把全部 lark-cli profile 的事件收下来,原子落盘。零参数、零配置。
54
54
 
55
- lark-relay collect # 前台跑(调试)
55
+ lark-relay-collect # 前台跑(调试)
56
56
  常驻:Linux 用 systemd,macOS 用 launchd(单元模板见仓库 systemd//launchd 目录)
57
57
 
58
+ 独立可执行文件而非 \`lark-relay\` 的子命令 —— 它是常驻服务,服务管理器展示的是
59
+ 可执行文件名,与 launchd label(com.adaex.lark-relay-collect)、systemd 单元名同名才认得出。
60
+
58
61
  app 列表实时读 \`lark-cli profile list\`,新增 profile 自动纳入,无需改配置。
59
62
  每 app 一个子进程;某个挂了单独重启,不影响其他。
60
63
  tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是死循环)。
@@ -92,15 +95,17 @@ collect 行不是 running -> 看服务日志(Linux journalctl;
92
95
  macOS \`tail -f ~/.lark-relay/logs/collect.err.log\` -- 不进统一日志),
93
96
  collect 停摆的每一秒都在丢消息。`
94
97
 
95
- const GUIDE = `lark-relay -- Lark 事件中继站。两个命令:收下来 / 我来取。
98
+ const GUIDE = `lark-relay -- Lark 事件中继站。两件事:收下来 / 我来取。
96
99
 
97
- collect 把事件收下来落盘,take 在你需要时取走一批。两者分离是硬需求:
98
- 消费侧崩了、AI 跑 30 分钟、会话关几小时,采集都不能停 -- 停摆的每一秒都在丢消息。
100
+ \`lark-relay-collect\` 把事件收下来落盘(常驻服务),\`lark-relay take\` 在你需要时取走一批。
101
+ 两者分离是硬需求:消费侧崩了、AI 跑 30 分钟、会话关几小时,采集都不能停 --
102
+ 停摆的每一秒都在丢消息。所以采集是独立的可执行文件,不是本命令的子命令。
99
103
 
100
- 前置:collect 必须在跑
104
+ 前置:采集必须在跑
101
105
  lark-relay status # collect 行应为 running
102
106
  进程不在的时刻消息永久丢失,不是延迟送达。
103
- 不在跑 -> 起常驻服务(Linux systemd / macOS launchd,模板见仓库;或 \`lark-relay collect\` 前台调试)
107
+ 不在跑 -> 起常驻服务(Linux systemd / macOS launchd,模板见仓库;
108
+ 或 \`lark-relay-collect\` 前台调试)
104
109
 
105
110
  take 三步(照做)
106
111
  1. 查群 ID lark-cli --profile <app> im +chat-search --as user --query "<群名>"
package/lib/utf8.js ADDED
@@ -0,0 +1,15 @@
1
+ 'use strict'
2
+
3
+ // UTF-8 兜底:systemd / launchd 环境都无 LANG -> C locale。虽然 Node 的字符串处理
4
+ // 不像 bash 那样按字节切片,但子进程(lark-cli)会继承 locale,中文仍会被切坏。
5
+ //
6
+ // 两个 bin(lark-relay / lark-relay-collect)都要,故抽成模块 —— 复制两份会漂移。
7
+ // 必须在任何 spawn 之前执行,所以两个入口都在最顶上 require 它。
8
+ function ensureUtf8(env = process.env) {
9
+ if (!/utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || '')) {
10
+ env.LANG = 'C.UTF-8'
11
+ env.LC_ALL = 'C.UTF-8'
12
+ }
13
+ }
14
+
15
+ module.exports = { ensureUtf8 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",
@@ -17,7 +17,8 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "bin": {
20
- "lark-relay": "bin/lark-relay.js"
20
+ "lark-relay": "bin/lark-relay.js",
21
+ "lark-relay-collect": "bin/lark-relay-collect.js"
21
22
  },
22
23
  "files": [
23
24
  "bin",