lark-relay 0.0.0 → 0.1.0

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/claude.js ADDED
@@ -0,0 +1,207 @@
1
+ 'use strict'
2
+
3
+ // 唤起 headless claude。session 确定性派生,不落配置、不需人填。
4
+ const crypto = require('crypto')
5
+ const fs = require('fs')
6
+ const os = require('os')
7
+ const path = require('path')
8
+ const { spawn } = require('child_process')
9
+
10
+ // 模型分发交给 dotfiles 里的 claude() 函数 —— 它有白名单校验,是单一事实源。
11
+ // 旧架构存的是内部函数名(route: cc-48,实测早已不存在),改存模型名后
12
+ // 新增后端零改动,写错也会被分发器 exit 2 挡住。
13
+ const CC_RC = process.env.LR_CC_RC || path.join(os.homedir(), 'space/dotfiles/rc.d/claude.sh')
14
+
15
+ // sha1(task|scope) 前 32 位切成 8-4-4-4-12
16
+ function deriveUuid(task, scope) {
17
+ const h = crypto.createHash('sha1').update(`${task}|${scope}`).digest('hex')
18
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`
19
+ }
20
+
21
+ // Claude Code 按 cwd 分目录存 session:/ 和 . 都换成 -
22
+ function encCwd(cwd) {
23
+ return String(cwd).replace(/[/.]/g, '-')
24
+ }
25
+
26
+ function sessionFile(cwd, uuid) {
27
+ return path.join(os.homedir(), '.claude', 'projects', encCwd(cwd), `${uuid}.jsonl`)
28
+ }
29
+
30
+ // 会话世代号:超过 idleGap 没动静就开新会话。thread 模式不调用(话题即边界,恒 0)。
31
+ function sessionEpoch(stateDir, task, chatId, idleGap) {
32
+ const dir = path.join(stateDir, 'state')
33
+ fs.mkdirSync(dir, { recursive: true })
34
+ const file = path.join(dir, `${task}__${chatId}.tsv`)
35
+ const now = Math.floor(Date.now() / 1000)
36
+ let epoch = 0
37
+ try {
38
+ const [lastStr, epStr] = fs.readFileSync(file, 'utf8').trim().split('\t')
39
+ const last = Number(lastStr) || 0
40
+ epoch = Number(epStr) || 0
41
+ if (idleGap > 0 && now - last > idleGap) epoch += 1
42
+ } catch {}
43
+ fs.writeFileSync(file, `${now}\t${epoch}\n`)
44
+ return epoch
45
+ }
46
+
47
+ const PROMPT_TEMPLATE = (batch) =>
48
+ `下面是飞书群里最新一批消息。请把它们当作针对本仓库(当前工作目录)的任务来处理:定位/分析/修复/测试。处理完后,你的最终回复会被原样发回该群,请直接面向群里的人简洁说明你做了什么、发现了什么、需要什么确认。
49
+
50
+ ===== 群消息 =====
51
+ ${batch}
52
+ ===== 结束 =====`
53
+
54
+ /**
55
+ * 起一轮 headless claude。
56
+ * @returns {Promise<{rc:number, final:string, err:string}>}
57
+ */
58
+ function runClaude(opts) {
59
+ const {
60
+ cwd,
61
+ prompt,
62
+ uuid,
63
+ instructions,
64
+ addDirs = [],
65
+ allowedTools = 'Read Edit Write Bash',
66
+ model,
67
+ timeoutSec = 1800,
68
+ stream = false,
69
+ onEvent,
70
+ } = opts
71
+
72
+ const resume = fs.existsSync(sessionFile(cwd, uuid))
73
+ const args = ['-p', prompt]
74
+ // 首次用 --session-id 建、之后用 --resume 续,判据就是 jsonl 是否存在
75
+ args.push(resume ? '--resume' : '--session-id', uuid)
76
+ args.push('--add-dir', cwd) // 主工作目录自己也加一次
77
+ args.push('--setting-sources', 'user,project,local') // 含 project 才加载主目录 CLAUDE.md
78
+ args.push('--permission-mode', 'acceptEdits')
79
+ args.push('--allowedTools', allowedTools)
80
+ for (const d of addDirs) args.push('--add-dir', d)
81
+ // 职责/边界必须走这里:CLAUDE.md 只自动加载主工作目录+父链,
82
+ // --add-dir 的目录启动不加载,AGENTS.md 也不被当 memory
83
+ if (instructions && fs.existsSync(instructions)) {
84
+ args.push('--append-system-prompt-file', instructions)
85
+ }
86
+ args.push('--output-format', stream ? 'stream-json' : 'json')
87
+ if (stream) args.push('--verbose')
88
+
89
+ // 经 bash -lc source claude.sh 后调 claude() —— 模型分发与白名单校验都在那里
90
+ const quoted = args.map(shellQuote).join(' ')
91
+ const modelArg = model ? `--model ${shellQuote(model)} ` : ''
92
+ const script =
93
+ `set -o pipefail\n` +
94
+ `if ! source ${shellQuote(CC_RC)} 2>/dev/null; then\n` +
95
+ ` echo "cc: 无法 source ${CC_RC}" >&2; exit 3\n` +
96
+ `fi\n` +
97
+ `claude ${modelArg}${quoted}\n`
98
+
99
+ return new Promise((resolve) => {
100
+ const child = spawn('bash', ['-lc', script], {
101
+ cwd,
102
+ env: {
103
+ ...process.env,
104
+ LANG: 'C.UTF-8',
105
+ LC_ALL: 'C.UTF-8',
106
+ // headless 里 Bash 工具层单命令超时 10min(非整体超时)
107
+ BASH_MAX_TIMEOUT_MS: process.env.BASH_MAX_TIMEOUT_MS || '600000',
108
+ },
109
+ stdio: ['ignore', 'pipe', 'pipe'],
110
+ })
111
+
112
+ let out = ''
113
+ let err = ''
114
+ let done = false
115
+ // 挂钟超时兜底:包住整轮
116
+ const timer = setTimeout(() => {
117
+ if (!done) {
118
+ err += `\n[timeout ${timeoutSec}s]`
119
+ child.kill('SIGTERM')
120
+ setTimeout(() => child.kill('SIGKILL'), 5000)
121
+ }
122
+ }, timeoutSec * 1000)
123
+
124
+ if (stream) {
125
+ let buf = ''
126
+ child.stdout.on('data', (d) => {
127
+ buf += d.toString()
128
+ const lines = buf.split('\n')
129
+ buf = lines.pop()
130
+ for (const line of lines) handleStreamLine(line, onEvent, (t) => (out = t))
131
+ })
132
+ child.stdout.on('end', () => {
133
+ if (buf.trim()) handleStreamLine(buf, onEvent, (t) => (out = t))
134
+ })
135
+ } else {
136
+ child.stdout.on('data', (d) => (out += d.toString()))
137
+ }
138
+ child.stderr.on('data', (d) => (err += d.toString()))
139
+
140
+ child.on('close', (code) => {
141
+ done = true
142
+ clearTimeout(timer)
143
+ let final = ''
144
+ if (stream) {
145
+ final = out
146
+ } else {
147
+ try {
148
+ final = JSON.parse(stripToJson(out))?.result || ''
149
+ } catch {
150
+ final = out.trim()
151
+ }
152
+ }
153
+ resolve({ rc: code === null ? 124 : code, final, err })
154
+ })
155
+ child.on('error', (e) => {
156
+ done = true
157
+ clearTimeout(timer)
158
+ resolve({ rc: 1, final: '', err: String(e.message) })
159
+ })
160
+ })
161
+ }
162
+
163
+ // stream-json 事件 → say / tool / final
164
+ function handleStreamLine(line, onEvent, setFinal) {
165
+ const s = line.trim()
166
+ if (!s || s[0] !== '{') return
167
+ let j
168
+ try {
169
+ j = JSON.parse(s)
170
+ } catch {
171
+ return
172
+ }
173
+ if (j.type === 'assistant') {
174
+ for (const c of j.message?.content || []) {
175
+ if (c.type === 'text' && c.text) onEvent?.('say', c.text)
176
+ else if (c.type === 'tool_use') {
177
+ const detail =
178
+ c.input?.command ?? c.input?.file_path ?? c.input?.pattern ?? c.input?.description ?? ''
179
+ const str = String(detail)
180
+ onEvent?.('tool', `${c.name}: ${str.length > 300 ? `${str.slice(0, 300)}…` : str}`)
181
+ }
182
+ }
183
+ } else if (j.type === 'result') {
184
+ const r = j.result || ''
185
+ setFinal(r)
186
+ onEvent?.('final', r)
187
+ }
188
+ }
189
+
190
+ function stripToJson(s) {
191
+ const i = String(s).indexOf('{')
192
+ return i === -1 ? '{}' : String(s).slice(i)
193
+ }
194
+
195
+ function shellQuote(s) {
196
+ return `'${String(s).replace(/'/g, `'\\''`)}'`
197
+ }
198
+
199
+ module.exports = {
200
+ runClaude,
201
+ deriveUuid,
202
+ sessionEpoch,
203
+ sessionFile,
204
+ encCwd,
205
+ PROMPT_TEMPLATE,
206
+ CC_RC,
207
+ }
package/lib/collect.js ADDED
@@ -0,0 +1,167 @@
1
+ 'use strict'
2
+
3
+ // collect:底座。全部 profile 各起一个 consume → 读 stdout NDJSON → 原子落盘。
4
+ // 零参数、零配置 —— app 列表实时读 `lark-cli profile list`。
5
+ //
6
+ // 为什么必须独立常驻:lark 事件是流式的,进程不在的时刻消息永久丢失
7
+ // (实测:消息发出 8s 后才起 consumer,收到 0 条)。collect 独立常驻,
8
+ // 才能让 take/dispatch 侧崩了、claude 跑 30 分钟、会话关几小时,都不丢消息。
9
+ const readline = require('readline')
10
+ const larkcli = require('./larkcli')
11
+ const store = require('./store')
12
+ const { paths, ensureDir } = require('./paths')
13
+
14
+ const EVENT_KEY = process.env.LARK_RELAY_EVENT_KEY || 'im.message.receive_v1'
15
+ const GC_INTERVAL_MS = 3600_000
16
+
17
+ class AppCollector {
18
+ constructor(app, opts) {
19
+ this.app = app
20
+ this.opts = opts
21
+ this.seq = 0
22
+ this.received = 0
23
+ this.child = null
24
+ this.stopped = false
25
+ this.backoffMs = 1000
26
+ this.restarts = 0
27
+ }
28
+
29
+ start() {
30
+ if (this.stopped) return
31
+ const child = larkcli.spawnConsume(this.app, EVENT_KEY, ['--as', 'bot', '--timeout', '0'])
32
+ this.child = child
33
+
34
+ // stdin 是管道且我们永不写、永不关 —— unbounded consume 把 stdin EOF 当退出信号。
35
+ // 关掉或给 /dev/null 会让它立刻退出,变成疯狂重启循环(实测踩过)。
36
+ // 只在 stop() 里显式 SIGTERM 结束它。
37
+ if (child.stdin) child.stdin.on('error', () => {})
38
+
39
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity })
40
+ rl.on('line', (line) => {
41
+ const s = line.trim()
42
+ if (!s || s[0] !== '{') return
43
+ let obj
44
+ try {
45
+ obj = JSON.parse(s)
46
+ } catch {
47
+ process.stderr.write(`[${this.app}] warn: 非 JSON 行,已跳过\n`)
48
+ return
49
+ }
50
+ try {
51
+ store.writeEvent(this.app, obj, this.seq++)
52
+ this.received++
53
+ // 收到第一条就说明这个 app 通了,重置退避
54
+ this.backoffMs = 1000
55
+ } catch (err) {
56
+ process.stderr.write(`[${this.app}] error: 落盘失败 ${err.message}\n`)
57
+ }
58
+ })
59
+
60
+ // 不加 --quiet:它会隐藏事件丢失。stderr 的 ready/exit/drop 诊断进 journald
61
+ child.stderr.on('data', (buf) => {
62
+ const text = buf.toString().trimEnd()
63
+ if (text) process.stderr.write(`[${this.app}] ${text}\n`)
64
+ })
65
+
66
+ child.on('exit', (code, signal) => {
67
+ this.child = null
68
+ if (this.stopped) return
69
+ this.restarts++
70
+ const why = signal ? `signal ${signal}` : `code ${code}`
71
+ process.stderr.write(
72
+ `[${this.app}] consume 退出(${why}),${Math.round(this.backoffMs / 1000)}s 后重启\n`,
73
+ )
74
+ // 每 app 一个子进程;某个挂了单独重启,不影响其他
75
+ setTimeout(() => this.start(), this.backoffMs)
76
+ this.backoffMs = Math.min(this.backoffMs * 2, 60_000)
77
+ })
78
+
79
+ child.on('error', (err) => {
80
+ process.stderr.write(`[${this.app}] spawn 失败:${err.message}\n`)
81
+ })
82
+ }
83
+
84
+ stop() {
85
+ this.stopped = true
86
+ // 优雅停止:SIGTERM。禁 SIGKILL —— 否则漏卸载服务端订阅
87
+ if (this.child) this.child.kill('SIGTERM')
88
+ }
89
+ }
90
+
91
+ async function runCollect(opts) {
92
+ ensureDir(paths.store)
93
+ ensureDir(paths.cursors)
94
+ ensureDir(paths.ledger)
95
+
96
+ const exclude = new Set(opts.exclude || [])
97
+ const profiles = await larkcli.listProfiles()
98
+
99
+ const chosen = []
100
+ const skipped = []
101
+ for (const p of profiles) {
102
+ if (exclude.has(p.name)) {
103
+ skipped.push(`${p.name} [excluded]`)
104
+ continue
105
+ }
106
+ // expired = 授权过期,not-authed = 从未 login。两者都永久失败,重试是死循环。
107
+ // 用户重新 login 后下次启动自动纳入。
108
+ if (!p.usable) {
109
+ skipped.push(`${p.name} [${p.reason}]`)
110
+ continue
111
+ }
112
+ chosen.push(p)
113
+ }
114
+
115
+ if (skipped.length) {
116
+ process.stderr.write(`warn: 跳过 ${skipped.join(' ')}\n`)
117
+ }
118
+ if (!chosen.length) {
119
+ process.stderr.write('error: 没有可用 profile。先 `lark-cli auth login`\n')
120
+ return 1
121
+ }
122
+
123
+ process.stderr.write(
124
+ `lark-relay collect: ${chosen.length}/${profiles.length} profiles → ${paths.store}\n` +
125
+ ` ${chosen.map((p) => p.name).join(' ')}\n` +
126
+ ` event_key=${EVENT_KEY} store 保留 ${opts.retain}天 ledger 保留 ${opts.ledgerRetain}天\n`,
127
+ )
128
+
129
+ const collectors = chosen.map((p) => new AppCollector(p.name, opts))
130
+ for (const c of collectors) c.start()
131
+
132
+ // gc 在本进程内每小时自查:回收逻辑就是「删超期日期目录」一句话,
133
+ // 另起 gc 命令 + systemd timer 是为一行逻辑加两个部署件。
134
+ const gcTimer = setInterval(() => {
135
+ try {
136
+ const s = store.gcStore(opts.retain)
137
+ const l = store.gcLedger(opts.ledgerRetain)
138
+ if (s.length || l.length) {
139
+ process.stderr.write(`gc: 删 store ${s.length} 个日期目录, ledger ${l.length} 个文件\n`)
140
+ }
141
+ } catch (err) {
142
+ process.stderr.write(`gc: 失败 ${err.message}\n`)
143
+ }
144
+ }, GC_INTERVAL_MS)
145
+ // 启动时先跑一次,别等一小时
146
+ try {
147
+ store.gcStore(opts.retain)
148
+ store.gcLedger(opts.ledgerRetain)
149
+ } catch {}
150
+
151
+ return await new Promise((resolve) => {
152
+ let shuttingDown = false
153
+ const shutdown = (sig) => {
154
+ if (shuttingDown) return
155
+ shuttingDown = true
156
+ process.stderr.write(`收到 ${sig},停止 ${collectors.length} 个 consumer\n`)
157
+ clearInterval(gcTimer)
158
+ for (const c of collectors) c.stop()
159
+ // 给子进程时间卸载服务端订阅
160
+ setTimeout(() => resolve(0), 3000)
161
+ }
162
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
163
+ process.on('SIGINT', () => shutdown('SIGINT'))
164
+ })
165
+ }
166
+
167
+ module.exports = { runCollect, EVENT_KEY }
package/lib/dedup.js ADDED
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ // 两层去重:cursor 管进度,message_id 管幂等。
4
+ // 为什么还要第二层:collect 重启或 bus 重投时同一条消息可能二次落盘,
5
+ // 此时它是**新文件**(新纳秒前缀),游标拦不住 —— 只有 message_id 能拦。
6
+ // 为什么不用 event_id:同一条消息在不同 consumer 上 event_id 可复用/变化,
7
+ // 实测不可作幂等键(旧架构 docs/architecture.md 已记此坑)。
8
+ const fs = require('fs')
9
+ const path = require('path')
10
+ const { paths, ensureDir } = require('./paths')
11
+
12
+ const TTL_MS = 6 * 3600 * 1000
13
+
14
+ class Dedup {
15
+ constructor(name, app) {
16
+ this.file = path.join(paths.cursors, name, `${app}.seen.json`)
17
+ this.map = new Map()
18
+ this.dirty = false
19
+ this._load()
20
+ }
21
+
22
+ _load() {
23
+ let raw
24
+ try {
25
+ raw = fs.readFileSync(this.file, 'utf8')
26
+ } catch {
27
+ return
28
+ }
29
+ let obj
30
+ try {
31
+ obj = JSON.parse(raw)
32
+ } catch {
33
+ return // 坏文件:当空处理,最多多放行一条重复
34
+ }
35
+ const cutoff = Date.now() - TTL_MS
36
+ for (const [id, ts] of Object.entries(obj)) {
37
+ if (typeof ts === 'number' && ts >= cutoff) this.map.set(id, ts)
38
+ }
39
+ }
40
+
41
+ has(id) {
42
+ if (!id) return false
43
+ return this.map.has(id)
44
+ }
45
+
46
+ add(id) {
47
+ if (!id) return
48
+ this.map.set(id, Date.now())
49
+ this.dirty = true
50
+ }
51
+
52
+ flush() {
53
+ if (!this.dirty) return
54
+ const cutoff = Date.now() - TTL_MS
55
+ const obj = {}
56
+ for (const [id, ts] of this.map) {
57
+ if (ts >= cutoff) obj[id] = ts
58
+ }
59
+ ensureDir(path.dirname(this.file))
60
+ const tmp = `${this.file}.tmp`
61
+ try {
62
+ fs.writeFileSync(tmp, JSON.stringify(obj))
63
+ fs.renameSync(tmp, this.file)
64
+ this.dirty = false
65
+ } catch {}
66
+ }
67
+ }
68
+
69
+ module.exports = { Dedup, TTL_MS }
@@ -0,0 +1,241 @@
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 { CardSession } = require('./card')
17
+ const { reply } = require('./reply')
18
+ const larkcli = require('./larkcli')
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, session, display } = 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 派生:thread 模式按 thread_id 隔离(话题即边界,不滚动,epoch 恒 0);
101
+ // 否则按 (群, epoch),空闲超 idle_gap 就开新世代
102
+ let uuid
103
+ let epoch = 0
104
+ if (session === '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
+ const replyInThread = session === 'thread'
112
+ const useCard = display === 'card'
113
+ let card = null
114
+
115
+ if (useCard) {
116
+ card = new CardSession({ profile, chatId, lastMsg, replyInThread })
117
+ try {
118
+ if (!(await card.create())) throw new Error('建卡片实体失败')
119
+ if (!(await card.post())) throw new Error('发卡片消息失败')
120
+ } catch (err) {
121
+ process.stderr.write(`card: ${err.message},回退 final\n`)
122
+ card = null
123
+ }
124
+ }
125
+
126
+ const res = await claude.runClaude({
127
+ cwd,
128
+ prompt: claude.PROMPT_TEMPLATE(batch),
129
+ uuid,
130
+ instructions: insPath,
131
+ addDirs,
132
+ allowedTools: cfg.allowed || 'Read Edit Write Bash',
133
+ model: cfg.model,
134
+ timeoutSec: Number(cfg.timeout) || 1800,
135
+ stream: !!card,
136
+ onEvent: card
137
+ ? (kind, text) => {
138
+ if (kind === 'say') card.onSay(text)
139
+ else if (kind === 'tool') card.onTool(text)
140
+ else if (kind === 'final') card.onFinal()
141
+ if (kind !== 'final') card.update(card.acc)
142
+ }
143
+ : null,
144
+ })
145
+
146
+ let final = (res.final || '').trim()
147
+ if (!final) {
148
+ final =
149
+ res.rc === 0
150
+ ? '(完成,但没拿到最终结论)'
151
+ : `⚠️ 处理超时或异常(rc=${res.rc})。稍后重试或换个说法。\n\n错误:${String(res.err)
152
+ .replace(/\n/g, ' ')
153
+ .slice(-300)}`
154
+ }
155
+
156
+ if (card) {
157
+ // 全卡 PUT 失败必须降级发纯文本,保证结论必达
158
+ const ok = await card.finalize(final)
159
+ if (!ok) await reply(profile, chatId, lastMsg, final, { inThread: replyInThread })
160
+ await card.stop()
161
+ } else {
162
+ await reply(profile, chatId, lastMsg, final, { inThread: replyInThread })
163
+ }
164
+
165
+ ledgerAppend(task, chatId, epoch, batch, final)
166
+ process.stderr.write(
167
+ `dispatch: ${task}/${chatId}${threadId ? `/${threadId}` : ''} 完成 rc=${res.rc} ` +
168
+ `${events.length} 条 → ${final.length} 字符\n`,
169
+ )
170
+ return res.rc === 0 ? 0 : 1
171
+ } finally {
172
+ lock.release()
173
+ }
174
+ }
175
+
176
+ async function run(task, opts = {}) {
177
+ const t = loadTask(task)
178
+ if (!t) {
179
+ process.stderr.write(
180
+ `找不到任务 ${task}:${path.join(lrRoot(), task, 'dispatch.yaml')} 不存在\n` +
181
+ `LR_ROOT=${lrRoot()};看现有任务:lark-relay dispatch --list\n`,
182
+ )
183
+ return 2
184
+ }
185
+ if (t.errs.length) {
186
+ process.stderr.write(`任务 ${task} 配置有问题:\n ${t.errs.join('\n ')}\n`)
187
+ return 2
188
+ }
189
+
190
+ const cfg = t.config
191
+ const chats = cfg.chats || []
192
+ // thread=话题回复,交互式一问一答要跟手 → 1s;否则群任务多行需求 → 15s 让人打完
193
+ const debounceMs = (Number(cfg.debounce) || (t.session === 'thread' ? 1 : 15)) * 1000
194
+
195
+ process.stderr.write(
196
+ `dispatch ${task}: app=${cfg.app} ${chats.length} 群 session=${t.session} ` +
197
+ `display=${t.display} model=${cfg.model || '(默认路由)'} 防抖 ${debounceMs / 1000}s\n`,
198
+ )
199
+
200
+ const consumer = `dispatch-${task}`
201
+ if (store.readCursor(consumer, cfg.app) === null) store.seedCursorNow(consumer, cfg.app)
202
+
203
+ const ac = new AbortController()
204
+ let stopping = false
205
+ const onSig = () => {
206
+ stopping = true
207
+ ac.abort()
208
+ }
209
+ process.on('SIGTERM', onSig)
210
+ process.on('SIGINT', onSig)
211
+
212
+ for (;;) {
213
+ const res = await takeBatch({
214
+ app: cfg.app,
215
+ chats,
216
+ name: consumer,
217
+ filter: cfg.filter,
218
+ debounceMs,
219
+ maxWaitMs: 75_000,
220
+ timeoutMs: opts.once ? 300_000 : 12 * 3600 * 1000,
221
+ signal: ac.signal,
222
+ })
223
+
224
+ if (res.events.length) {
225
+ // 一个防抖窗口会吐同群多话题的消息,只按 chat_id 分组会**串话题**
226
+ for (const g of groupByChatThread(res.events)) {
227
+ try {
228
+ await handleGroup(t, g)
229
+ } catch (err) {
230
+ process.stderr.write(`dispatch: ${task} 处理组失败 ${err.stack || err}\n`)
231
+ }
232
+ }
233
+ } else if (opts.once) {
234
+ process.stderr.write(`dispatch: 没等到消息(${res.reason})\n`)
235
+ }
236
+
237
+ if (opts.once || stopping) return 0
238
+ }
239
+ }
240
+
241
+ module.exports = { run, cmdList, ledgerAppend, handleGroup }