lark-relay 0.4.0 → 0.4.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 +18 -15
- package/bin/lark-relay.js +91 -12
- package/lib/args.js +55 -4
- package/lib/help.js +63 -51
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ lark-relay guide # 装完先读这个
|
|
|
12
12
|
|
|
13
13
|
## 为什么需要它
|
|
14
14
|
|
|
15
|
-
Lark 事件是**流式**的
|
|
15
|
+
Lark 事件是**流式**的 -- 进程不在的时刻,消息**永久丢失**,不是延迟送达
|
|
16
16
|
(实测:消息发出 8 秒后才起 consumer,收到 0 条)。
|
|
17
17
|
|
|
18
18
|
所以要有一个常驻进程只管把事件收下来落盘,消费侧崩了、AI 跑了半小时、
|
|
@@ -29,13 +29,13 @@ Lark 事件是**流式**的 —— 进程不在的时刻,消息**永久丢失**,
|
|
|
29
29
|
| 谁能干 | 任何 agent(Claude Code / Codex / 手敲) | 需内置唤起知识 |
|
|
30
30
|
| 配置 | 纯参数,无文件 | `dispatch.yaml` |
|
|
31
31
|
|
|
32
|
-
你(AI)在会话里盯群
|
|
32
|
+
你(AI)在会话里盯群 -> `take`。无人在场也要干活 -> `dispatch`。
|
|
33
33
|
|
|
34
34
|
## 命令
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
lark-relay collect # 底座:全部 profile 各起 consume
|
|
38
|
-
lark-relay take … # 场景1:阻塞等一批
|
|
37
|
+
lark-relay collect # 底座:全部 profile 各起 consume -> 原子落盘(systemd 常驻)
|
|
38
|
+
lark-relay take … # 场景1:阻塞等一批 -> 输出 -> 退出
|
|
39
39
|
lark-relay dispatch <task> # 场景2:常驻循环 = take + 唤起 claude
|
|
40
40
|
lark-relay status # 谁在跑 / 各 app 积压 / 各游标位置
|
|
41
41
|
lark-relay guide # 一页用法
|
|
@@ -47,24 +47,27 @@ lark-relay guide # 一页用法
|
|
|
47
47
|
未授权或授权过期的 profile 自动跳过并 warn(永久失败,重试是死循环)。
|
|
48
48
|
|
|
49
49
|
每 app 一个子进程,某个挂了单独重启不影响其他。回收在本进程内每小时自查,
|
|
50
|
-
删超期的整个日期目录
|
|
50
|
+
删超期的整个日期目录 -- 不另起 gc 单元/timer。
|
|
51
51
|
|
|
52
52
|
### take
|
|
53
53
|
|
|
54
|
-
只有一种形态:**阻塞等
|
|
54
|
+
只有一种形态:**阻塞等 -> 有一批就输出 -> 退出**。循环交给 harness --
|
|
55
55
|
Claude Code 用 `run_in_background` 起,进程退出会主动通知 AI,这才是事件驱动。
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
58
|
lark-relay take --app <app> --chats oc_xxx --render text
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
空参数跑一下会输出完整的照做指导(查群 ID
|
|
61
|
+
空参数跑一下会输出完整的照做指导(查群 ID -> 验 bot 在群 -> 监听三步),exit 0。
|
|
62
|
+
起来后 stderr 先打一行「监听中」-- 没这行就是没起来,看 stderr 报错。
|
|
63
|
+
退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
|
|
64
|
+
参数拼错(如 `--chat-id`)会硬失败并给出改写后的命令,不静默吞掉。
|
|
62
65
|
|
|
63
66
|
业务过滤只有 `--filter`(jq 表达式)一个口子,不为每个业务加参数。
|
|
64
67
|
|
|
65
68
|
### dispatch
|
|
66
69
|
|
|
67
|
-
任务发现:扫 `$LR_ROOT/*/dispatch.yaml`
|
|
70
|
+
任务发现:扫 `$LR_ROOT/*/dispatch.yaml` -- 有文件即是任务,无需注册表。
|
|
68
71
|
`dispatch` 内部就是调 `take`,保证两场景不实现分叉。
|
|
69
72
|
|
|
70
73
|
```yaml
|
|
@@ -77,7 +80,7 @@ filter: '.mentions[]?.id == "ou_xxx"' # 可选
|
|
|
77
80
|
# model: <名称> 默认走终端同一套默认路由
|
|
78
81
|
```
|
|
79
82
|
|
|
80
|
-
`mode` 三个场景
|
|
83
|
+
`mode` 三个场景 -- 一个键定死全部行为,不用拼组合:
|
|
81
84
|
|
|
82
85
|
| | `work`(默认) | `chat` | `agent` |
|
|
83
86
|
|---|---|---|---|
|
|
@@ -90,18 +93,18 @@ filter: '.mentions[]?.id == "ou_xxx"' # 可选
|
|
|
90
93
|
|
|
91
94
|
`agent` 是给「**多数轮该沉默**」的场景准备的:群运营里绝大多数消息不需要回应,
|
|
92
95
|
而 `work`/`chat` 的提示词都承诺「你的最终回复会被发回群」,等于逼模型每轮说话。
|
|
93
|
-
`agent` 模式下引擎只负责拉起模型、传消息批次、记台账
|
|
96
|
+
`agent` 模式下引擎只负责拉起模型、传消息批次、记台账 -- 发不发、回哪条、
|
|
94
97
|
用哪个 bot 身份、发文字还是表情回应,全由模型按 `instructions` 决定(它有
|
|
95
98
|
Bash + lark-cli)。沉默的轮也会进台账,便于事后看它到底判了多少次。
|
|
96
99
|
|
|
97
100
|
这与「引擎不做权限管控」是同一条思路:**引擎不做回复决策,边界靠 instructions
|
|
98
101
|
到达模型侧**。
|
|
99
102
|
|
|
100
|
-
`work` 的过程与结论是**两条消息**
|
|
103
|
+
`work` 的过程与结论是**两条消息** -- COT 消息只承载过程(接口的设计前提),
|
|
101
104
|
结论另发一条卡片。卡片发送失败会自动降级纯文本,保证结论必达。
|
|
102
105
|
|
|
103
106
|
⚠️ **COT 要求客户端 PC ≥ 7.70 / 移动 ≥ 7.74**:老客户端上那条过程消息显示为
|
|
104
|
-
「Completed」(不会崩),结论卡片不受影响
|
|
107
|
+
「Completed」(不会崩),结论卡片不受影响 -- 这也是「结论单独发」的价值。
|
|
105
108
|
|
|
106
109
|
环境变量:`LR_COT_BATCH_MS`(COT 攒批窗口,默认 1000)、
|
|
107
110
|
`LR_COT_SAY_AS=text|reasoning`(中间文本走正式文本流还是思考流,默认 `text`;
|
|
@@ -119,13 +122,13 @@ Bash + lark-cli)。沉默的轮也会进台账,便于事后看它到底判了多
|
|
|
119
122
|
判据:能随时删掉重建的才放这里。`ledger` 是唯一不能重建的东西
|
|
120
123
|
(session 会因空闲滚动/过期丢上下文,结论不能丢),走文件系统级备份。
|
|
121
124
|
|
|
122
|
-
多消费者**共享 store
|
|
125
|
+
多消费者**共享 store、各持游标**,不做「处理完即删」-- 谁都不能替别人删。
|
|
123
126
|
盯同一 app 请用不同 `--name` 隔离游标。
|
|
124
127
|
|
|
125
128
|
## 设计取舍
|
|
126
129
|
|
|
127
|
-
- **原子落盘**:collect 读 stdout NDJSON 后自己 `写 .tmp
|
|
128
|
-
不用 `--output-dir`
|
|
130
|
+
- **原子落盘**:collect 读 stdout NDJSON 后自己 `写 .tmp -> rename`(同目录原子)。
|
|
131
|
+
不用 `--output-dir` -- 它先建 0 字节再填充,消费侧游标可能跨过半成品导致事件永久丢失
|
|
129
132
|
- **一事件一文件 + 按天分目录**:不用追加式 NDJSON。因为多消费者共享 store 时
|
|
130
133
|
两种方案都不能「处理完即删」,NDJSON 最大的优势(删除简单)失效,而它的 gc
|
|
131
134
|
要按大小滚动 + 保留 N 个文件,反而比整目录删复杂
|
package/bin/lark-relay.js
CHANGED
|
@@ -8,11 +8,74 @@ if (!/utf-?8/i.test(process.env.LC_ALL || process.env.LC_CTYPE || process.env.LA
|
|
|
8
8
|
process.env.LC_ALL = 'C.UTF-8'
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
const { parseArgs, num, list } = require('../lib/args')
|
|
11
|
+
const { parseArgs, num, list, suggest } = require('../lib/args')
|
|
12
12
|
const help = require('../lib/help')
|
|
13
13
|
|
|
14
14
|
const VERSION = require('../package.json').version
|
|
15
15
|
|
|
16
|
+
// 各命令的带值参数白名单(布尔开关在 parseArgs 的 flags 里)。
|
|
17
|
+
// 不在白名单的 --xxx 一律硬报错 —— 拼错参数静默吞掉是实测事故:
|
|
18
|
+
// --chat-id 拼错 -> 打印用法退出,看着像「正常退出但没消息」,漏看 bot 第一条 Working
|
|
19
|
+
const TAKE_KEYS = ['app', 'chats', 'name', 'filter', 'debounce', 'max-wait', 'timeout', 'render', 'since']
|
|
20
|
+
const COLLECT_KEYS = ['exclude', 'retain', 'ledger-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
|
+
|
|
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
|
+
}
|
|
78
|
+
|
|
16
79
|
const USAGE = `lark-relay ${VERSION} —— Lark 事件中继站
|
|
17
80
|
|
|
18
81
|
lark-relay collect 底座:全部 profile 各起 consume → 原子落盘(systemd 常驻)
|
|
@@ -50,14 +113,17 @@ async function main() {
|
|
|
50
113
|
return await cmdStatus(rest)
|
|
51
114
|
case 'dispatch':
|
|
52
115
|
return await cmdDispatch(rest)
|
|
53
|
-
default:
|
|
54
|
-
|
|
116
|
+
default: {
|
|
117
|
+
const s = suggest(cmd, ['collect', 'take', 'dispatch', 'status', 'guide'])
|
|
118
|
+
process.stderr.write(`未知命令:${cmd}${s ? `。你是不是想写 ${s}?` : ''}\n\n${USAGE}\n`)
|
|
55
119
|
return 2
|
|
120
|
+
}
|
|
56
121
|
}
|
|
57
122
|
}
|
|
58
123
|
|
|
59
124
|
async function cmdCollect(argv) {
|
|
60
|
-
const a = parseArgs(argv, { flags: ['help'] })
|
|
125
|
+
const a = parseArgs(argv, { flags: ['help'], keys: COLLECT_KEYS })
|
|
126
|
+
if (a._unknown.length) return rejectUnknown('collect', argv, a._unknown, COLLECT_KEYS)
|
|
61
127
|
if (a.help) {
|
|
62
128
|
process.stdout.write(`${help.COLLECT_HELP}\n`)
|
|
63
129
|
return 0
|
|
@@ -71,17 +137,20 @@ async function cmdCollect(argv) {
|
|
|
71
137
|
}
|
|
72
138
|
|
|
73
139
|
async function cmdTake(argv) {
|
|
74
|
-
const a = parseArgs(argv, { flags: ['help'] })
|
|
140
|
+
const a = parseArgs(argv, { flags: ['help'], keys: TAKE_KEYS })
|
|
141
|
+
if (a._unknown.length) return rejectUnknown('take', argv, a._unknown, TAKE_KEYS)
|
|
75
142
|
const larkcli = require('../lib/larkcli')
|
|
76
143
|
|
|
77
|
-
// 空参数输出完整照做指导,而非报错
|
|
78
|
-
|
|
144
|
+
// 空参数输出完整照做指导,而非报错 -- AI 的真实行为是「先空参数跑一下看看」。
|
|
145
|
+
// 但带了参数又缺 app/chats 的走下面短错误 -- 打印完整用法和 --help 长得一样,
|
|
146
|
+
// 会被当成「正常退出但没消息」(实测漏看 bot 第一条 Working)。
|
|
147
|
+
if (a.help || argv.length === 0) {
|
|
79
148
|
let apps = []
|
|
80
149
|
try {
|
|
81
150
|
apps = (await larkcli.listProfiles()).filter((p) => p.usable).map((p) => p.name)
|
|
82
151
|
} catch {}
|
|
83
152
|
process.stdout.write(`${help.takeHelp(apps)}\n`)
|
|
84
|
-
return
|
|
153
|
+
return 0 // 空参 = 求教,和 --help 一样是成功路径
|
|
85
154
|
}
|
|
86
155
|
|
|
87
156
|
const store = require('../lib/store')
|
|
@@ -90,7 +159,7 @@ async function cmdTake(argv) {
|
|
|
90
159
|
const { acquireExclusive } = require('../lib/lock')
|
|
91
160
|
|
|
92
161
|
if (!a.app || a.app === true) {
|
|
93
|
-
process.stderr.write('需要 --app <name
|
|
162
|
+
process.stderr.write('需要 --app <name>(= lark-cli profile 名)。完整步骤跑 `lark-relay take`\n')
|
|
94
163
|
return 2
|
|
95
164
|
}
|
|
96
165
|
const chats = parseChats(a.chats === true ? '' : a.chats)
|
|
@@ -129,6 +198,14 @@ async function cmdTake(argv) {
|
|
|
129
198
|
const maxWaitMs = num(a['max-wait'], 25) * 1000
|
|
130
199
|
const timeoutMs = num(a.timeout, 12 * 3600) * 1000
|
|
131
200
|
|
|
201
|
+
// 起来先打一行「监听中」-- 没这行就是没起来。
|
|
202
|
+
// 反馈实测:进程静默退出(参数错)被当成「在监听」,漏看 bot 第一条 Working
|
|
203
|
+
const fmtDur = (ms) => (ms >= 3600000 ? `${ms / 3600000}h` : `${ms / 1000}s`)
|
|
204
|
+
process.stderr.write(
|
|
205
|
+
`监听中:app=${a.app} 群=${chats.length} 个(${chats.join(',')}) 游标=${name} ` +
|
|
206
|
+
`防抖=${debounceMs / 1000}s 最长等=${fmtDur(timeoutMs)}\n`,
|
|
207
|
+
)
|
|
208
|
+
|
|
132
209
|
const ac = new AbortController()
|
|
133
210
|
const onSig = () => ac.abort()
|
|
134
211
|
process.on('SIGTERM', onSig)
|
|
@@ -146,7 +223,7 @@ async function cmdTake(argv) {
|
|
|
146
223
|
})
|
|
147
224
|
|
|
148
225
|
if (!res.events.length) {
|
|
149
|
-
process.stderr.write(`没等到消息(${res.reason})。游标身份 ${name}
|
|
226
|
+
process.stderr.write(`没等到消息(${res.reason})。游标身份 ${name},正常退出,再起一个继续\n`)
|
|
150
227
|
return res.reason === 'timeout' ? 4 : 0
|
|
151
228
|
}
|
|
152
229
|
|
|
@@ -157,7 +234,8 @@ async function cmdTake(argv) {
|
|
|
157
234
|
}
|
|
158
235
|
|
|
159
236
|
async function cmdStatus(argv) {
|
|
160
|
-
const a = parseArgs(argv, { flags: ['json', 'help'] })
|
|
237
|
+
const a = parseArgs(argv, { flags: ['json', 'help'], keys: [] })
|
|
238
|
+
if (a._unknown.length) return rejectUnknown('status', argv, a._unknown, ['json'])
|
|
161
239
|
if (a.help) {
|
|
162
240
|
process.stdout.write(`${help.STATUS_HELP}\n`)
|
|
163
241
|
return 0
|
|
@@ -170,7 +248,8 @@ async function cmdStatus(argv) {
|
|
|
170
248
|
}
|
|
171
249
|
|
|
172
250
|
async function cmdDispatch(argv) {
|
|
173
|
-
const a = parseArgs(argv, { flags: ['list', 'once', 'help'] })
|
|
251
|
+
const a = parseArgs(argv, { flags: ['list', 'once', 'help'], keys: [] })
|
|
252
|
+
if (a._unknown.length) return rejectUnknown('dispatch', argv, a._unknown, ['list', 'once'])
|
|
174
253
|
if (a.help) {
|
|
175
254
|
process.stdout.write(`${help.DISPATCH_HELP}\n`)
|
|
176
255
|
return 0
|
package/lib/args.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
// 零依赖 argv 解析。只支持 --key value / --key=value / --flag。
|
|
4
|
+
// spec.flags:布尔开关;spec.keys:带值选项。不在两者里的 --xxx 进 out._unknown,
|
|
5
|
+
// 由调用侧硬报错 —— 拼错参数必须当场失败,不能静默吞掉
|
|
6
|
+
// (实测:--chat-id 拼错 -> 打印用法退出,看着像「正常退出但没消息」,漏看 bot 第一条 Working)
|
|
4
7
|
function parseArgs(argv, spec = {}) {
|
|
5
8
|
const flags = new Set(spec.flags || [])
|
|
6
|
-
const
|
|
9
|
+
const keys = new Set(spec.keys || [])
|
|
10
|
+
const known = (k) => flags.has(k) || keys.has(k)
|
|
11
|
+
const out = { _: [], _unknown: [] }
|
|
7
12
|
for (let i = 0; i < argv.length; i++) {
|
|
8
13
|
const a = argv[i]
|
|
9
14
|
if (a === '--') {
|
|
@@ -16,17 +21,26 @@ function parseArgs(argv, spec = {}) {
|
|
|
16
21
|
}
|
|
17
22
|
const eq = a.indexOf('=')
|
|
18
23
|
if (eq !== -1) {
|
|
19
|
-
|
|
24
|
+
const k = a.slice(2, eq)
|
|
25
|
+
if (known(k)) out[k] = a.slice(eq + 1)
|
|
26
|
+
else out._unknown.push(k)
|
|
20
27
|
continue
|
|
21
28
|
}
|
|
22
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
|
+
}
|
|
23
37
|
if (flags.has(key)) {
|
|
24
38
|
out[key] = true
|
|
25
39
|
continue
|
|
26
40
|
}
|
|
27
41
|
const next = argv[i + 1]
|
|
28
42
|
if (next === undefined || next.startsWith('--')) {
|
|
29
|
-
out[key] = true //
|
|
43
|
+
out[key] = true // 缺值,由调用侧校验
|
|
30
44
|
} else {
|
|
31
45
|
out[key] = next
|
|
32
46
|
i++
|
|
@@ -49,4 +63,41 @@ function list(v) {
|
|
|
49
63
|
.filter(Boolean)
|
|
50
64
|
}
|
|
51
65
|
|
|
52
|
-
|
|
66
|
+
function levenshtein(a, b) {
|
|
67
|
+
const m = a.length
|
|
68
|
+
const n = b.length
|
|
69
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)])
|
|
70
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j
|
|
71
|
+
for (let i = 1; i <= m; i++) {
|
|
72
|
+
for (let j = 1; j <= n; j++) {
|
|
73
|
+
dp[i][j] = Math.min(
|
|
74
|
+
dp[i - 1][j] + 1,
|
|
75
|
+
dp[i][j - 1] + 1,
|
|
76
|
+
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return dp[m][n]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 拼错参数时给个候选:编辑距离 <= 2,或前 4 字符前缀相同(--chat-id -> --chats)。
|
|
84
|
+
// 都不沾边返回 null,不硬猜。
|
|
85
|
+
function suggest(unknown, allowed) {
|
|
86
|
+
let best = null
|
|
87
|
+
let bestD = Infinity
|
|
88
|
+
for (const k of allowed) {
|
|
89
|
+
const d = levenshtein(unknown, k)
|
|
90
|
+
if (d < bestD) {
|
|
91
|
+
bestD = d
|
|
92
|
+
best = k
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (bestD <= 2) return best
|
|
96
|
+
if (unknown.length >= 4) {
|
|
97
|
+
const hit = allowed.find((k) => k.startsWith(unknown.slice(0, 4)))
|
|
98
|
+
if (hit) return hit
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { parseArgs, num, list, suggest }
|
package/lib/help.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// 帮助文本是 AI 的主入口
|
|
3
|
+
// 帮助文本是 AI 的主入口 -- 实际行为是「先空参数跑一下看看」,
|
|
4
4
|
// 故 take 空参输出必须是完整的照做指导,而非报错。
|
|
5
|
+
// 所有提示按「AI 读到后下一步该做什么」来写:给完整命令、给退出码语义、给判据。
|
|
5
6
|
//
|
|
6
7
|
// ⚠️ 公网包:示例只用占位符(oc_xxx/<app>/ou_xxx),绝不含真实 chat_id/open_id/profile 名。
|
|
7
8
|
|
|
@@ -9,34 +10,41 @@ function takeHelp(apps) {
|
|
|
9
10
|
const appLine = apps && apps.length ? apps.join(' ') : '(跑 `lark-cli profile list` 看)'
|
|
10
11
|
return `需要 --app 和 --chats。监听要求 bot 已在群内。
|
|
11
12
|
|
|
12
|
-
一、查群 ID
|
|
13
|
+
一、查群 ID(用户身份;bot 搜不到自己没加入的群)
|
|
13
14
|
lark-cli --profile <app> im +chat-search --as user --query "<群名>"
|
|
14
|
-
|
|
15
|
+
-> 取 oc_ 开头的 chat_id;多个匹配时向用户确认是哪个
|
|
15
16
|
|
|
16
|
-
二、验 bot
|
|
17
|
+
二、验 bot 在群(用户身份;bot 不在群时用 bot 身份查不到,不能作判据)
|
|
17
18
|
lark-cli --profile <app> im chat.members bots --as user --params '{"chat_id":"oc_xxx"}'
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
-> bots[] 含本 app 才算在群
|
|
20
|
+
-> 不在群:请用户拉 bot 进群(对群可见动作,先征得同意)
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
三、监听(Claude Code 用 run_in_background 起,有消息会自动通知你)
|
|
22
23
|
lark-relay take --app <app> --chats oc_xxx --render text
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
起来后 stderr 先打一行「监听中 ...」-- 没这行就是没起来,看 stderr 报错。
|
|
26
|
+
阻塞等消息,有一批就吐到 stdout 并退出(默认防抖 5s、最长等 12h)。
|
|
25
27
|
处理完再起一个,如此循环。
|
|
26
28
|
|
|
27
|
-
可用 app
|
|
29
|
+
可用 app:${appLine}
|
|
28
30
|
|
|
29
31
|
参数
|
|
30
32
|
--app <name> 必填,= lark-cli profile 名
|
|
31
|
-
--chats <ids> 必填,oc_ 开头;逗号分隔多个;或
|
|
33
|
+
--chats <ids> 必填,oc_ 开头;逗号分隔多个;或 @文件(首列 chat_id)
|
|
32
34
|
--name <n> 游标身份,省略时按 app+chats 自动派生
|
|
33
35
|
--filter <jq> 业务过滤,如 '.sender_id != "ou_xxx"'
|
|
34
|
-
--debounce N
|
|
35
|
-
--render text
|
|
36
|
-
--since now|all
|
|
36
|
+
--debounce N 防抖秒数(默认 5;聊天场景建议 15)
|
|
37
|
+
--render text 按群分组紧凑文本(默认 NDJSON)
|
|
38
|
+
--since now|all 起始位置(默认 now,不重放历史)
|
|
39
|
+
|
|
40
|
+
退出码
|
|
41
|
+
0 吐了一批(stdout);空参求教也是 0
|
|
42
|
+
2 参数错 -- stderr 有短错误和正确写法,照着重跑
|
|
43
|
+
3 游标身份被另一个 take 占用 -- 换 --name
|
|
44
|
+
4 超时没消息 -- 正常,再起一个继续
|
|
37
45
|
|
|
38
46
|
最佳实践
|
|
39
|
-
· 用 run_in_background 起,不要前台跑
|
|
47
|
+
· 用 run_in_background 起,不要前台跑 -- 会阻塞会话最长 12h
|
|
40
48
|
· 一批处理完再起下一个;不需要自己写 while 循环
|
|
41
49
|
· 拿不到消息先查第二步:bot 不在群是最常见原因
|
|
42
50
|
· 多个消费者盯同一 app 用不同 --name,游标互不干扰`
|
|
@@ -44,109 +52,113 @@ function takeHelp(apps) {
|
|
|
44
52
|
|
|
45
53
|
const COLLECT_HELP = `把全部 lark-cli profile 的事件收下来,原子落盘。零参数、零配置。
|
|
46
54
|
|
|
47
|
-
lark-relay collect #
|
|
48
|
-
systemctl enable --now lark-relay-collect #
|
|
55
|
+
lark-relay collect # 前台跑(调试)
|
|
56
|
+
systemctl enable --now lark-relay-collect # 常驻(推荐)
|
|
49
57
|
|
|
50
58
|
app 列表实时读 \`lark-cli profile list\`,新增 profile 自动纳入,无需改配置。
|
|
51
59
|
每 app 一个子进程;某个挂了单独重启,不影响其他。
|
|
52
|
-
tokenStatus 为 expired 的 profile 自动跳过并 warn
|
|
60
|
+
tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是死循环)。
|
|
53
61
|
用户重新 login 后下次启动自动纳入。
|
|
54
62
|
|
|
55
|
-
为什么必须常驻:lark
|
|
56
|
-
8s 后才起 consumer,收到 0
|
|
63
|
+
为什么必须常驻:lark 事件是流式的,进程不在的时刻消息永久丢失(实测:消息发出
|
|
64
|
+
8s 后才起 consumer,收到 0 条)。collect 独立常驻,才能让 take/dispatch 侧
|
|
57
65
|
崩了、claude 跑 30 分钟、会话关几小时,都不丢消息。
|
|
58
66
|
|
|
59
67
|
落盘:~/.lark-relay/store/<app>/<YYYY-MM-DD>/<纳秒>_<pid>_<seq>.json
|
|
60
|
-
回收由 collect
|
|
68
|
+
回收由 collect 进程内每小时自查一次,删超期的整个日期目录(不另起 gc 单元/timer)。
|
|
61
69
|
|
|
62
70
|
参数
|
|
63
|
-
--exclude <apps> 排除指定 profile
|
|
64
|
-
--retain N store
|
|
65
|
-
--ledger-retain N ledger
|
|
71
|
+
--exclude <apps> 排除指定 profile(逗号分隔)
|
|
72
|
+
--retain N store 保留天数(默认 3)
|
|
73
|
+
--ledger-retain N ledger 保留天数(默认 90)`
|
|
66
74
|
|
|
67
75
|
const STATUS_HELP = `一眼看清全局:谁在跑、积压多少、游标在哪。
|
|
68
76
|
|
|
69
77
|
lark-relay status # 概览
|
|
70
78
|
lark-relay status --json # 机器可读
|
|
71
79
|
|
|
72
|
-
排障入口:某消费者「落后」很多
|
|
80
|
+
排障入口:某消费者「落后」很多 -> 它的 take/dispatch 没在跑或卡住;
|
|
73
81
|
「无消费者」= 白采,可考虑 --exclude。`
|
|
74
82
|
|
|
75
|
-
const DISPATCH_HELP = `常驻:等消息
|
|
83
|
+
const DISPATCH_HELP = `常驻:等消息 -> 唤起 claude 干活 -> 结果回群。无人在场也跑。
|
|
76
84
|
|
|
77
85
|
lark-relay dispatch --list # 列出所有 dispatch 任务及状态
|
|
78
|
-
lark-relay dispatch <task> --once #
|
|
86
|
+
lark-relay dispatch <task> --once # 前台跑一轮(调试,不进 systemd)
|
|
79
87
|
systemctl enable --now lark-relay-dispatch@<task>
|
|
80
88
|
|
|
81
89
|
--list 输出:任务名 / app / 群数 / systemd 单元是否 active / 上次 ledger 时间
|
|
82
90
|
|
|
83
|
-
任务发现:扫 $LR_ROOT/*/dispatch.yaml
|
|
84
|
-
LR_ROOT
|
|
91
|
+
任务发现:扫 $LR_ROOT/*/dispatch.yaml -- 有文件即是任务,无需注册表。
|
|
92
|
+
LR_ROOT 在单元里指定(唯一的目录绑定,与代码无关)。
|
|
85
93
|
|
|
86
|
-
dispatch.yaml
|
|
94
|
+
dispatch.yaml(4 必填 + 3 可选)
|
|
87
95
|
app: <profile 名>
|
|
88
96
|
chats: [oc_xxx]
|
|
89
97
|
dirs: [/path/to/repo] # 首个 = 主工作目录
|
|
90
98
|
instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
|
|
91
99
|
filter: '.mentions[]?.id == "ou_xxx"' # 可选
|
|
92
|
-
# mode: work 默认;work | chat | agent
|
|
100
|
+
# mode: work 默认;work | chat | agent(见下)
|
|
93
101
|
# model: <名称> 默认走终端同一套默认路由
|
|
94
102
|
|
|
95
|
-
mode
|
|
103
|
+
mode 三个场景(一个键定死全部行为,不用拼组合)
|
|
96
104
|
work 处理工作:话题内回复 + 过程挂 COT 消息 + 结论发卡片
|
|
97
|
-
+ 按 thread_id 隔离 session + 防抖 1s
|
|
98
|
-
COT 要求客户端 PC
|
|
99
|
-
「Completed
|
|
100
|
-
chat 简单问答:直发群里 + 纯文本 +
|
|
101
|
-
+ 防抖 15s
|
|
105
|
+
+ 按 thread_id 隔离 session + 防抖 1s(一问一答要跟手)
|
|
106
|
+
COT 要求客户端 PC >= 7.70 / 移动 >= 7.74;老客户端那条过程消息显示为
|
|
107
|
+
「Completed」(不崩),结论卡片不受影响
|
|
108
|
+
chat 简单问答:直发群里 + 纯文本 + 按(群, epoch)隔离 session
|
|
109
|
+
+ 防抖 15s(等人打完多行)
|
|
102
110
|
agent 模型自己当运营者:引擎只拉起它 + 传消息 + 记台账,**一条消息都不发**。
|
|
103
111
|
发不发 / 回哪条 / 用哪个 bot 身份 / 文字还是表情,全由模型按
|
|
104
|
-
instructions
|
|
105
|
-
群运营
|
|
112
|
+
instructions 决定(它有 Bash + lark-cli)。适合「多数轮该沉默」的
|
|
113
|
+
群运营 -- work/chat 会逼模型每轮都产出一段发回群的话
|
|
106
114
|
|
|
107
115
|
最佳实践
|
|
108
|
-
· 边界写 instructions,别指望 --add-dir 目录的 CLAUDE.md
|
|
116
|
+
· 边界写 instructions,别指望 --add-dir 目录的 CLAUDE.md(启动不加载)
|
|
109
117
|
· 改完 yaml 先 --once 跑一轮验证,再 enable
|
|
110
118
|
· 高风险发布仍建议人工执行`
|
|
111
119
|
|
|
112
|
-
const GUIDE = `lark-relay
|
|
120
|
+
const GUIDE = `lark-relay -- Lark 事件中继站。三个命令:收下来 / 我来取 / 派给别人。
|
|
113
121
|
|
|
114
|
-
|
|
122
|
+
先判场景(两者不可混用)
|
|
115
123
|
┌──────────┬────────────────────────┬────────────────────────┐
|
|
116
124
|
│ │ 在场取用 take │ 托管派活 dispatch │
|
|
117
125
|
├──────────┼────────────────────────┼────────────────────────┤
|
|
118
|
-
│ 长命的 │ AI
|
|
119
|
-
│ 短命的 │
|
|
126
|
+
│ 长命的 │ AI 会话(人设、上下文) │ 消息(等在队列里) │
|
|
127
|
+
│ 短命的 │ 消息(来一批处理一批) │ AI(每轮新起,跑完就没)│
|
|
120
128
|
│ 谁等谁 │ AI 等消息 │ 消息等 AI │
|
|
121
129
|
│ 控制权 │ AI 手里 │ 服务手里 │
|
|
122
130
|
│ 谁能干 │ 任何 agent │ 需内置唤起知识 │
|
|
123
131
|
│ 配置 │ 纯参数,无文件 │ dispatch.yaml │
|
|
124
132
|
└──────────┴────────────────────────┴────────────────────────┘
|
|
125
133
|
|
|
126
|
-
|
|
134
|
+
你(AI)在会话里盯群 -> take。无人在场也要干活 -> dispatch。
|
|
127
135
|
|
|
128
136
|
前置:collect 必须在跑
|
|
129
137
|
lark-relay status # collect 行应为 running
|
|
130
138
|
进程不在的时刻消息永久丢失,不是延迟送达。
|
|
131
139
|
|
|
132
|
-
take
|
|
140
|
+
take 三步(照做)
|
|
133
141
|
1. 查群 ID lark-cli --profile <app> im +chat-search --as user --query "<群名>"
|
|
134
142
|
2. 验 bot 在群 lark-cli --profile <app> im chat.members bots --as user \\
|
|
135
143
|
--params '{"chat_id":"oc_xxx"}'
|
|
136
|
-
3.
|
|
144
|
+
3. 监听(用 run_in_background 起,别前台)
|
|
137
145
|
lark-relay take --app <app> --chats oc_xxx --render text
|
|
138
|
-
|
|
146
|
+
起来后 stderr 有一行「监听中」-- 没有就是没起来,看 stderr。
|
|
147
|
+
一批处理完再起一个。不要自己写 while 循环 -- 进程退出会通知你。
|
|
148
|
+
退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
|
|
139
149
|
|
|
140
150
|
建 dispatch 任务
|
|
141
|
-
1. 在任务目录写 dispatch.yaml
|
|
151
|
+
1. 在任务目录写 dispatch.yaml(\`lark-relay dispatch --help\` 有模板)
|
|
142
152
|
2. lark-relay dispatch <task> --once # 前台验一轮
|
|
143
153
|
3. systemctl enable --now lark-relay-dispatch@<task>
|
|
144
154
|
|
|
145
155
|
常见错误
|
|
146
|
-
· 拿不到消息
|
|
156
|
+
· 拿不到消息 -> 九成是 bot 不在群。验 bot 必须 --as user,
|
|
147
157
|
bot 不在群时用 bot 身份查不到,「查询失败」不能推断「不在群」
|
|
148
|
-
·
|
|
149
|
-
|
|
158
|
+
· 起来后没有「监听中」那行 -> 没起来;参数拼错会直接报错并给正确写法,
|
|
159
|
+
不会静默
|
|
160
|
+
· --chats 只接受 oc_ 开头的 chat_id,不接群名 -- 群名会匹配错群,
|
|
161
|
+
而监听错群是静默失败(你会一直等一个永不来的消息)
|
|
150
162
|
· 默认 --since now 不重放历史。要历史用 --since all
|
|
151
163
|
· 多个消费者盯同一 app 必须用不同 --name,否则互相推游标撕批次
|
|
152
164
|
|