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/lib/dispatch.js DELETED
@@ -1,276 +0,0 @@
1
- 'use strict'
2
-
3
- // dispatch:常驻循环 = take + 唤起 claude。无人在场也跑。
4
- // **内部就是调 take** —— 保证两场景不实现分叉(旧架构三份 wrapper 各自演化出
5
- // 253 行就是分叉的教训)。
6
- const fs = require('fs')
7
- const path = require('path')
8
- const { execFileSync } = require('child_process')
9
- const { loadTask, listTasks, lrRoot } = require('./tasks')
10
- const { takeBatch } = require('./take')
11
- const { groupByChatThread, renderText } = require('./render')
12
- const { acquireBlocking } = require('./lock')
13
- const { paths, ensureDir } = require('./paths')
14
- const store = require('./store')
15
- const claude = require('./claude')
16
- const { sendCard } = require('./card')
17
- const { CotSession } = require('./cot')
18
- const { reply } = require('./reply')
19
-
20
- function cmdList() {
21
- const tasks = listTasks()
22
- if (!tasks.length) {
23
- process.stdout.write(
24
- `没有 dispatch 任务。LR_ROOT=${lrRoot()}\n` +
25
- `建任务:在 $LR_ROOT/<任务名>/dispatch.yaml 写配置(模板见 lark-relay dispatch --help)\n`,
26
- )
27
- return 0
28
- }
29
- const rows = [['任务', 'app', '群数', '单元', '上次派活']]
30
- for (const t of tasks) {
31
- const unit = `lark-relay-dispatch@${t.name}.service`
32
- let state = 'inactive'
33
- try {
34
- state = execFileSync('systemctl', ['is-active', unit], { encoding: 'utf8' }).trim()
35
- } catch (err) {
36
- state = (err.stdout || '').trim() || 'inactive'
37
- }
38
- let last = '—'
39
- for (const chat of t.config.chats || []) {
40
- try {
41
- const st = fs.statSync(paths.ledgerFile(t.name, chat))
42
- const m = Math.round((Date.now() - st.mtimeMs) / 60000)
43
- last = m < 60 ? `${m}m ago` : `${Math.round(m / 60)}h ago`
44
- } catch {}
45
- }
46
- rows.push([
47
- t.name,
48
- String(t.config.app || '?'),
49
- String((t.config.chats || []).length),
50
- state,
51
- last,
52
- ])
53
- if (t.errs.length) rows.push([` ⚠ ${t.errs.join('; ')}`, '', '', '', ''])
54
- }
55
- const wid = [0, 1, 2, 3, 4].map((i) => Math.max(...rows.map((r) => (r[i] || '').length)))
56
- for (const r of rows) {
57
- process.stdout.write(r.map((c, i) => String(c || '').padEnd(wid[i] + 2)).join('').trimEnd() + '\n')
58
- }
59
- return 0
60
- }
61
-
62
- // ledger:dispatch 每轮追加一条,四个字段。session 会因空闲滚动/过期丢上下文,
63
- // 结论不能丢,供跨会话回溯。
64
- function ledgerAppend(task, chatId, epoch, trigger, result) {
65
- try {
66
- ensureDir(paths.ledger)
67
- const line = JSON.stringify({
68
- ts: new Date().toISOString(),
69
- epoch: Number(epoch) || 0,
70
- trigger: String(trigger).slice(0, 500),
71
- result: String(result).slice(0, 4000),
72
- })
73
- fs.appendFileSync(paths.ledgerFile(task, chatId), `${line}\n`)
74
- } catch {}
75
- }
76
-
77
- // 处理一个 (chat_id, thread_id) 组:派一轮活
78
- async function handleGroup(t, group, opts = {}) {
79
- const { name: task, taskDir, insPath, dirs, mode } = t
80
- const cfg = t.config
81
- const profile = cfg.app
82
- const chatId = group.chatId
83
- const threadId = group.threadId
84
- const events = group.events
85
-
86
- const cwd = dirs[0] || taskDir
87
- const addDirs = dirs.slice(1)
88
- const batch = renderText(events)
89
- const lastMsg = events[events.length - 1]?.message_id || '-'
90
-
91
- // 每(任务,群)一把锁串行:并发会写坏 append-only ledger、错算 epoch,
92
- // 且同群两轮 claude 会互相打断。粒度是群而非话题 —— session 与 epoch 状态按群竞争。
93
- const lock = await acquireBlocking(`dispatch-${task}__${chatId}`, 1800_000)
94
- if (!lock.ok) {
95
- process.stderr.write(`dispatch: ${task}/${chatId} 获取锁超时,跳过本批\n`)
96
- return 0
97
- }
98
-
99
- try {
100
- // session 派生:work 模式按 thread_id 隔离(话题即边界,不滚动,epoch 恒 0);
101
- // chat 模式按 (群, epoch),空闲超 idle_gap 就开新世代
102
- let uuid
103
- let epoch = 0
104
- if (mode.thread && threadId) {
105
- uuid = claude.deriveUuid(task, `${chatId}|thread:${threadId}`)
106
- } else {
107
- epoch = claude.sessionEpoch(paths.root, task, chatId, Number(cfg.idle_gap) || 86400)
108
- uuid = claude.deriveUuid(task, `${chatId}|${epoch}`)
109
- }
110
-
111
- // 过程展示:work 模式挂 COT 消息。建不起来就静默降级(结论照发)
112
- let cot = null
113
- if (mode.cot) {
114
- cot = new CotSession({ profile, chatId, lastMsg, replyInThread: mode.thread })
115
- try {
116
- if (!(await cot.create())) throw new Error('建 COT 消息失败')
117
- } catch (err) {
118
- process.stderr.write(`cot: ${err.message},本轮无过程展示\n`)
119
- cot = null
120
- }
121
- }
122
-
123
- const res = await claude.runClaude({
124
- cwd,
125
- prompt: claude.PROMPTS[mode.prompt](batch),
126
- uuid,
127
- instructions: insPath,
128
- addDirs,
129
- allowedTools: cfg.allowed || 'Read Edit Write Bash',
130
- model: cfg.model,
131
- timeoutSec: Number(cfg.timeout) || 1800,
132
- stream: !!cot,
133
- onEvent: cot
134
- ? (kind, text) => {
135
- if (kind === 'say') cot.onSay(text)
136
- else if (kind === 'tool') cot.onTool(text)
137
- else if (kind === 'final') cot.onFinal()
138
- }
139
- : null,
140
- })
141
-
142
- // 兜底文案分两套:要发回群的(deliver != none)面向群里的人写,
143
- // agent 模式的 final 只进台账,不该出现「稍后重试或换个说法」这种对人说的话
144
- let final = (res.final || '').trim()
145
- if (!final) {
146
- const errTail = String(res.err).replace(/\n/g, ' ').slice(-300)
147
- if (mode.deliver === 'none') {
148
- final = res.rc === 0 ? '(本轮无自述)' : `(异常 rc=${res.rc}) ${errTail}`
149
- } else {
150
- final =
151
- res.rc === 0
152
- ? '(完成,但没拿到最终结论)'
153
- : `⚠️ 处理超时或异常(rc=${res.rc})。稍后重试或换个说法。\n\n错误:${errTail}`
154
- }
155
- }
156
-
157
- // 收尾必须包 try/catch:handleGroup 外层只有 try/finally(无 catch),
158
- // 收尾一抛就会跳过下面的结论投递 + ledgerAppend,而 take 游标已推
159
- // → 结论既不进群也不进台账,这批消息永不重来
160
- if (cot) {
161
- try {
162
- await cot.finalize({ rc: res.rc, error: res.err })
163
- await cot.stop()
164
- } catch (err) {
165
- process.stderr.write(`cot: 收尾异常(不影响结论)→ ${err.stack || err}\n`)
166
- }
167
- }
168
-
169
- // 结论投递:card = 卡片(markdown 渲染、不受 3500 字符分片限制),失败降级纯文本;
170
- // text = 纯文本;none = **引擎不发**,agent 模式下模型已自己发过了,
171
- // final 只是它的一句自述(进台账供回溯)
172
- if (mode.deliver !== 'none') {
173
- const sent = mode.deliver === 'card'
174
- ? await sendCard(final, { profile, chatId, replyTo: lastMsg, inThread: mode.thread })
175
- : false
176
- if (!sent) await reply(profile, chatId, lastMsg, final, { inThread: mode.thread })
177
- }
178
-
179
- ledgerAppend(task, chatId, epoch, batch, final)
180
- process.stderr.write(
181
- `dispatch: ${task}/${chatId}${threadId ? `/${threadId}` : ''} 完成 rc=${res.rc} ` +
182
- `${events.length} 条 → ${final.length} 字符\n`,
183
- )
184
- return res.rc === 0 ? 0 : 1
185
- } finally {
186
- lock.release()
187
- }
188
- }
189
-
190
- async function run(task, opts = {}) {
191
- const t = loadTask(task)
192
- if (!t) {
193
- process.stderr.write(
194
- `找不到任务 ${task}:${path.join(lrRoot(), task, 'dispatch.yaml')} 不存在\n` +
195
- `LR_ROOT=${lrRoot()};看现有任务:lark-relay dispatch --list\n`,
196
- )
197
- return 2
198
- }
199
- if (t.errs.length) {
200
- process.stderr.write(`任务 ${task} 配置有问题:\n ${t.errs.join('\n ')}\n`)
201
- return 2
202
- }
203
-
204
- const cfg = t.config
205
- const chats = cfg.chats || []
206
- // work=话题内一问一答要跟手 → 1s;chat=群里多行需求 → 15s 让人打完
207
- const debounceMs = (Number(cfg.debounce) || t.mode.debounce) * 1000
208
-
209
- process.stderr.write(
210
- `dispatch ${task}: app=${cfg.app} ${chats.length} 群 mode=${t.modeName} ` +
211
- `model=${cfg.model || '(默认路由)'} 防抖 ${debounceMs / 1000}s\n`,
212
- )
213
-
214
- const consumer = `dispatch-${task}`
215
- if (store.readCursor(consumer, cfg.app) === null) store.seedCursorNow(consumer, cfg.app)
216
-
217
- const ac = new AbortController()
218
- let stopping = false
219
- const onSig = () => {
220
- stopping = true
221
- ac.abort()
222
- }
223
- process.on('SIGTERM', onSig)
224
- process.on('SIGINT', onSig)
225
-
226
- for (;;) {
227
- let res
228
- try {
229
- res = await takeBatch({
230
- app: cfg.app,
231
- chats,
232
- name: consumer,
233
- filter: cfg.filter,
234
- debounceMs,
235
- maxWaitMs: 75_000,
236
- timeoutMs: opts.once ? 300_000 : 12 * 3600 * 1000,
237
- signal: ac.signal,
238
- })
239
- } catch (err) {
240
- // 取批阶段的异常绝不能让进程退出:Restart=on-failure 会变成每 5s 崩一次,
241
- // 而卡住的那条消息永远在队首 → 任务彻底停摆且看不出原因
242
- process.stderr.write(`dispatch: ${task} 取批失败,15s 后重试 → ${err.stack || err}\n`)
243
- if (opts.once) return 1
244
- await new Promise((r) => setTimeout(r, 15_000))
245
- continue
246
- }
247
-
248
- if (res.events.length) {
249
- // 一个防抖窗口会吐同群多话题的消息,只按 chat_id 分组会**串话题**
250
- const groups = groupByChatThread(res.events)
251
- for (let i = 0; i < groups.length; i++) {
252
- // 收到停止信号后不再开新一轮 claude:单元 TimeoutStopSec=30,
253
- // 而一轮最长 1800s → 会被 SIGKILL,卡片停在占位符、结论不回群。
254
- // 游标此时也没推(take 的 abort 分支只在有批次时推),故剩余组下次重新取到
255
- if (stopping) {
256
- process.stderr.write(
257
- `dispatch: ${task} 正在停止,剩余 ${groups.length - i} 组留到下次\n`,
258
- )
259
- break
260
- }
261
- try {
262
- await handleGroup(t, groups[i])
263
- } catch (err) {
264
- process.stderr.write(`dispatch: ${task} 处理组失败 ${err.stack || err}\n`)
265
- }
266
- }
267
- } else if (opts.once) {
268
- process.stderr.write(`dispatch: 没等到消息(${res.reason})\n`)
269
- return res.reason === 'timeout' ? 4 : 0 // 与 take 对齐:4 = 等超时,0 = 正常
270
- }
271
-
272
- if (opts.once || stopping) return 0
273
- }
274
- }
275
-
276
- module.exports = { run, cmdList, handleGroup }
package/lib/reply.js DELETED
@@ -1,69 +0,0 @@
1
- 'use strict'
2
-
3
- // 回群:超长按**字符**分片。飞书单条 text 有长度上限,超长会整条发送失败
4
- // (用户收不到任何东西),故必须分片而非截断。
5
- const larkcli = require('./larkcli')
6
- const { errText, tail } = larkcli
7
-
8
- const CHUNK = Number(process.env.LR_REPLY_CHUNK || 3500)
9
-
10
- // 首片 reply、其余直发;每片尾标 (i/n)。任一片失败只置 rc,循环继续发完剩余片。
11
- async function reply(profile, chatId, msgId, text, opts = {}) {
12
- if (!text) return true
13
- // 按码点数,不按字节 —— C locale 下按字节会切坏中文
14
- const chars = [...String(text)]
15
- if (chars.length <= CHUNK) {
16
- return await replyOne(profile, chatId, msgId, text, opts.inThread)
17
- }
18
- const n = Math.ceil(chars.length / CHUNK)
19
- process.stderr.write(`lr: 回复超长(${chars.length} 字符)→ 分 ${n} 片发送 chat=${chatId}\n`)
20
- let ok = true
21
- for (let i = 0; i < n; i++) {
22
- const part = `${chars.slice(i * CHUNK, (i + 1) * CHUNK).join('')}\n(${i + 1}/${n})`
23
- // 话题由首条锚定即可,续片不带 reply-in-thread
24
- const r =
25
- i === 0
26
- ? await replyOne(profile, chatId, msgId, part, opts.inThread)
27
- : await replyOne(profile, chatId, '-', part, false)
28
- if (!r) ok = false
29
- }
30
- return ok
31
- }
32
-
33
- async function replyOne(profile, chatId, msgId, text, inThread) {
34
- const hasParent = msgId && msgId !== '-'
35
- if (hasParent) {
36
- try {
37
- const data = { msg_type: 'text', content: JSON.stringify({ text }) }
38
- if (inThread) data.reply_in_thread = true
39
- const { stdout } = await larkcli.run([
40
- '--profile', profile, 'api', 'POST',
41
- `/open-apis/im/v1/messages/${msgId}/reply`,
42
- '--as', 'bot', '--data', JSON.stringify(data),
43
- ])
44
- if (/"ok":\s*true/.test(stdout)) return true
45
- process.stderr.write(`lr: reply 未 ok chat=${chatId} msg=${msgId} → ${tail(stdout)}\n`)
46
- } catch (err) {
47
- // 原消息过期/权限不足 → 自动退回直发群
48
- process.stderr.write(`lr: reply 失败 chat=${chatId} msg=${msgId} → ${tail(errText(err))}\n`)
49
- }
50
- }
51
- try {
52
- const { stdout } = await larkcli.run([
53
- '--profile', profile, 'im', '+messages-send',
54
- '--params', JSON.stringify({ receive_id_type: 'chat_id' }),
55
- '--data', JSON.stringify({
56
- receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }),
57
- }),
58
- '--as', 'bot',
59
- ])
60
- if (/"ok":\s*true/.test(stdout)) return true
61
- process.stderr.write(`lr: 发群未 ok chat=${chatId} → ${tail(stdout)}\n`)
62
- return false
63
- } catch (err) {
64
- process.stderr.write(`lr: 发群失败 chat=${chatId} → ${tail(errText(err))}\n`)
65
- return false
66
- }
67
- }
68
-
69
- module.exports = { reply, CHUNK }
package/lib/tasks.js DELETED
@@ -1,181 +0,0 @@
1
- 'use strict'
2
-
3
- // dispatch 任务发现:扫 $LR_ROOT/*/dispatch.yaml —— 有文件即是任务,无需注册表。
4
- // LR_ROOT 是唯一的目录绑定,在 systemd 单元里指定,与代码位置无关。
5
- const fs = require('fs')
6
- const path = require('path')
7
-
8
- function lrRoot() {
9
- return process.env.LR_ROOT || process.cwd()
10
- }
11
-
12
- // 极小 YAML 子集解析器:dispatch.yaml 只需要标量、行内数组 [a, b]、块数组 - a。
13
- // 不引 js-yaml —— 包保持零依赖,而配置面就这 8 个键。
14
- function parseYaml(text) {
15
- const out = {}
16
- const lines = text.split('\n')
17
- let curKey = null // 正在收集块数组的键
18
- for (let raw of lines) {
19
- const line = stripComment(raw)
20
- if (!line.trim()) continue
21
-
22
- const blockItem = line.match(/^\s*-\s*(.*)$/)
23
- if (blockItem && curKey) {
24
- out[curKey].push(scalar(blockItem[1]))
25
- continue
26
- }
27
-
28
- const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/)
29
- if (!kv) continue
30
- const key = kv[1]
31
- const val = kv[2].trim()
32
- if (val === '') {
33
- // 下面跟块数组
34
- out[key] = []
35
- curKey = key
36
- continue
37
- }
38
- curKey = null
39
- if (val.startsWith('[')) {
40
- out[key] = parseInline(val)
41
- } else {
42
- out[key] = scalar(val)
43
- }
44
- }
45
- return out
46
- }
47
-
48
- // 注释剥离要避开引号内的 # (filter 表达式里常有 open_id,不含 # 但别赌)
49
- function stripComment(line) {
50
- let inS = false
51
- let inD = false
52
- for (let i = 0; i < line.length; i++) {
53
- const c = line[i]
54
- if (c === "'" && !inD) inS = !inS
55
- else if (c === '"' && !inS) inD = !inD
56
- else if (c === '#' && !inS && !inD) {
57
- // 只有 # 前是行首或空白才算注释
58
- if (i === 0 || /\s/.test(line[i - 1])) return line.slice(0, i)
59
- }
60
- }
61
- return line
62
- }
63
-
64
- function parseInline(val) {
65
- const inner = val.replace(/^\[/, '').replace(/\]\s*$/, '')
66
- if (!inner.trim()) return []
67
- return splitTop(inner).map(scalar)
68
- }
69
-
70
- // 按顶层逗号切,尊重引号
71
- function splitTop(s) {
72
- const out = []
73
- let cur = ''
74
- let inS = false
75
- let inD = false
76
- for (const c of s) {
77
- if (c === "'" && !inD) {
78
- inS = !inS
79
- cur += c
80
- } else if (c === '"' && !inS) {
81
- inD = !inD
82
- cur += c
83
- } else if (c === ',' && !inS && !inD) {
84
- out.push(cur)
85
- cur = ''
86
- } else cur += c
87
- }
88
- if (cur.trim()) out.push(cur)
89
- return out
90
- }
91
-
92
- function scalar(s) {
93
- let v = s.trim()
94
- if (
95
- (v.startsWith("'") && v.endsWith("'") && v.length >= 2) ||
96
- (v.startsWith('"') && v.endsWith('"') && v.length >= 2)
97
- ) {
98
- return v.slice(1, -1)
99
- }
100
- if (v === 'true') return true
101
- if (v === 'false') return false
102
- if (v === 'null' || v === '~') return null
103
- if (/^-?\d+$/.test(v)) return Number(v)
104
- return v
105
- }
106
-
107
- const REQUIRED = ['app', 'chats', 'dirs', 'instructions']
108
-
109
- // 三个场景,不是几个正交开关的组合 —— 一个 mode 键定死全部行为。
110
- // deliver 决定「结论由谁发」:card/text = 引擎发,none = 模型自己发。
111
- const MODES = {
112
- // 日常闲聊:群里直接对话,不开话题
113
- chat: { thread: false, cot: false, deliver: 'text', prompt: 'task', debounce: 15 },
114
- // 处理工作:话题内一问一答,过程挂 COT,结论发卡片
115
- work: { thread: true, cot: true, deliver: 'card', prompt: 'task', debounce: 1 },
116
- // 模型自己当运营者:引擎只拉起它 + 传消息 + 记台账,**不发任何消息**。
117
- // 发不发、回哪条、用谁的身份、文字还是表情,全由模型按 instructions 决定
118
- // (它有 Bash + lark-cli)。多数轮的正解是沉默 —— 引擎不该逼它每轮都说话
119
- agent: { thread: false, cot: false, deliver: 'none', prompt: 'agent', debounce: 15 },
120
- }
121
-
122
- function validate(name, cfg, taskDir) {
123
- const errs = []
124
- for (const k of REQUIRED) {
125
- if (cfg[k] === undefined || cfg[k] === null || cfg[k] === '') errs.push(`缺 ${k}`)
126
- }
127
- if (cfg.chats && !Array.isArray(cfg.chats)) errs.push('chats 必须是数组')
128
- if (cfg.dirs && !Array.isArray(cfg.dirs)) errs.push('dirs 必须是数组')
129
- for (const c of cfg.chats || []) {
130
- if (typeof c !== 'string' || !c.startsWith('oc_')) errs.push(`chats 项不是 oc_ 开头:${c}`)
131
- }
132
- const modeName = cfg.mode || 'work'
133
- if (!MODES[modeName]) errs.push(`mode 只能 ${Object.keys(MODES).join('|')},给的是 ${modeName}`)
134
- const mode = MODES[modeName] || MODES.work
135
-
136
- // instructions 相对任务目录解析 —— 执行偏好是资产,必须留仓库
137
- let insPath = null
138
- if (cfg.instructions) {
139
- insPath = path.resolve(taskDir, cfg.instructions)
140
- if (!fs.existsSync(insPath)) errs.push(`instructions 文件不存在:${insPath}`)
141
- }
142
- const dirs = (cfg.dirs || []).map((d) => path.resolve(taskDir, d))
143
- for (const d of dirs) {
144
- if (!fs.existsSync(d)) errs.push(`dirs 目录不存在:${d}`)
145
- }
146
- return { errs, insPath, dirs, modeName, mode }
147
- }
148
-
149
- function loadTask(name, root = lrRoot()) {
150
- const taskDir = path.join(root, name)
151
- const file = path.join(taskDir, 'dispatch.yaml')
152
- let text
153
- try {
154
- text = fs.readFileSync(file, 'utf8')
155
- } catch {
156
- return null
157
- }
158
- const cfg = parseYaml(text)
159
- const v = validate(name, cfg, taskDir)
160
- return { name, taskDir, file, config: cfg, ...v }
161
- }
162
-
163
- function listTasks(root = lrRoot()) {
164
- let names
165
- try {
166
- names = fs.readdirSync(root, { withFileTypes: true })
167
- .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
168
- .map((d) => d.name)
169
- .sort()
170
- } catch {
171
- return []
172
- }
173
- const out = []
174
- for (const name of names) {
175
- const t = loadTask(name, root)
176
- if (t) out.push(t)
177
- }
178
- return out
179
- }
180
-
181
- module.exports = { lrRoot, parseYaml, loadTask, listTasks, validate }