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/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 }