lark-relay 0.4.5 → 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 +26 -68
- package/bin/lark-relay-collect.js +58 -0
- package/bin/lark-relay.js +30 -112
- package/lib/args.js +61 -1
- package/lib/collect.js +35 -26
- package/lib/filter.js +1 -1
- package/lib/help.js +26 -73
- package/lib/lock.js +3 -19
- package/lib/log.js +164 -0
- package/lib/paths.js +0 -4
- package/lib/status.js +16 -42
- package/lib/store.js +1 -24
- package/lib/take.js +2 -2
- package/lib/utf8.js +15 -0
- package/package.json +4 -3
- package/lib/card.js +0 -76
- package/lib/claude.js +0 -231
- package/lib/cot.js +0 -377
- package/lib/dispatch.js +0 -276
- package/lib/reply.js +0 -69
- package/lib/tasks.js +0 -181
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
|
-
|
|
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,32 +16,37 @@ Lark 事件是**流式**的 -- 进程不在的时刻,消息**永久丢失**,不
|
|
|
16
16
|
(实测:消息发出 8 秒后才起 consumer,收到 0 条)。
|
|
17
17
|
|
|
18
18
|
所以要有一个常驻进程只管把事件收下来落盘,消费侧崩了、AI 跑了半小时、
|
|
19
|
-
会话关了几小时,都不丢消息。这就是 `collect`。
|
|
19
|
+
会话关了几小时,都不丢消息。这就是 `lark-relay-collect`。
|
|
20
20
|
|
|
21
|
-
##
|
|
21
|
+
## 两段分离
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
| 短命的 | 消息(来一批处理一批) | AI(每轮新起,跑完就没) |
|
|
27
|
-
| 谁在等谁 | AI 等消息 | 消息等 AI |
|
|
28
|
-
| 控制权 | AI 手里(自己决定何时再取) | 服务手里(决定何时拉起 AI) |
|
|
29
|
-
| 谁能干 | 任何 agent(Claude Code / Codex / 手敲) | 需内置唤起知识 |
|
|
30
|
-
| 配置 | 纯参数,无文件 | `dispatch.yaml` |
|
|
23
|
+
采集与消费必须分离:`lark-relay-collect` 只管把事件收下来落盘,`lark-relay take`
|
|
24
|
+
在你需要时取走一批。消费侧崩了、AI 跑了半小时、会话关了几小时,采集都不能停 --
|
|
25
|
+
停摆的每一秒都在丢消息。
|
|
31
26
|
|
|
32
|
-
|
|
27
|
+
分离到**可执行文件**这一层,不是两个子命令:采集是常驻服务,服务管理器
|
|
28
|
+
(systemd / launchd)展示的是可执行文件名,与单元名同名才认得出是谁。
|
|
29
|
+
|
|
30
|
+
| | |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| 长命的 | AI 会话(人设、上下文连续) |
|
|
33
|
+
| 短命的 | 消息(来一批处理一批) |
|
|
34
|
+
| 谁在等谁 | AI 等消息 |
|
|
35
|
+
| 控制权 | AI 手里(自己决定何时再取) |
|
|
36
|
+
| 谁能干 | 任何 agent(Claude Code / Codex / 手敲) |
|
|
37
|
+
| 配置 | 纯参数,无文件 |
|
|
33
38
|
|
|
34
39
|
## 命令
|
|
35
40
|
|
|
36
41
|
```bash
|
|
37
|
-
lark-relay
|
|
38
|
-
|
|
39
|
-
lark-relay
|
|
42
|
+
lark-relay-collect # 底座:全部 profile 各起 consume -> 原子落盘(常驻服务)
|
|
43
|
+
|
|
44
|
+
lark-relay take … # 阻塞等一批 -> 输出 -> 退出
|
|
40
45
|
lark-relay status # 谁在跑 / 各 app 积压 / 各游标位置
|
|
41
46
|
lark-relay guide # 一页用法
|
|
42
47
|
```
|
|
43
48
|
|
|
44
|
-
### collect
|
|
49
|
+
### lark-relay-collect
|
|
45
50
|
|
|
46
51
|
零参数、零配置。app 列表实时读 `lark-cli profile list`,新增 profile 自动纳入。
|
|
47
52
|
未授权或授权过期的 profile 自动跳过并 warn(永久失败,重试是死循环)。
|
|
@@ -49,7 +54,7 @@ lark-relay guide # 一页用法
|
|
|
49
54
|
每 app 一个子进程,某个挂了单独重启不影响其他。回收在本进程内每小时自查,
|
|
50
55
|
删超期的整个日期目录 -- 不另起 gc 单元/timer。
|
|
51
56
|
|
|
52
|
-
### take
|
|
57
|
+
### lark-relay take
|
|
53
58
|
|
|
54
59
|
只有一种形态:**阻塞等 -> 有一批就输出 -> 退出**。循环交给 harness --
|
|
55
60
|
Claude Code 用 `run_in_background` 起,进程退出会主动通知 AI,这才是事件驱动。
|
|
@@ -65,62 +70,15 @@ lark-relay take --app <app> --chats oc_xxx --render text
|
|
|
65
70
|
|
|
66
71
|
业务过滤只有 `--filter`(jq 表达式)一个口子,不为每个业务加参数。
|
|
67
72
|
|
|
68
|
-
### dispatch
|
|
69
|
-
|
|
70
|
-
任务发现:扫 `$LR_ROOT/*/dispatch.yaml` -- 有文件即是任务,无需注册表。
|
|
71
|
-
`dispatch` 内部就是调 `take`,保证两场景不实现分叉。
|
|
72
|
-
|
|
73
|
-
```yaml
|
|
74
|
-
app: <profile 名>
|
|
75
|
-
chats: [oc_xxx]
|
|
76
|
-
dirs: [/path/to/repo] # 首个 = 主工作目录
|
|
77
|
-
instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
|
|
78
|
-
filter: '.mentions[]?.id == "ou_xxx"' # 可选
|
|
79
|
-
# mode: work 默认;work | chat | agent(见下)
|
|
80
|
-
# model: <名称> 默认走终端同一套默认路由
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
`mode` 三个场景 -- 一个键定死全部行为,不用拼组合:
|
|
84
|
-
|
|
85
|
-
| | `work`(默认) | `chat` | `agent` |
|
|
86
|
-
|---|---|---|---|
|
|
87
|
-
| 用途 | 处理工作 | 简单问答 | 群运营 |
|
|
88
|
-
| 回复位置 | 话题内(首问开话题) | 直接发群里 | **模型自己决定** |
|
|
89
|
-
| 过程展示 | COT 消息(实时流式) | 无 | 无 |
|
|
90
|
-
| 结论投递 | 引擎发卡片 | 引擎发纯文本 | **引擎不发** |
|
|
91
|
-
| session 隔离 | 按 `thread_id` | 按 (群, epoch) | 按 (群, epoch) |
|
|
92
|
-
| 防抖 | 1s | 15s | 15s |
|
|
93
|
-
|
|
94
|
-
`agent` 是给「**多数轮该沉默**」的场景准备的:群运营里绝大多数消息不需要回应,
|
|
95
|
-
而 `work`/`chat` 的提示词都承诺「你的最终回复会被发回群」,等于逼模型每轮说话。
|
|
96
|
-
`agent` 模式下引擎只负责拉起模型、传消息批次、记台账 -- 发不发、回哪条、
|
|
97
|
-
用哪个 bot 身份、发文字还是表情回应,全由模型按 `instructions` 决定(它有
|
|
98
|
-
Bash + lark-cli)。沉默的轮也会进台账,便于事后看它到底判了多少次。
|
|
99
|
-
|
|
100
|
-
这与「引擎不做权限管控」是同一条思路:**引擎不做回复决策,边界靠 instructions
|
|
101
|
-
到达模型侧**。
|
|
102
|
-
|
|
103
|
-
`work` 的过程与结论是**两条消息** -- COT 消息只承载过程(接口的设计前提),
|
|
104
|
-
结论另发一条卡片。卡片发送失败会自动降级纯文本,保证结论必达。
|
|
105
|
-
|
|
106
|
-
⚠️ **COT 要求客户端 PC ≥ 7.70 / 移动 ≥ 7.74**:老客户端上那条过程消息显示为
|
|
107
|
-
「Completed」(不会崩),结论卡片不受影响 -- 这也是「结论单独发」的价值。
|
|
108
|
-
|
|
109
|
-
环境变量:`LR_COT_BATCH_MS`(COT 攒批窗口,默认 1000)、
|
|
110
|
-
`LR_COT_SAY_AS=text|reasoning`(中间文本走正式文本流还是思考流,默认 `text`;
|
|
111
|
-
实测两者渲染一致,`text` 每段少 2 个事件)。
|
|
112
|
-
|
|
113
73
|
## 存储布局
|
|
114
74
|
|
|
115
75
|
```
|
|
116
76
|
~/.lark-relay/
|
|
117
77
|
store/<app>/<YYYY-MM-DD>/<纳秒>_<pid>_<seq>.json 原始事件(可随时删,丢了自愈)
|
|
118
78
|
cursors/<name>/<app> 各消费者游标
|
|
119
|
-
ledger/<task>__<chat>.ndjson dispatch 结论台账
|
|
120
79
|
```
|
|
121
80
|
|
|
122
|
-
|
|
123
|
-
(session 会因空闲滚动/过期丢上下文,结论不能丢),走文件系统级备份。
|
|
81
|
+
判据:能随时删掉重建的才放这里 -- 上面两样都能。
|
|
124
82
|
|
|
125
83
|
多消费者**共享 store、各持游标**,不做「处理完即删」-- 谁都不能替别人删。
|
|
126
84
|
盯同一 app 请用不同 `--name` 隔离游标。
|
|
@@ -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,89 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
|
-
|
|
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,
|
|
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
|
-
//
|
|
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', 'ledger-retain']
|
|
21
15
|
|
|
22
|
-
|
|
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
|
-
}
|
|
16
|
+
const COMMANDS = ['take', 'status', 'guide']
|
|
42
17
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
}
|
|
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
|
|
82
|
-
lark-relay take … 场景1 在场取用:阻塞等一批 → 输出 → 退出
|
|
83
|
-
lark-relay dispatch <task> 场景2 托管派活:常驻循环 = take + 唤起 claude
|
|
27
|
+
lark-relay take … 在场取用:阻塞等一批 → 输出 → 退出
|
|
84
28
|
lark-relay status 谁在跑 / 各 app 积压 / 各游标位置
|
|
85
29
|
lark-relay guide 一页用法(装完先读这个)
|
|
86
30
|
|
|
31
|
+
采集底座是独立命令(常驻服务):lark-relay-collect
|
|
32
|
+
|
|
87
33
|
第一次用 → lark-relay guide
|
|
88
34
|
各命令详情 → lark-relay <命令> --help`
|
|
89
35
|
|
|
@@ -105,40 +51,34 @@ async function main() {
|
|
|
105
51
|
case 'guide':
|
|
106
52
|
process.stdout.write(`${help.GUIDE}\n`)
|
|
107
53
|
return 0
|
|
108
|
-
case 'collect':
|
|
109
|
-
return await cmdCollect(rest)
|
|
110
54
|
case 'take':
|
|
111
55
|
return await cmdTake(rest)
|
|
112
56
|
case 'status':
|
|
113
57
|
return await cmdStatus(rest)
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
116
65
|
default: {
|
|
117
|
-
|
|
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)
|
|
118
73
|
process.stderr.write(`未知命令:${cmd}${s ? `。你是不是想写 ${s}?` : ''}\n\n${USAGE}\n`)
|
|
119
74
|
return 2
|
|
120
75
|
}
|
|
121
76
|
}
|
|
122
77
|
}
|
|
123
78
|
|
|
124
|
-
async function cmdCollect(argv) {
|
|
125
|
-
const a = parseArgs(argv, { flags: ['help'], keys: COLLECT_KEYS })
|
|
126
|
-
if (a._unknown.length) return rejectUnknown('collect', argv, a._unknown, COLLECT_KEYS)
|
|
127
|
-
if (a.help) {
|
|
128
|
-
process.stdout.write(`${help.COLLECT_HELP}\n`)
|
|
129
|
-
return 0
|
|
130
|
-
}
|
|
131
|
-
const { runCollect } = require('../lib/collect')
|
|
132
|
-
return await runCollect({
|
|
133
|
-
exclude: list(a.exclude),
|
|
134
|
-
retain: num(a.retain, 3),
|
|
135
|
-
ledgerRetain: num(a['ledger-retain'], 90),
|
|
136
|
-
})
|
|
137
|
-
}
|
|
138
|
-
|
|
139
79
|
async function cmdTake(argv) {
|
|
140
80
|
const a = parseArgs(argv, { flags: ['help'], keys: TAKE_KEYS })
|
|
141
|
-
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)
|
|
142
82
|
const larkcli = require('../lib/larkcli')
|
|
143
83
|
|
|
144
84
|
// 空参数输出完整照做指导,而非报错 -- AI 的真实行为是「先空参数跑一下看看」。
|
|
@@ -235,7 +175,7 @@ async function cmdTake(argv) {
|
|
|
235
175
|
|
|
236
176
|
async function cmdStatus(argv) {
|
|
237
177
|
const a = parseArgs(argv, { flags: ['json', 'help'], keys: [] })
|
|
238
|
-
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'])
|
|
239
179
|
if (a.help) {
|
|
240
180
|
process.stdout.write(`${help.STATUS_HELP}\n`)
|
|
241
181
|
return 0
|
|
@@ -247,28 +187,6 @@ async function cmdStatus(argv) {
|
|
|
247
187
|
return 0
|
|
248
188
|
}
|
|
249
189
|
|
|
250
|
-
async function cmdDispatch(argv) {
|
|
251
|
-
const a = parseArgs(argv, { flags: ['list', 'once', 'help'], keys: [] })
|
|
252
|
-
if (a._unknown.length) return rejectUnknown('dispatch', argv, a._unknown, ['list', 'once'])
|
|
253
|
-
if (a.help) {
|
|
254
|
-
process.stdout.write(`${help.DISPATCH_HELP}\n`)
|
|
255
|
-
return 0
|
|
256
|
-
}
|
|
257
|
-
const dispatch = require('../lib/dispatch')
|
|
258
|
-
if (a.list) return dispatch.cmdList()
|
|
259
|
-
const task = a._[0]
|
|
260
|
-
if (!task) {
|
|
261
|
-
// 空参 = 求教,和 take 一致(exit 0);带了 flag 却没任务名是写错,短错误
|
|
262
|
-
if (argv.length === 0) {
|
|
263
|
-
process.stdout.write(`${help.DISPATCH_HELP}\n`)
|
|
264
|
-
return 0
|
|
265
|
-
}
|
|
266
|
-
process.stderr.write('需要任务名($LR_ROOT/<任务名>/dispatch.yaml)。看现有任务:`lark-relay dispatch --list`\n')
|
|
267
|
-
return 2
|
|
268
|
-
}
|
|
269
|
-
return await dispatch.run(task, { once: !!a.once })
|
|
270
|
-
}
|
|
271
|
-
|
|
272
190
|
main()
|
|
273
191
|
.then((code) => process.exit(code || 0))
|
|
274
192
|
.catch((err) => {
|
package/lib/args.js
CHANGED
|
@@ -100,4 +100,64 @@ function suggest(unknown, allowed) {
|
|
|
100
100
|
return null
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
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
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
//
|
|
6
6
|
// 为什么必须独立常驻:lark 事件是流式的,进程不在的时刻消息永久丢失
|
|
7
7
|
// (实测:消息发出 8s 后才起 consumer,收到 0 条)。collect 独立常驻,
|
|
8
|
-
// 才能让 take
|
|
8
|
+
// 才能让 take 侧崩了、AI 跑 30 分钟、会话关几小时,都不丢消息。
|
|
9
9
|
const readline = require('readline')
|
|
10
10
|
const larkcli = require('./larkcli')
|
|
11
11
|
const store = require('./store')
|
|
12
12
|
const { paths, ensureDir } = require('./paths')
|
|
13
|
+
const { init: logInit, logLine } = require('./log')
|
|
13
14
|
|
|
14
15
|
const EVENT_KEY = process.env.LARK_RELAY_EVENT_KEY || 'im.message.receive_v1'
|
|
15
16
|
const GC_INTERVAL_MS = 3600_000
|
|
@@ -45,14 +46,14 @@ class AppCollector {
|
|
|
45
46
|
try {
|
|
46
47
|
obj = JSON.parse(s)
|
|
47
48
|
} catch {
|
|
48
|
-
|
|
49
|
+
logLine(`[${this.app}] warn: 非 JSON 行,已跳过`)
|
|
49
50
|
return
|
|
50
51
|
}
|
|
51
52
|
try {
|
|
52
53
|
store.writeEvent(this.app, obj, this.seq++)
|
|
53
54
|
this.received++
|
|
54
55
|
} catch (err) {
|
|
55
|
-
|
|
56
|
+
logLine(`[${this.app}] error: 落盘失败 ${err.message}`)
|
|
56
57
|
}
|
|
57
58
|
})
|
|
58
59
|
|
|
@@ -63,10 +64,17 @@ class AppCollector {
|
|
|
63
64
|
this.backoffMs = 1000
|
|
64
65
|
}, 30_000)
|
|
65
66
|
|
|
66
|
-
// 不加 --quiet:它会隐藏事件丢失。stderr 的 ready/exit/drop
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
// 不加 --quiet:它会隐藏事件丢失。stderr 的 ready/exit/drop 诊断要留下。
|
|
68
|
+
//
|
|
69
|
+
// ⚠️ 必须走 readline 按行读,不能裸接 'data' 事件(改回去会同时带回三个 bug):
|
|
70
|
+
// ① 一个 chunk 常含多行 -> 只有块首能拿到 [app] 前缀与时间戳,其余行无法归属
|
|
71
|
+
// ② chunk 边界任意切,一条诊断可能被拆成两次 write -> 变成两条独立记录
|
|
72
|
+
// ③ UTF-8 多字节被 chunk 边界切断 -> 中文诊断出现替换字符
|
|
73
|
+
// readline 内部用 StringDecoder 处理跨块残字节,故 stdout 路径从来没这些问题。
|
|
74
|
+
const erl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity })
|
|
75
|
+
erl.on('line', (line) => {
|
|
76
|
+
const text = line.trimEnd()
|
|
77
|
+
if (text) logLine(`[${this.app}] ${text}`)
|
|
70
78
|
})
|
|
71
79
|
|
|
72
80
|
// spawn 失败(lark-cli 被卸载 / 升级中途 / PATH 变化)只发 error+close,**没有 exit**。
|
|
@@ -78,9 +86,9 @@ class AppCollector {
|
|
|
78
86
|
clearTimeout(this.healthyTimer)
|
|
79
87
|
this.child = null
|
|
80
88
|
this.restarts++
|
|
81
|
-
|
|
89
|
+
logLine(
|
|
82
90
|
`[${this.app}] consume 退出(${why}),${Math.round(this.backoffMs / 1000)}s 后重启 ` +
|
|
83
|
-
`—— 这段时间该 app
|
|
91
|
+
`—— 这段时间该 app 的消息会永久丢失`,
|
|
84
92
|
)
|
|
85
93
|
// 每 app 一个子进程;某个挂了单独重启,不影响其他
|
|
86
94
|
setTimeout(() => this.start(), this.backoffMs)
|
|
@@ -92,7 +100,7 @@ class AppCollector {
|
|
|
92
100
|
})
|
|
93
101
|
|
|
94
102
|
child.on('error', (err) => {
|
|
95
|
-
|
|
103
|
+
logLine(`[${this.app}] spawn 失败:${err.message}`)
|
|
96
104
|
scheduleRestart(`spawn error ${err.code || err.message}`)
|
|
97
105
|
})
|
|
98
106
|
}
|
|
@@ -108,22 +116,25 @@ class AppCollector {
|
|
|
108
116
|
async function runCollect(opts) {
|
|
109
117
|
ensureDir(paths.store)
|
|
110
118
|
ensureDir(paths.cursors)
|
|
111
|
-
|
|
119
|
+
// 日志接管要最早做 —— 下面的启动横幅、profile 报错都该带上时间戳。
|
|
120
|
+
// 非 launchd 场景(journald / 前台)是 no-op,logLine 退化成裸 stderr 直写
|
|
121
|
+
logInit()
|
|
112
122
|
|
|
113
123
|
const exclude = new Set(opts.exclude || [])
|
|
114
124
|
let profiles
|
|
115
125
|
try {
|
|
116
126
|
profiles = await larkcli.listProfiles()
|
|
117
127
|
} catch (err) {
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
// 不抛给 bin 的顶层 catch —— 那行是所有命令共用的裸 stderr,
|
|
129
|
+
// 会让「collect 崩了」这条最关键的记录反而没有时间戳
|
|
130
|
+
logLine(
|
|
131
|
+
`error: 读不到 lark-cli profile 列表:${(err.message || '').split('\n')[0]}\n` +
|
|
120
132
|
`collect 依赖 lark-cli 提供认证与事件总线。检查:\n` +
|
|
121
133
|
` which ${larkcli.CLI} # 装了吗、在 PATH 里吗\n` +
|
|
122
134
|
` ${larkcli.CLI} profile list # 能跑吗\n` +
|
|
123
135
|
`常驻服务(systemd 单元/launchd plist)的 PATH 要用 fnm default alias 的稳定路径,不能用会话级的 multishell 路径`,
|
|
124
136
|
)
|
|
125
|
-
|
|
126
|
-
throw e
|
|
137
|
+
return 1
|
|
127
138
|
}
|
|
128
139
|
|
|
129
140
|
const chosen = []
|
|
@@ -143,17 +154,17 @@ async function runCollect(opts) {
|
|
|
143
154
|
}
|
|
144
155
|
|
|
145
156
|
if (skipped.length) {
|
|
146
|
-
|
|
157
|
+
logLine(`warn: 跳过 ${skipped.join(' ')}`)
|
|
147
158
|
}
|
|
148
159
|
if (!chosen.length) {
|
|
149
|
-
|
|
160
|
+
logLine('error: 没有可用 profile。先 `lark-cli auth login`')
|
|
150
161
|
return 1
|
|
151
162
|
}
|
|
152
163
|
|
|
153
|
-
|
|
154
|
-
`lark-relay
|
|
164
|
+
logLine(
|
|
165
|
+
`lark-relay-collect: ${chosen.length}/${profiles.length} profiles → ${paths.store}\n` +
|
|
155
166
|
` ${chosen.map((p) => p.name).join(' ')}\n` +
|
|
156
|
-
` event_key=${EVENT_KEY} store 保留 ${opts.retain}
|
|
167
|
+
` event_key=${EVENT_KEY} store 保留 ${opts.retain}天`,
|
|
157
168
|
)
|
|
158
169
|
|
|
159
170
|
const collectors = chosen.map((p) => new AppCollector(p.name, opts))
|
|
@@ -164,18 +175,16 @@ async function runCollect(opts) {
|
|
|
164
175
|
const gcTimer = setInterval(() => {
|
|
165
176
|
try {
|
|
166
177
|
const s = store.gcStore(opts.retain)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
process.stderr.write(`gc: 删 store ${s.length} 个日期目录, ledger ${l.length} 个文件\n`)
|
|
178
|
+
if (s.length) {
|
|
179
|
+
logLine(`gc: 删 store ${s.length} 个日期目录`)
|
|
170
180
|
}
|
|
171
181
|
} catch (err) {
|
|
172
|
-
|
|
182
|
+
logLine(`gc: 失败 ${err.message}`)
|
|
173
183
|
}
|
|
174
184
|
}, GC_INTERVAL_MS)
|
|
175
185
|
// 启动时先跑一次,别等一小时
|
|
176
186
|
try {
|
|
177
187
|
store.gcStore(opts.retain)
|
|
178
|
-
store.gcLedger(opts.ledgerRetain)
|
|
179
188
|
} catch {}
|
|
180
189
|
|
|
181
190
|
return await new Promise((resolve) => {
|
|
@@ -183,7 +192,7 @@ async function runCollect(opts) {
|
|
|
183
192
|
const shutdown = (sig) => {
|
|
184
193
|
if (shuttingDown) return
|
|
185
194
|
shuttingDown = true
|
|
186
|
-
|
|
195
|
+
logLine(`收到 ${sig},停止 ${collectors.length} 个 consumer`)
|
|
187
196
|
clearInterval(gcTimer)
|
|
188
197
|
for (const c of collectors) c.stop()
|
|
189
198
|
// 给子进程时间卸载服务端订阅
|
package/lib/filter.js
CHANGED
|
@@ -37,7 +37,7 @@ function applyFilter(events, expr) {
|
|
|
37
37
|
maxBuffer: 64 * 1024 * 1024,
|
|
38
38
|
})
|
|
39
39
|
} catch (err) {
|
|
40
|
-
// 保守放行 + warn,不抛 ——
|
|
40
|
+
// 保守放行 + warn,不抛 —— 抛出去会让常驻消费者崩溃循环:
|
|
41
41
|
// 表达式对某类事件报错(如 `.mentions[].id` 遇到没有 mentions 的消息)时,
|
|
42
42
|
// 那条消息永远卡在队首,游标推不动,systemd 每 5s 重启一次,该任务彻底停摆。
|
|
43
43
|
// 放行的代价是模型多看几条本该滤掉的消息,远小于停摆
|