lark-relay 0.6.3 → 0.6.4
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 +9 -3
- package/bin/lark-relay.js +6 -3
- package/lib/collect.js +8 -4
- package/lib/filter.js +4 -14
- package/lib/help.js +5 -1
- package/lib/larkcli.js +2 -1
- package/lib/lock.js +23 -20
- package/lib/paths.js +25 -10
- package/lib/render.js +1 -19
- package/lib/status.js +14 -20
- package/lib/store.js +68 -74
- package/lib/take.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,13 +34,14 @@ user 未登录或授权过期不影响 bot 采集,bot 凭据和权限由 `lark-c
|
|
|
34
34
|
- `--app` 是 lark-cli profile 名,`--chats` 是逗号分隔的 chat_id 或 `@文件`(首列)
|
|
35
35
|
- 监听要求 bot 已在群内,查群与验成员的完整命令见 `take --help`
|
|
36
36
|
- `--name` 标识消费断点,默认按 app+chats 派生,并行消费使用不同名字
|
|
37
|
+
- app 和消费名称不能含路径分隔符、控制字符、首尾空白或以点开头
|
|
37
38
|
- `--filter` 是唯一业务过滤入口,使用 jq 表达式;语法错误在启动前拒绝
|
|
38
|
-
-
|
|
39
|
+
- jq 求值失败或输出不完整时中止取批,不输出消息、不提交待交付批次的断点;修正后可重取
|
|
39
40
|
- 默认防抖 5 秒,从第一条消息起最多攒 25 秒,本次取用总超时 12 小时
|
|
40
41
|
- `--render` 支持 `ndjson`(默认)和按群/话题分组的 `text`
|
|
41
42
|
- 参数校验完成后 stderr 输出「监听中」;未知参数、位置参数、缺值和空值均报错
|
|
42
43
|
|
|
43
|
-
退出码:0=有一批/帮助/信号退出,2=参数错,3=消费身份被占用,4=超时无消息
|
|
44
|
+
退出码:0=有一批/帮助/信号退出,1=读取/过滤等运行错误,2=参数错,3=消费身份被占用,4=超时无消息
|
|
44
45
|
`--timeout 0` 立即扫描一次并返回,有消息仍会输出
|
|
45
46
|
|
|
46
47
|
## 断点与保留期
|
|
@@ -55,8 +56,11 @@ user 未登录或授权过期不影响 bot 采集,bot 凭据和权限由 `lark-c
|
|
|
55
56
|
多消费者共享事件存储,各持独立断点
|
|
56
57
|
已有断点始终续接,不按空闲时长过期;首次取用由 `--since now|all` 决定起点,默认 `now`
|
|
57
58
|
需要从新的起点消费时使用新的 `--name`
|
|
59
|
+
两种首次起点都会创建断点,没有事件时也可通过 `status` 查看等待中的消费者
|
|
60
|
+
断点和事件文件读取失败或内容损坏时明确报错,不会当成首次消费或空数据继续推进
|
|
58
61
|
|
|
59
62
|
采集端 `--retain` 指定事件保留天数(默认 3 个自然日,含今天),启动时及每小时回收超期目录
|
|
63
|
+
回收失败写入采集日志,排除文件系统错误后在下一轮重试
|
|
60
64
|
断点只定位仍在盘上的消息,无法恢复已被回收的事件
|
|
61
65
|
|
|
62
66
|
取批使用 at-most-once 交付:攒批期间只推进内存游标,交付前才提交磁盘断点
|
|
@@ -66,8 +70,9 @@ user 未登录或授权过期不影响 bot 采集,bot 凭据和权限由 `lark-c
|
|
|
66
70
|
## 存储取舍
|
|
67
71
|
|
|
68
72
|
- 一事件一文件,同目录写临时文件后 rename,消费者只扫描完整事件文件
|
|
73
|
+
- 写入失败时清理本次临时文件,原断点保持不变
|
|
69
74
|
- 按日期及数值文件名排序;stamp 在采集进程内严格递增
|
|
70
|
-
- 按 message_id
|
|
75
|
+
- 按 message_id 去重,进程启动或跨天后的首条消息前从当天文件恢复集合
|
|
71
76
|
- 每 500ms 扫描保留的全部日期,不引入额外扫描窗口
|
|
72
77
|
- 参数由 Node 内置解析器处理,不兼容已移除的命令或参数
|
|
73
78
|
|
|
@@ -82,6 +87,7 @@ user 未登录或授权过期不影响 bot 采集,bot 凭据和权限由 `lark-c
|
|
|
82
87
|
需要缩减日志时截断文件,不要删除或替换 launchd 持有的文件
|
|
83
88
|
`status` 中 collect running 只说明主进程在跑,各 app 是否连接成功需查看采集日志
|
|
84
89
|
`collect.profiles` 列出已配置 profile,不表示正在采集,也不反映 collect 的 `--exclude`
|
|
90
|
+
profile 查询失败时 `status` 保留本地状态并以 exit 1 返回,错误见 `collect.profileError` 或文本输出
|
|
85
91
|
|
|
86
92
|
## 许可
|
|
87
93
|
|
package/bin/lark-relay.js
CHANGED
|
@@ -41,6 +41,7 @@ async function cmdTake(argv) {
|
|
|
41
41
|
const { validateFilter } = require('../lib/filter')
|
|
42
42
|
const { render } = require('../lib/render')
|
|
43
43
|
const { acquireExclusive } = require('../lib/lock')
|
|
44
|
+
const { namePart } = require('../lib/paths')
|
|
44
45
|
|
|
45
46
|
if (!a.app) {
|
|
46
47
|
process.stderr.write('需要 --app <name>,跑 `lark-relay take --help` 看用法\n')
|
|
@@ -52,6 +53,8 @@ async function cmdTake(argv) {
|
|
|
52
53
|
return 2
|
|
53
54
|
}
|
|
54
55
|
const name = a.name ?? deriveName(a.app, chats)
|
|
56
|
+
namePart(a.app, '--app')
|
|
57
|
+
namePart(name, '--name')
|
|
55
58
|
const since = a.since ?? 'now'
|
|
56
59
|
const mode = a.render ?? 'ndjson'
|
|
57
60
|
if (!['now', 'all'].includes(since)) {
|
|
@@ -78,8 +81,8 @@ async function cmdTake(argv) {
|
|
|
78
81
|
process.stderr.write(`游标身份 "${name}" 被占用(pid ${lock.holder}),并行消费请换 --name\n`)
|
|
79
82
|
return 3
|
|
80
83
|
}
|
|
81
|
-
if (
|
|
82
|
-
store.
|
|
84
|
+
if (store.readCursor(name, a.app) === null) {
|
|
85
|
+
store.seedCursor(name, a.app, since)
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
const fmtDur = (ms) => (ms >= 3600000 ? `${ms / 3600000}h` : `${ms / 1000}s`)
|
|
@@ -119,7 +122,7 @@ async function cmdStatus(argv) {
|
|
|
119
122
|
const { buildStatus, formatStatus } = require('../lib/status')
|
|
120
123
|
const s = await buildStatus()
|
|
121
124
|
process.stdout.write(`${a.json ? JSON.stringify(s, null, 2) : formatStatus(s)}\n`)
|
|
122
|
-
return 0
|
|
125
|
+
return s.collect.profileError ? 1 : 0
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
// 自然排空 stdout,避免大批次经管道输出时被截断
|
package/lib/collect.js
CHANGED
|
@@ -19,9 +19,9 @@ class AppCollector {
|
|
|
19
19
|
this.stopped = false
|
|
20
20
|
this.backoffMs = 1000
|
|
21
21
|
this.healthyTimer = null
|
|
22
|
-
|
|
22
|
+
this.restartTimer = null
|
|
23
23
|
this.day = dayKey()
|
|
24
|
-
this.seen =
|
|
24
|
+
this.seen = null
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
// 收下一条:重复的丢弃,新的落盘。跨天时重置去重集(与 store 按天分目录同构)
|
|
@@ -29,8 +29,10 @@ class AppCollector {
|
|
|
29
29
|
const today = dayKey()
|
|
30
30
|
if (today !== this.day) {
|
|
31
31
|
this.day = today
|
|
32
|
-
this.seen =
|
|
32
|
+
this.seen = null
|
|
33
33
|
}
|
|
34
|
+
// 首条消息前恢复当天去重;读取失败可在下次重试,不阻断其他 app 启动
|
|
35
|
+
if (this.seen === null) this.seen = store.seedIds(this.app, this.day)
|
|
34
36
|
const id = obj?.message_id
|
|
35
37
|
if (id && this.seen.has(id)) return false
|
|
36
38
|
store.writeEvent(this.app, obj, this.seq++)
|
|
@@ -40,6 +42,7 @@ class AppCollector {
|
|
|
40
42
|
|
|
41
43
|
start() {
|
|
42
44
|
if (this.stopped) return
|
|
45
|
+
this.restartTimer = null
|
|
43
46
|
const child = larkcli.spawnConsume(this.app, EVENT_KEY, ['--as', 'bot', '--timeout', '0'])
|
|
44
47
|
this.child = child
|
|
45
48
|
|
|
@@ -88,7 +91,7 @@ class AppCollector {
|
|
|
88
91
|
`—— 这段时间该 app 的消息会永久丢失`,
|
|
89
92
|
)
|
|
90
93
|
// 每 app 一个子进程;某个挂了单独重启,不影响其他
|
|
91
|
-
setTimeout(() => this.start(), this.backoffMs)
|
|
94
|
+
this.restartTimer = setTimeout(() => this.start(), this.backoffMs)
|
|
92
95
|
this.backoffMs = Math.min(this.backoffMs * 2, 60_000)
|
|
93
96
|
}
|
|
94
97
|
|
|
@@ -105,6 +108,7 @@ class AppCollector {
|
|
|
105
108
|
stop() {
|
|
106
109
|
this.stopped = true
|
|
107
110
|
clearTimeout(this.healthyTimer)
|
|
111
|
+
clearTimeout(this.restartTimer)
|
|
108
112
|
// 优雅停止:SIGTERM。禁 SIGKILL —— 否则漏卸载服务端订阅
|
|
109
113
|
if (this.child) this.child.kill('SIGTERM')
|
|
110
114
|
}
|
package/lib/filter.js
CHANGED
|
@@ -35,22 +35,12 @@ function applyFilter(events, expr) {
|
|
|
35
35
|
maxBuffer: 64 * 1024 * 1024,
|
|
36
36
|
})
|
|
37
37
|
} catch (err) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
`warn: --filter 求值失败,本批不过滤(${events.length} 条放行)。表达式:${expr}\n` +
|
|
41
|
-
` ${(err.stderr || err.message || '').trim().split('\n')[0]}\n` +
|
|
42
|
-
` 提示:字段可能不存在,用 ? 和 // 兜底,如 '(.mentions // []) | any(.id == "ou_xxx")'\n`,
|
|
43
|
-
)
|
|
44
|
-
return events
|
|
38
|
+
const reason = (err.stderr || err.message).trim().split('\n')[0]
|
|
39
|
+
throw Object.assign(new Error(`--filter 求值失败:${reason}`), { userFacing: true })
|
|
45
40
|
}
|
|
46
41
|
const verdicts = out.split('\n').filter((l) => l.length > 0)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
process.stderr.write(
|
|
50
|
-
`warn: --filter 产出 ${verdicts.length} 个结果但有 ${events.length} 条事件,` +
|
|
51
|
-
`本批不过滤。表达式应对每条事件求出单个布尔值\n`,
|
|
52
|
-
)
|
|
53
|
-
return events
|
|
42
|
+
if (verdicts.length !== events.length || verdicts.some((v) => v !== 'true' && v !== 'false')) {
|
|
43
|
+
throw Object.assign(new Error('--filter 必须为每条事件返回一个布尔结果'), { userFacing: true })
|
|
54
44
|
}
|
|
55
45
|
return events.filter((_, i) => verdicts[i] === 'true')
|
|
56
46
|
}
|
package/lib/help.js
CHANGED
|
@@ -29,8 +29,10 @@ const TAKE_HELP = `需要 --app 和 --chats,监听要求 bot 已在群内
|
|
|
29
29
|
|
|
30
30
|
断点持续保留,不随空闲时间过期;store 只保留采集端设定天数的消息
|
|
31
31
|
使用新的 --name 可从新的起点消费
|
|
32
|
+
app 和消费名称不能含路径分隔符、控制字符、首尾空白或以点开头
|
|
33
|
+
读取或过滤失败时中止取批并保留待交付批次的断点,修正后可重取
|
|
32
34
|
|
|
33
|
-
退出码:0=有一批/帮助/信号退出,2=参数错,3=身份被占用,4=超时无消息`
|
|
35
|
+
退出码:0=有一批/帮助/信号退出,1=运行错误,2=参数错,3=身份被占用,4=超时无消息`
|
|
34
36
|
|
|
35
37
|
const COLLECT_HELP = `lark-relay-collect -- 常驻收集 im.message.receive_v1 消息并原子落盘
|
|
36
38
|
|
|
@@ -65,7 +67,9 @@ const STATUS_HELP = `lark-relay status -- 采集状态、事件数量和消费
|
|
|
65
67
|
lark-relay status --json
|
|
66
68
|
|
|
67
69
|
消费者以持锁进程是否存活区分在跑/未运行,未运行的断点仍保留
|
|
70
|
+
首次 --since all 等待中也会保留起始断点
|
|
68
71
|
profiles 列出已配置项,不表示正在采集,也不反映 collect 的 --exclude
|
|
72
|
+
profile 查询失败时保留本地状态并以 exit 1 返回,错误见 collect.profileError
|
|
69
73
|
collect running 只表示主进程在跑,各 app 的连接状态需查看采集日志`
|
|
70
74
|
|
|
71
75
|
module.exports = { TAKE_HELP, COLLECT_HELP, STATUS_HELP }
|
package/lib/larkcli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// 唯一外部依赖:spawn lark-cli,通过公开命令读取配置
|
|
4
4
|
const { spawn, execFile } = require('child_process')
|
|
5
|
+
const { namePart } = require('./paths')
|
|
5
6
|
|
|
6
7
|
const CLI = process.env.LARK_CLI_BIN || 'lark-cli'
|
|
7
8
|
|
|
@@ -26,7 +27,7 @@ function run(args, opts = {}) {
|
|
|
26
27
|
// 只枚举配置;bot 认证和权限由 event consume --as bot 检查
|
|
27
28
|
async function listProfiles() {
|
|
28
29
|
const { stdout } = await run(['profile', 'list'])
|
|
29
|
-
return JSON.parse(stdout).map((p) => p.name)
|
|
30
|
+
return JSON.parse(stdout).map((p) => namePart(p.name, 'profile name'))
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
// 保持 stdin 管道打开,stdout NDJSON 由 collect 原子写入
|
package/lib/lock.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// mkdir 与写 pid 之间的空目录保守拒绝,避免抢走正在初始化的锁
|
|
5
5
|
const fs = require('fs')
|
|
6
6
|
const path = require('path')
|
|
7
|
-
const { paths, ensureDir } = require('./paths')
|
|
7
|
+
const { paths, ensureDir, namePart } = require('./paths')
|
|
8
8
|
|
|
9
9
|
function acquireExclusive(key) {
|
|
10
10
|
const dir = lockPathFor(key)
|
|
@@ -15,23 +15,24 @@ function acquireExclusive(key) {
|
|
|
15
15
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
16
16
|
try {
|
|
17
17
|
fs.mkdirSync(dir) // 非 recursive:已存在就抛 EEXIST
|
|
18
|
-
fs.writeFileSync(pidFile, me)
|
|
19
|
-
return mkHandle(dir, pidFile, me)
|
|
20
18
|
} catch (err) {
|
|
21
19
|
if (err.code !== 'EEXIST') throw err
|
|
20
|
+
const holder = readPid(pidFile)
|
|
21
|
+
if (holder === process.pid) return mkHandle(dir, pidFile, me)
|
|
22
|
+
// 空目录可能正处于写 PID 的窗口,只接管已知持有者死亡的锁
|
|
23
|
+
if (holder === null || alive(holder)) return { ok: false, file: dir, holder }
|
|
24
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
25
|
+
continue
|
|
22
26
|
}
|
|
23
|
-
|
|
24
|
-
const holder = readPid(pidFile)
|
|
25
|
-
if (holder === process.pid) return mkHandle(dir, pidFile, me) // 自己已持有,复用
|
|
26
|
-
if (holder === null) {
|
|
27
|
-
// 无法区分初始化中的锁与崩溃留下的空壳,需确认持有者后处理
|
|
28
|
-
return { ok: false, file: dir, holder: null }
|
|
29
|
-
}
|
|
30
|
-
if (holder && alive(holder)) return { ok: false, file: dir, holder }
|
|
31
|
-
// 持有者已死:清掉重试一次
|
|
32
27
|
try {
|
|
33
|
-
fs.
|
|
34
|
-
} catch {
|
|
28
|
+
fs.writeFileSync(pidFile, me)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
try { fs.rmSync(dir, { recursive: true, force: true }) } catch (cleanup) {
|
|
31
|
+
err.message += `; 清理未完成的锁失败:${cleanup.message}`
|
|
32
|
+
}
|
|
33
|
+
throw err
|
|
34
|
+
}
|
|
35
|
+
return mkHandle(dir, pidFile, me)
|
|
35
36
|
}
|
|
36
37
|
return { ok: false, file: dir, holder: readPid(pidFile) }
|
|
37
38
|
}
|
|
@@ -46,7 +47,9 @@ function mkHandle(dir, pidFile, me) {
|
|
|
46
47
|
if (fs.readFileSync(pidFile, 'utf8').trim() === me) {
|
|
47
48
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
48
49
|
}
|
|
49
|
-
} catch {
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err.code !== 'ENOENT') process.stderr.write(`释放锁失败:${err.message}\n`)
|
|
52
|
+
}
|
|
50
53
|
}
|
|
51
54
|
// 注:SIGKILL 不跑 exit 钩子 -> 留下陈旧锁,靠上面的死进程检测清理
|
|
52
55
|
process.once('exit', release)
|
|
@@ -57,8 +60,9 @@ function readPid(file) {
|
|
|
57
60
|
try {
|
|
58
61
|
const n = Number(fs.readFileSync(file, 'utf8').trim())
|
|
59
62
|
return Number.isInteger(n) && n > 0 ? n : null
|
|
60
|
-
} catch {
|
|
61
|
-
return null
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (err.code === 'ENOENT') return null
|
|
65
|
+
throw err
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -71,10 +75,9 @@ function alive(pid) {
|
|
|
71
75
|
}
|
|
72
76
|
}
|
|
73
77
|
|
|
74
|
-
//
|
|
75
|
-
// 自己拼路径会让含特殊字符的 key 匹配不上
|
|
78
|
+
// 与数据目录使用相同的名称,不重写字符造成身份碰撞
|
|
76
79
|
function lockPathFor(key) {
|
|
77
|
-
return path.join(paths.run, `${
|
|
80
|
+
return path.join(paths.run, `${namePart(key, 'lock name')}.lock`)
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
// 谁持有这把锁(活着才算)。与抢锁时的判活口径完全一致
|
package/lib/paths.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// 本地事件与消费断点的路径,删除后无法恢复已回收或离线的消息
|
|
4
4
|
const os = require('os')
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const fs = require('fs')
|
|
@@ -14,26 +14,41 @@ const paths = {
|
|
|
14
14
|
run: path.join(ROOT, 'run'),
|
|
15
15
|
|
|
16
16
|
appDay(app, day) {
|
|
17
|
-
return path.join(ROOT, 'store', app, day)
|
|
17
|
+
return path.join(ROOT, 'store', namePart(app, 'app'), day)
|
|
18
18
|
},
|
|
19
19
|
appStore(app) {
|
|
20
|
-
return path.join(ROOT, 'store', app)
|
|
20
|
+
return path.join(ROOT, 'store', namePart(app, 'app'))
|
|
21
21
|
},
|
|
22
22
|
cursor(name, app) {
|
|
23
|
-
return path.join(ROOT, 'cursors', name, app)
|
|
23
|
+
return path.join(ROOT, 'cursors', namePart(name, 'name'), namePart(app, 'app'))
|
|
24
24
|
},
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function namePart(value, label) {
|
|
28
|
+
if (typeof value !== 'string' || !value || value !== value.trim() ||
|
|
29
|
+
value.startsWith('.') || /[\\/\x00-\x1f\x7f]/.test(value)) {
|
|
30
|
+
throw Object.assign(new Error(`${label} 需要普通名称,不能含路径分隔符、控制字符、首尾空白或以点开头`), {
|
|
31
|
+
userFacing: true, exitCode: 2,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
return value
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
function ensureDir(dir) {
|
|
28
38
|
fs.mkdirSync(dir, { recursive: true })
|
|
29
39
|
return dir
|
|
30
40
|
}
|
|
31
41
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
function readDir(dir, options) {
|
|
43
|
+
try {
|
|
44
|
+
return fs.readdirSync(dir, options)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code === 'ENOENT') return []
|
|
47
|
+
throw err
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// stamp 在进程内严格单调,文件名按 stamp/pid/seq 数值排序
|
|
37
52
|
let lastStamp = 0n
|
|
38
53
|
|
|
39
54
|
function eventFilename(seq) {
|
|
@@ -57,4 +72,4 @@ function dayKeyOffset(days) {
|
|
|
57
72
|
return dayKey(t)
|
|
58
73
|
}
|
|
59
74
|
|
|
60
|
-
module.exports = { paths, ensureDir, eventFilename, dayKey, dayKeyOffset }
|
|
75
|
+
module.exports = { paths, namePart, ensureDir, readDir, eventFilename, dayKey, dayKeyOffset }
|
package/lib/render.js
CHANGED
|
@@ -14,24 +14,6 @@ function senderLabel(e) {
|
|
|
14
14
|
return e.sender_id || 'unknown'
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
// 文本正文:content 可能是字符串,也可能是 lark 原始的 JSON 字符串
|
|
18
|
-
function bodyOf(e) {
|
|
19
|
-
if (typeof e.content === 'string') {
|
|
20
|
-
const s = e.content.trim()
|
|
21
|
-
if (s.startsWith('{')) {
|
|
22
|
-
try {
|
|
23
|
-
const j = JSON.parse(s)
|
|
24
|
-
if (typeof j.text === 'string') return j.text
|
|
25
|
-
} catch {}
|
|
26
|
-
}
|
|
27
|
-
return e.content
|
|
28
|
-
}
|
|
29
|
-
if (e.content && typeof e.content === 'object' && typeof e.content.text === 'string') {
|
|
30
|
-
return e.content.text
|
|
31
|
-
}
|
|
32
|
-
return e.content == null ? '' : String(e.content)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
17
|
function renderNdjson(events) {
|
|
36
18
|
return events.map((e) => JSON.stringify(e)).join('\n')
|
|
37
19
|
}
|
|
@@ -54,7 +36,7 @@ function renderText(events) {
|
|
|
54
36
|
for (const e of list) {
|
|
55
37
|
const line = [`[${fmtTime(e.create_time)}]`, senderLabel(e)]
|
|
56
38
|
if (e.message_type && e.message_type !== 'text') line.push(`<${e.message_type}>`)
|
|
57
|
-
out.push(`${line.join(' ')}: ${
|
|
39
|
+
out.push(`${line.join(' ')}: ${e.content ?? ''}`)
|
|
58
40
|
const meta = []
|
|
59
41
|
meta.push(`message_id=${e.message_id || '?'}`)
|
|
60
42
|
meta.push(`sender_id=${e.sender_id || '?'}`)
|
package/lib/status.js
CHANGED
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
// status:一眼看清全局 —— 谁在跑、各 app 收了多少、游标在哪。
|
|
4
4
|
const { execFileSync } = require('child_process')
|
|
5
|
-
const fs = require('fs')
|
|
6
5
|
const path = require('path')
|
|
7
6
|
const store = require('./store')
|
|
8
7
|
const larkcli = require('./larkcli')
|
|
9
|
-
const { paths } = require('./paths')
|
|
8
|
+
const { paths, readDir } = require('./paths')
|
|
10
9
|
const { holderPid } = require('./lock')
|
|
11
10
|
|
|
12
11
|
function ago(ms) {
|
|
@@ -32,32 +31,22 @@ function serviceState() {
|
|
|
32
31
|
const pid = Number((out.match(/^\s*pid\s*=\s*(\d+)/m) || [])[1]) || null
|
|
33
32
|
const s = (state || '').trim()
|
|
34
33
|
return { state: s === 'running' ? 'active' : s || 'inactive', pid }
|
|
35
|
-
} catch {
|
|
36
|
-
return { state: 'inactive', pid: null }
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err.status === 113) return { state: 'inactive', pid: null }
|
|
36
|
+
throw err
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// 枚举游标:cursors/<name>/<app>。活消费者判定复用 take 的锁,不另造注册表
|
|
41
41
|
function listCursors() {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
names = fs.readdirSync(paths.cursors).filter((n) => !n.startsWith('.'))
|
|
45
|
-
} catch {
|
|
46
|
-
return []
|
|
47
|
-
}
|
|
42
|
+
const names = readDir(paths.cursors, { withFileTypes: true })
|
|
43
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name)
|
|
48
44
|
const out = []
|
|
49
45
|
for (const name of names.sort()) {
|
|
50
46
|
const dir = path.join(paths.cursors, name)
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
if (!fs.statSync(dir).isDirectory()) continue
|
|
54
|
-
files = fs.readdirSync(dir)
|
|
55
|
-
} catch {
|
|
56
|
-
continue
|
|
57
|
-
}
|
|
47
|
+
const files = readDir(dir).filter((app) => !app.startsWith('.'))
|
|
58
48
|
const pid = holderPid(`take-${name}`)
|
|
59
49
|
for (const app of files.sort()) {
|
|
60
|
-
if (app.startsWith('.') || app.endsWith('.tmp')) continue
|
|
61
50
|
const cursor = store.readCursor(name, app)
|
|
62
51
|
if (!cursor) continue
|
|
63
52
|
out.push({ name, app, cursor, pid })
|
|
@@ -70,16 +59,19 @@ async function buildStatus() {
|
|
|
70
59
|
const { state, pid } = serviceState()
|
|
71
60
|
|
|
72
61
|
let profiles = []
|
|
62
|
+
let profileError = null
|
|
73
63
|
try {
|
|
74
64
|
profiles = await larkcli.listProfiles()
|
|
75
|
-
} catch {
|
|
65
|
+
} catch (err) {
|
|
66
|
+
profileError = `读取 profile 失败:${err.message.split('\n')[0]}`
|
|
67
|
+
}
|
|
76
68
|
const byApp = new Map()
|
|
77
69
|
for (const c of listCursors()) {
|
|
78
70
|
if (!byApp.has(c.app)) byApp.set(c.app, [])
|
|
79
71
|
byApp.get(c.app).push(c)
|
|
80
72
|
}
|
|
81
73
|
|
|
82
|
-
const apps = [...new Set([...store.listApps(), ...profiles])].sort()
|
|
74
|
+
const apps = [...new Set([...store.listApps(), ...profiles, ...byApp.keys()])].sort()
|
|
83
75
|
const rows = apps.map((app) => {
|
|
84
76
|
const latest = store.latestEntry(app)
|
|
85
77
|
const ev = latest ? store.readEvent(latest) : null
|
|
@@ -96,6 +88,7 @@ async function buildStatus() {
|
|
|
96
88
|
state,
|
|
97
89
|
pid,
|
|
98
90
|
profiles,
|
|
91
|
+
profileError,
|
|
99
92
|
hint: `sudo launchctl bootstrap system /Library/LaunchDaemons/${COLLECT_LABEL}.plist`,
|
|
100
93
|
},
|
|
101
94
|
apps: rows,
|
|
@@ -123,6 +116,7 @@ function formatStatus(s) {
|
|
|
123
116
|
if (s.collect.profiles.length) bits.push(`${s.collect.profiles.length} configured profiles`)
|
|
124
117
|
if (bits.length) head += ` (${bits.join(', ')})`
|
|
125
118
|
out.push(head)
|
|
119
|
+
if (s.collect.profileError) out.push(` error: ${s.collect.profileError}`)
|
|
126
120
|
if (s.collect.state !== 'active') {
|
|
127
121
|
out.push(' ⚠ collect 没在跑 —— 此刻的消息会永久丢失,不是延迟送达')
|
|
128
122
|
out.push(` ${s.collect.hint}`)
|
package/lib/store.js
CHANGED
|
@@ -3,34 +3,44 @@
|
|
|
3
3
|
// 多消费者共享按天存储的事件,各持消费断点;GC 只回收事件目录
|
|
4
4
|
const fs = require('fs')
|
|
5
5
|
const path = require('path')
|
|
6
|
-
const { paths, ensureDir, eventFilename, dayKey, dayKeyOffset } = require('./paths')
|
|
6
|
+
const { paths, ensureDir, readDir, eventFilename, dayKey, dayKeyOffset } = require('./paths')
|
|
7
7
|
|
|
8
8
|
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
9
9
|
|
|
10
|
+
function writeAtomic(file, text) {
|
|
11
|
+
ensureDir(path.dirname(file))
|
|
12
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp`)
|
|
13
|
+
try {
|
|
14
|
+
fs.writeFileSync(tmp, text)
|
|
15
|
+
fs.renameSync(tmp, file)
|
|
16
|
+
} catch (err) {
|
|
17
|
+
try { fs.unlinkSync(tmp) } catch (cleanup) {
|
|
18
|
+
if (cleanup.code !== 'ENOENT') err.message += `; 清理临时文件失败:${cleanup.message}`
|
|
19
|
+
}
|
|
20
|
+
throw err
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
// 原子落盘:同目录 写 .tmp -> rename。不用 lark-cli --output-dir(非原子)
|
|
11
25
|
function writeEvent(app, obj, seq) {
|
|
12
26
|
const day = dayKey()
|
|
13
|
-
const dir = ensureDir(paths.appDay(app, day))
|
|
14
27
|
const name = eventFilename(seq)
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
fs.writeFileSync(tmp, JSON.stringify(obj))
|
|
18
|
-
fs.renameSync(tmp, dest)
|
|
28
|
+
const dest = path.join(paths.appDay(app, day), name)
|
|
29
|
+
writeAtomic(dest, JSON.stringify(obj))
|
|
19
30
|
return { day, name, file: dest }
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
// 排序键:stamp -> pid -> seq,数值比较(字典序在位宽变化时会错序)。
|
|
23
|
-
const NAME_RE = /^(\d+)_(\d+)_(\d+)$/
|
|
34
|
+
const NAME_RE = /^(\d+)_(\d+)_(\d+)\.json$/
|
|
24
35
|
|
|
25
36
|
function parseKey(name) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return { stamp: BigInt(m[1]), pid: Number(m[2]), seq: Number(m[3]), valid: true }
|
|
37
|
+
const m = NAME_RE.exec(name)
|
|
38
|
+
if (!m) throw new Error(`事件文件名无效:${name}`)
|
|
39
|
+
return { stamp: BigInt(m[1]), pid: Number(m[2]), seq: Number(m[3]) }
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
function isEventName(name) {
|
|
33
|
-
return
|
|
43
|
+
return NAME_RE.test(name)
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
function cmpName(a, b) {
|
|
@@ -61,21 +71,9 @@ function afterCursor(entry, cursor) {
|
|
|
61
71
|
// 扫描盘上保留的全部日期
|
|
62
72
|
function scanAll(app) {
|
|
63
73
|
const out = []
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
days = fs.readdirSync(paths.appStore(app))
|
|
67
|
-
} catch {
|
|
68
|
-
return out
|
|
69
|
-
}
|
|
70
|
-
for (const day of days) {
|
|
74
|
+
for (const day of readDir(paths.appStore(app))) {
|
|
71
75
|
if (!DAY_RE.test(day)) continue
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
names = fs.readdirSync(paths.appDay(app, day))
|
|
75
|
-
} catch {
|
|
76
|
-
continue
|
|
77
|
-
}
|
|
78
|
-
for (const name of names) {
|
|
76
|
+
for (const name of readDir(paths.appDay(app, day))) {
|
|
79
77
|
if (!isEventName(name)) continue // 跳过 .tmp、隐藏文件、手工放进来的杂项
|
|
80
78
|
out.push({ app, day, name, file: path.join(paths.appDay(app, day), name) })
|
|
81
79
|
}
|
|
@@ -85,25 +83,26 @@ function scanAll(app) {
|
|
|
85
83
|
}
|
|
86
84
|
|
|
87
85
|
function readEvent(entry) {
|
|
86
|
+
let raw
|
|
88
87
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
raw = fs.readFileSync(entry.file, 'utf8')
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err.code === 'ENOENT') return null // 扫描后可能被 GC 回收
|
|
91
|
+
throw err
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const event = JSON.parse(raw)
|
|
95
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) throw new Error('需要 JSON 对象')
|
|
96
|
+
return event
|
|
97
|
+
} catch (err) {
|
|
98
|
+
throw Object.assign(new Error(`事件文件损坏:${entry.file}: ${err.message}`), { userFacing: true })
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
// 重启后从当天事件恢复 message_id 去重集合
|
|
98
103
|
function seedIds(app, day = dayKey()) {
|
|
99
104
|
const ids = new Set()
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
names = fs.readdirSync(paths.appDay(app, day))
|
|
103
|
-
} catch {
|
|
104
|
-
return ids
|
|
105
|
-
}
|
|
106
|
-
for (const name of names) {
|
|
105
|
+
for (const name of readDir(paths.appDay(app, day))) {
|
|
107
106
|
if (!isEventName(name)) continue
|
|
108
107
|
const ev = readEvent({ file: path.join(paths.appDay(app, day), name) })
|
|
109
108
|
const id = ev?.message_id
|
|
@@ -113,36 +112,42 @@ function seedIds(app, day = dayKey()) {
|
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
function readCursor(name, app) {
|
|
115
|
+
const file = paths.cursor(name, app)
|
|
116
|
+
let raw
|
|
116
117
|
try {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
raw = fs.readFileSync(file, 'utf8').trim()
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (err.code === 'ENOENT') return null
|
|
121
|
+
throw err
|
|
122
|
+
}
|
|
123
|
+
checkCursor(raw, file)
|
|
124
|
+
return raw
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function checkCursor(key, file) {
|
|
128
|
+
const [day, name, extra] = String(key).split('/')
|
|
129
|
+
const time = Date.parse(day)
|
|
130
|
+
if (!DAY_RE.test(day) || !Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== day ||
|
|
131
|
+
!isEventName(name) || extra !== undefined) {
|
|
132
|
+
throw Object.assign(new Error(`断点格式无效:${file}`), { userFacing: true })
|
|
121
133
|
}
|
|
122
134
|
}
|
|
123
135
|
|
|
124
136
|
function writeCursor(name, app, key) {
|
|
125
137
|
const file = paths.cursor(name, app)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
fs.writeFileSync(tmp, key)
|
|
129
|
-
fs.renameSync(tmp, file)
|
|
138
|
+
checkCursor(key, file)
|
|
139
|
+
writeAtomic(file, key)
|
|
130
140
|
}
|
|
131
141
|
|
|
132
|
-
//
|
|
133
|
-
function
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
142
|
+
// 两种起点都落盘,空闲消费者也能通过断点与锁显示在 status 中
|
|
143
|
+
function seedCursor(name, app, since) {
|
|
144
|
+
const latest = since === 'now' ? latestEntry(app) : null
|
|
145
|
+
const startDay = since === 'now' ? dayKey() : '0000-01-01'
|
|
146
|
+
writeCursor(name, app, latest ? entryKey(latest) : `${startDay}/0_0_0.json`)
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
function countToday(app) {
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
return fs.readdirSync(dir).filter(isEventName).length
|
|
143
|
-
} catch {
|
|
144
|
-
return 0
|
|
145
|
-
}
|
|
150
|
+
return readDir(paths.appDay(app, dayKey())).filter(isEventName).length
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
function latestEntry(app) {
|
|
@@ -151,11 +156,8 @@ function latestEntry(app) {
|
|
|
151
156
|
}
|
|
152
157
|
|
|
153
158
|
function listApps() {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
} catch {
|
|
157
|
-
return []
|
|
158
|
-
}
|
|
159
|
+
return readDir(paths.store, { withFileTypes: true })
|
|
160
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name).sort()
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
// gc:删超期的整个日期目录
|
|
@@ -164,19 +166,11 @@ function gcStore(retainDays) {
|
|
|
164
166
|
for (let i = 0; i < retainDays; i++) keep.add(dayKeyOffset(-i))
|
|
165
167
|
const removed = []
|
|
166
168
|
for (const app of listApps()) {
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
days = fs.readdirSync(paths.appStore(app))
|
|
170
|
-
} catch {
|
|
171
|
-
continue
|
|
172
|
-
}
|
|
173
|
-
for (const day of days) {
|
|
169
|
+
for (const day of readDir(paths.appStore(app))) {
|
|
174
170
|
if (!DAY_RE.test(day)) continue
|
|
175
171
|
if (keep.has(day)) continue
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
removed.push(`${app}/${day}`)
|
|
179
|
-
} catch {}
|
|
172
|
+
fs.rmSync(path.join(paths.appStore(app), day), { recursive: true, force: true })
|
|
173
|
+
removed.push(`${app}/${day}`)
|
|
180
174
|
}
|
|
181
175
|
}
|
|
182
176
|
return removed
|
|
@@ -189,7 +183,7 @@ module.exports = {
|
|
|
189
183
|
seedIds,
|
|
190
184
|
readCursor,
|
|
191
185
|
writeCursor,
|
|
192
|
-
|
|
186
|
+
seedCursor,
|
|
193
187
|
afterCursor,
|
|
194
188
|
entryKey,
|
|
195
189
|
cmpEntry,
|
package/lib/take.js
CHANGED
|
@@ -81,7 +81,7 @@ async function takeBatch(opts) {
|
|
|
81
81
|
for (const entry of entries) {
|
|
82
82
|
cursor = store.entryKey(entry)
|
|
83
83
|
const ev = store.readEvent(entry)
|
|
84
|
-
if (!ev) continue //
|
|
84
|
+
if (!ev) continue // 扫描后已被 GC 回收
|
|
85
85
|
if (ev.type !== 'im.message.receive_v1') continue
|
|
86
86
|
if (chatSet.size && !chatSet.has(ev.chat_id)) continue
|
|
87
87
|
fresh.push(ev)
|