lark-relay 0.4.4 → 0.4.6
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 +14 -60
- package/bin/lark-relay.js +4 -30
- package/lib/collect.js +34 -25
- package/lib/filter.js +1 -1
- package/lib/help.js +18 -70
- package/lib/lock.js +3 -19
- package/lib/log.js +164 -0
- package/lib/paths.js +0 -4
- package/lib/status.js +22 -43
- package/lib/store.js +1 -24
- package/lib/take.js +2 -2
- package/package.json +2 -2
- 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/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 }
|