lark-relay 0.6.3 → 0.6.5

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 CHANGED
@@ -34,13 +34,15 @@ 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
- - 消息数据导致 jq 求值失败时,该次扫描到的消息告警并放行,避免阻塞断点
39
+ - jq 求值失败或输出不完整时中止取批,不输出消息、不提交待交付批次的断点;修正后可重取
40
+ - 损坏的事件文件跳过并告警,不中止取批 —— 硬失败会把断点永久停在它前面
39
41
  - 默认防抖 5 秒,从第一条消息起最多攒 25 秒,本次取用总超时 12 小时
40
42
  - `--render` 支持 `ndjson`(默认)和按群/话题分组的 `text`
41
43
  - 参数校验完成后 stderr 输出「监听中」;未知参数、位置参数、缺值和空值均报错
42
44
 
43
- 退出码:0=有一批/帮助/信号退出,2=参数错,3=消费身份被占用,4=超时无消息
45
+ 退出码:0=有一批/帮助/信号退出,1=读取/过滤等运行错误,2=参数错,3=消费身份被占用,4=超时无消息
44
46
  `--timeout 0` 立即扫描一次并返回,有消息仍会输出
45
47
 
46
48
  ## 断点与保留期
@@ -55,8 +57,13 @@ user 未登录或授权过期不影响 bot 采集,bot 凭据和权限由 `lark-c
55
57
  多消费者共享事件存储,各持独立断点
56
58
  已有断点始终续接,不按空闲时长过期;首次取用由 `--since now|all` 决定起点,默认 `now`
57
59
  需要从新的起点消费时使用新的 `--name`
60
+ 两种首次起点都会创建断点,没有事件时也可通过 `status` 查看等待中的消费者
61
+ 断点和事件文件读取失败时明确报错,不会当成首次消费或空数据继续推进
62
+ 损坏的事件文件不中止 `take`、采集或 `status`:三者都跳过它并告警,断点越过它继续。
63
+ 那几条消息永久丢失 —— `status` 标记该 app 并返回非零,复核后删除坏文件
58
64
 
59
65
  采集端 `--retain` 指定事件保留天数(默认 3 个自然日,含今天),启动时及每小时回收超期目录
66
+ 回收失败写入采集日志,排除文件系统错误后在下一轮重试
60
67
  断点只定位仍在盘上的消息,无法恢复已被回收的事件
61
68
 
62
69
  取批使用 at-most-once 交付:攒批期间只推进内存游标,交付前才提交磁盘断点
@@ -66,8 +73,10 @@ user 未登录或授权过期不影响 bot 采集,bot 凭据和权限由 `lark-c
66
73
  ## 存储取舍
67
74
 
68
75
  - 一事件一文件,同目录写临时文件后 rename,消费者只扫描完整事件文件
76
+ - 写入失败时清理本次临时文件,原断点保持不变
69
77
  - 按日期及数值文件名排序;stamp 在采集进程内严格递增
70
- - 按 message_id 去重,启动时从当天文件恢复集合,跨天重置
78
+ - 按 message_id 去重,进程启动或跨天后的首条消息前从当天文件恢复集合
79
+ - 损坏文件在恢复去重时跳过并告警,其 message_id 不在集合内,该条重投会重复落盘
71
80
  - 每 500ms 扫描保留的全部日期,不引入额外扫描窗口
72
81
  - 参数由 Node 内置解析器处理,不兼容已移除的命令或参数
73
82
 
@@ -82,6 +91,8 @@ user 未登录或授权过期不影响 bot 采集,bot 凭据和权限由 `lark-c
82
91
  需要缩减日志时截断文件,不要删除或替换 launchd 持有的文件
83
92
  `status` 中 collect running 只说明主进程在跑,各 app 是否连接成功需查看采集日志
84
93
  `collect.profiles` 列出已配置 profile,不表示正在采集,也不反映 collect 的 `--exclude`
94
+ profile 查询失败时 `status` 保留本地状态并以 exit 1 返回,错误见 `collect.profileError` 或文本输出
95
+ 某个 app 有损坏事件时同样以 exit 1 返回,错误见该 app 的 `error` 字段或文本中的 ⚠ 标记
85
96
 
86
97
  ## 许可
87
98
 
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 (since === 'now' && store.readCursor(name, a.app) === null) {
82
- store.seedCursorNow(name, a.app)
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`)
@@ -101,6 +104,14 @@ async function cmdTake(argv) {
101
104
  timeoutMs,
102
105
  signal: ac.signal,
103
106
  })
107
+ // 跳过的坏文件必须显式说出来:它们是永久丢掉的消息,不是延迟送达。
108
+ // 退出码保持批次语义,否则调用方会把已交付的一批当成失败丢弃
109
+ if (res.skipped.length) {
110
+ process.stderr.write(
111
+ `warn: 跳过 ${res.skipped.length} 个损坏的事件文件,这些消息永久丢失:` +
112
+ `${res.skipped.join(' ')}\n 用 lark-relay status 复核,确认后删除坏文件\n`,
113
+ )
114
+ }
104
115
  if (!res.events.length) {
105
116
  process.stderr.write(`没等到消息(${res.reason}),再起一个继续\n`)
106
117
  return res.reason === 'timeout' ? 4 : 0
@@ -119,7 +130,8 @@ async function cmdStatus(argv) {
119
130
  const { buildStatus, formatStatus } = require('../lib/status')
120
131
  const s = await buildStatus()
121
132
  process.stdout.write(`${a.json ? JSON.stringify(s, null, 2) : formatStatus(s)}\n`)
122
- return 0
133
+ // 输出完整,但异常仍以退出码透出,便于脚本发现
134
+ return s.collect.profileError || s.apps.some((r) => r.error) ? 1 : 0
123
135
  }
124
136
 
125
137
  // 自然排空 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 = store.seedIds(app, this.day)
24
+ this.seen = null
25
25
  }
26
26
 
27
27
  // 收下一条:重复的丢弃,新的落盘。跨天时重置去重集(与 store 按天分目录同构)
@@ -29,7 +29,20 @@ class AppCollector {
29
29
  const today = dayKey()
30
30
  if (today !== this.day) {
31
31
  this.day = today
32
- this.seen = new Set()
32
+ this.seen = null
33
+ }
34
+ // 首条消息前恢复当天去重;读不出的文件跳过,不阻断落盘
35
+ if (this.seen === null) {
36
+ const { ids, skipped } = store.seedIds(this.app, this.day)
37
+ this.seen = ids
38
+ // 跳过的文件其 message_id 不在集合内,那几条重投会重复落盘;
39
+ // 换来的是这个 app 继续采集,而不是每条消息都在 seed 上再失败一次
40
+ if (skipped.length) {
41
+ logLine(
42
+ `[${this.app}] warn: 恢复去重不完整,跳过 ${skipped.join(' ')};` +
43
+ `这些消息的 message_id 不在去重集合内,重投会重复落盘`,
44
+ )
45
+ }
33
46
  }
34
47
  const id = obj?.message_id
35
48
  if (id && this.seen.has(id)) return false
@@ -40,6 +53,7 @@ class AppCollector {
40
53
 
41
54
  start() {
42
55
  if (this.stopped) return
56
+ this.restartTimer = null
43
57
  const child = larkcli.spawnConsume(this.app, EVENT_KEY, ['--as', 'bot', '--timeout', '0'])
44
58
  this.child = child
45
59
 
@@ -88,7 +102,7 @@ class AppCollector {
88
102
  `—— 这段时间该 app 的消息会永久丢失`,
89
103
  )
90
104
  // 每 app 一个子进程;某个挂了单独重启,不影响其他
91
- setTimeout(() => this.start(), this.backoffMs)
105
+ this.restartTimer = setTimeout(() => this.start(), this.backoffMs)
92
106
  this.backoffMs = Math.min(this.backoffMs * 2, 60_000)
93
107
  }
94
108
 
@@ -105,6 +119,7 @@ class AppCollector {
105
119
  stop() {
106
120
  this.stopped = true
107
121
  clearTimeout(this.healthyTimer)
122
+ clearTimeout(this.restartTimer)
108
123
  // 优雅停止:SIGTERM。禁 SIGKILL —— 否则漏卸载服务端订阅
109
124
  if (this.child) this.child.kill('SIGTERM')
110
125
  }
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
- process.stderr.write(
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
- // 求值行数应与事件数一致;不一致说明表达式产出多值,保守放行全部并 warn
48
- if (verdicts.length !== events.length) {
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,11 @@ const TAKE_HELP = `需要 --app 和 --chats,监听要求 bot 已在群内
29
29
 
30
30
  断点持续保留,不随空闲时间过期;store 只保留采集端设定天数的消息
31
31
  使用新的 --name 可从新的起点消费
32
+ app 和消费名称不能含路径分隔符、控制字符、首尾空白或以点开头
33
+ 过滤失败时中止取批并保留待交付批次的断点,修正后可重取
34
+ 损坏的事件文件跳过并在 stderr 告警,断点越过它继续 —— 那几条消息永久丢失
32
35
 
33
- 退出码:0=有一批/帮助/信号退出,2=参数错,3=身份被占用,4=超时无消息`
36
+ 退出码:0=有一批/帮助/信号退出,1=运行错误,2=参数错,3=身份被占用,4=超时无消息`
34
37
 
35
38
  const COLLECT_HELP = `lark-relay-collect -- 常驻收集 im.message.receive_v1 消息并原子落盘
36
39
 
@@ -65,7 +68,10 @@ const STATUS_HELP = `lark-relay status -- 采集状态、事件数量和消费
65
68
  lark-relay status --json
66
69
 
67
70
  消费者以持锁进程是否存活区分在跑/未运行,未运行的断点仍保留
71
+ 首次 --since all 等待中也会保留起始断点
68
72
  profiles 列出已配置项,不表示正在采集,也不反映 collect 的 --exclude
73
+ profile 查询失败时保留本地状态并以 exit 1 返回,错误见 collect.profileError
74
+ 扫描全部保留日期,任意位置的损坏事件都标记 ⚠ 并以 exit 1 返回,其余 app 照常显示
69
75
  collect running 只表示主进程在跑,各 app 的连接状态需查看采集日志`
70
76
 
71
77
  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.rmSync(dir, { recursive: true, force: true })
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
- // 锁目录位置。**外部判活必须走这个函数**:sanitize 会把非 [A-Za-z0-9._-] 换成 `_`,
75
- // 自己拼路径会让含特殊字符的 key 匹配不上
78
+ // 与数据目录使用相同的名称,不重写字符造成身份碰撞
76
79
  function lockPathFor(key) {
77
- return path.join(paths.run, `${String(key).replace(/[^A-Za-z0-9._-]/g, '_')}.lock`)
80
+ return path.join(paths.run, `${namePart(key, 'lock name')}.lock`)
78
81
  }
79
82
 
80
83
  // 谁持有这把锁(活着才算)。与抢锁时的判活口径完全一致
@@ -84,4 +87,4 @@ function holderPid(key) {
84
87
  return alive(pid) ? pid : null
85
88
  }
86
89
 
87
- module.exports = { acquireExclusive, lockPathFor, holderPid }
90
+ module.exports = { acquireExclusive, holderPid }
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
- // 事件文件名 <stamp>_<pid>_<seq>.json -- stamp 前缀让字典序 == 写入序,
33
- // pid+seq 保证同一 stamp 内多进程/多事件不撞名。
34
- //
35
- // ⚠️ stamp 必须**严格单调递增**,否则游标会跨过后写的事件 -> 永久丢消息。
36
- // 墙上时间只作下界,计数器保证单调。见 docs/lessons.md#单调-stamp
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
@@ -8,30 +8,12 @@ function fmtTime(ts) {
8
8
  return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
9
9
  }
10
10
 
11
+ // 事件体里没有发送者姓名:mentions 只装被 @ 的人,实测 861 条真实事件
12
+ // 无一条把 sender_id 收进 mentions。要姓名需另查通讯录,不在渲染层做
11
13
  function senderLabel(e) {
12
- const mention = (e.mentions || []).find((m) => m && m.id === e.sender_id)
13
- if (mention && mention.name) return mention.name
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(' ')}: ${bodyOf(e)}`)
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 || '?'}`)
@@ -79,4 +61,4 @@ function render(events, mode) {
79
61
  return renderNdjson(events)
80
62
  }
81
63
 
82
- module.exports = { render, renderText }
64
+ module.exports = { render }
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
- let names
43
- try {
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
- let files
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,23 +59,41 @@ 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
- const latest = store.latestEntry(app)
85
- const ev = latest ? store.readEvent(latest) : null
76
+ // 扫全量:坏文件不在最新位置时同样要报出来 —— take 会跳过它,
77
+ // 而 status 是唯一的排障入口。实测本机 905 个事件全读约 23ms
78
+ const corrupt = []
79
+ let latestMs = null
80
+ for (const entry of store.scanAll(app)) {
81
+ let ev
82
+ try {
83
+ ev = store.readEvent(entry)
84
+ } catch (err) {
85
+ if (!err.corrupt) throw err
86
+ corrupt.push(`${entry.day}/${entry.name}`)
87
+ continue
88
+ }
89
+ // 最新时间取最后一条读得出的事件,坏文件不该让它变成「—」
90
+ if (ev) latestMs = Number(ev.timestamp) || latestMs
91
+ }
86
92
  return {
87
93
  app,
88
94
  today: store.countToday(app),
89
- latestMs: Number(ev?.timestamp) || null,
95
+ latestMs,
96
+ error: corrupt.length ? `${corrupt.length} 个事件文件损坏:${corrupt.join(' ')}` : null,
90
97
  consumers: byApp.get(app) || [],
91
98
  }
92
99
  })
@@ -96,6 +103,7 @@ async function buildStatus() {
96
103
  state,
97
104
  pid,
98
105
  profiles,
106
+ profileError,
99
107
  hint: `sudo launchctl bootstrap system /Library/LaunchDaemons/${COLLECT_LABEL}.plist`,
100
108
  },
101
109
  apps: rows,
@@ -123,6 +131,7 @@ function formatStatus(s) {
123
131
  if (s.collect.profiles.length) bits.push(`${s.collect.profiles.length} configured profiles`)
124
132
  if (bits.length) head += ` (${bits.join(', ')})`
125
133
  out.push(head)
134
+ if (s.collect.profileError) out.push(` error: ${s.collect.profileError}`)
126
135
  if (s.collect.state !== 'active') {
127
136
  out.push(' ⚠ collect 没在跑 —— 此刻的消息会永久丢失,不是延迟送达')
128
137
  out.push(` ${s.collect.hint}`)
@@ -133,14 +142,16 @@ function formatStatus(s) {
133
142
  for (const r of s.apps) {
134
143
  if (!r.consumers.length) {
135
144
  out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}—(无消费者)`)
136
- continue
137
- }
138
- // 消费者各占一行:「谁在跑」是排障第一问,挤在一行里 ●/○ 会被淹掉
139
- out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}`.trimEnd())
140
- for (const c of r.consumers) {
141
- const note = c.pid ? `在跑 (pid ${c.pid})` : '未运行 (断点保留)'
142
- out.push(` ${w(c.name, 24)}${c.pid ? '●' : '○'} ${note}`)
145
+ } else {
146
+ // 消费者各占一行:「谁在跑」是排障第一问,挤在一行里 ●/○ 会被淹掉
147
+ out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}`.trimEnd())
148
+ for (const c of r.consumers) {
149
+ const note = c.pid ? `在跑 (pid ${c.pid})` : '未运行 (断点保留)'
150
+ out.push(` ${w(c.name, 24)}${c.pid ? '●' : '○'} ${note}`)
151
+ }
143
152
  }
153
+ // 坏文件会让该 app 的采集跳过去重、take 直接失败,必须显式暴露
154
+ if (r.error) out.push(` ⚠ ${r.error}`)
144
155
  }
145
156
  out.push('')
146
157
  out.push(
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 tmp = path.join(dir, `.${name}.tmp`)
16
- const dest = path.join(dir, name)
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 base = name.endsWith('.json') ? name.slice(0, -5) : name
27
- const m = NAME_RE.exec(base)
28
- if (!m) return { stamp: 0n, pid: 0, seq: 0, valid: false }
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 name.endsWith('.json') && NAME_RE.test(name.slice(0, -5))
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
- let days
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
- let names
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,64 +83,88 @@ function scanAll(app) {
85
83
  }
86
84
 
87
85
  function readEvent(entry) {
86
+ let raw
88
87
  try {
89
- const raw = fs.readFileSync(entry.file, 'utf8')
90
- if (!raw) return null
91
- return JSON.parse(raw)
92
- } catch {
93
- return null // 半成品或坏文件:跳过,不让一条坏数据卡死消费
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
+ // corrupt:盘上数据坏了,重试不会好转。与 EACCES 等瞬时错误区分开,
99
+ // 让只读概览和采集能跳过单个坏文件继续跑;take 仍然硬失败
100
+ throw Object.assign(new Error(`事件文件损坏:${entry.file}: ${err.message}`), {
101
+ userFacing: true, corrupt: true,
102
+ })
94
103
  }
95
104
  }
96
105
 
97
- // 重启后从当天事件恢复 message_id 去重集合
106
+ // 重启后从当天事件恢复 message_id 去重集合。
107
+ // 去重是尽力而为的优化,不是落盘的前置条件:任何读不出的文件都跳过并回报,
108
+ // 最坏结果是那几条重投时重复落盘,比为它们丢掉后续所有消息轻得多
98
109
  function seedIds(app, day = dayKey()) {
99
110
  const ids = new Set()
111
+ const skipped = []
112
+ const dir = paths.appDay(app, day)
100
113
  let names
101
114
  try {
102
- names = fs.readdirSync(paths.appDay(app, day))
103
- } catch {
104
- return ids
115
+ names = readDir(dir) // ENOENT 已按空目录处理,这里兜的是 EACCES 等
116
+ } catch (err) {
117
+ return { ids, skipped: [`整个目录读不出(${err.code || err.message})`] }
105
118
  }
106
119
  for (const name of names) {
107
120
  if (!isEventName(name)) continue
108
- const ev = readEvent({ file: path.join(paths.appDay(app, day), name) })
109
- const id = ev?.message_id
110
- if (id) ids.add(id)
121
+ try {
122
+ const ev = readEvent({ file: path.join(dir, name) })
123
+ if (ev?.message_id) ids.add(ev.message_id)
124
+ } catch {
125
+ skipped.push(name)
126
+ }
111
127
  }
112
- return ids
128
+ return { ids, skipped }
113
129
  }
114
130
 
115
131
  function readCursor(name, app) {
132
+ const file = paths.cursor(name, app)
133
+ let raw
116
134
  try {
117
- const raw = fs.readFileSync(paths.cursor(name, app), 'utf8').trim()
118
- return raw || null
119
- } catch {
120
- return null
135
+ raw = fs.readFileSync(file, 'utf8').trim()
136
+ } catch (err) {
137
+ if (err.code === 'ENOENT') return null
138
+ throw err
139
+ }
140
+ checkCursor(raw, file)
141
+ return raw
142
+ }
143
+
144
+ function checkCursor(key, file) {
145
+ const [day, name, extra] = String(key).split('/')
146
+ const time = Date.parse(day)
147
+ if (!DAY_RE.test(day) || !Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== day ||
148
+ !isEventName(name) || extra !== undefined) {
149
+ throw Object.assign(new Error(`断点格式无效:${file}`), { userFacing: true })
121
150
  }
122
151
  }
123
152
 
124
153
  function writeCursor(name, app, key) {
125
154
  const file = paths.cursor(name, app)
126
- ensureDir(path.dirname(file))
127
- const tmp = `${file}.tmp`
128
- fs.writeFileSync(tmp, key)
129
- fs.renameSync(tmp, file)
155
+ checkCursor(key, file)
156
+ writeAtomic(file, key)
130
157
  }
131
158
 
132
- // --since now:游标落到当前最新,不重放历史
133
- function seedCursorNow(name, app) {
134
- const all = scanAll(app)
135
- if (all.length) writeCursor(name, app, entryKey(all[all.length - 1]))
136
- else writeCursor(name, app, `${dayKey()}/0_0_0.json`)
159
+ // 两种起点都落盘,空闲消费者也能通过断点与锁显示在 status 中
160
+ function seedCursor(name, app, since) {
161
+ const latest = since === 'now' ? latestEntry(app) : null
162
+ const startDay = since === 'now' ? dayKey() : '0000-01-01'
163
+ writeCursor(name, app, latest ? entryKey(latest) : `${startDay}/0_0_0.json`)
137
164
  }
138
165
 
139
166
  function countToday(app) {
140
- const dir = paths.appDay(app, dayKey())
141
- try {
142
- return fs.readdirSync(dir).filter(isEventName).length
143
- } catch {
144
- return 0
145
- }
167
+ return readDir(paths.appDay(app, dayKey())).filter(isEventName).length
146
168
  }
147
169
 
148
170
  function latestEntry(app) {
@@ -151,11 +173,8 @@ function latestEntry(app) {
151
173
  }
152
174
 
153
175
  function listApps() {
154
- try {
155
- return fs.readdirSync(paths.store).filter((n) => !n.startsWith('.')).sort()
156
- } catch {
157
- return []
158
- }
176
+ return readDir(paths.store, { withFileTypes: true })
177
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name).sort()
159
178
  }
160
179
 
161
180
  // gc:删超期的整个日期目录
@@ -164,19 +183,11 @@ function gcStore(retainDays) {
164
183
  for (let i = 0; i < retainDays; i++) keep.add(dayKeyOffset(-i))
165
184
  const removed = []
166
185
  for (const app of listApps()) {
167
- let days
168
- try {
169
- days = fs.readdirSync(paths.appStore(app))
170
- } catch {
171
- continue
172
- }
173
- for (const day of days) {
186
+ for (const day of readDir(paths.appStore(app))) {
174
187
  if (!DAY_RE.test(day)) continue
175
188
  if (keep.has(day)) continue
176
- try {
177
- fs.rmSync(path.join(paths.appStore(app), day), { recursive: true, force: true })
178
- removed.push(`${app}/${day}`)
179
- } catch {}
189
+ fs.rmSync(path.join(paths.appStore(app), day), { recursive: true, force: true })
190
+ removed.push(`${app}/${day}`)
180
191
  }
181
192
  }
182
193
  return removed
@@ -189,13 +200,11 @@ module.exports = {
189
200
  seedIds,
190
201
  readCursor,
191
202
  writeCursor,
192
- seedCursorNow,
203
+ seedCursor,
193
204
  afterCursor,
194
205
  entryKey,
195
- cmpEntry,
196
206
  cmpName,
197
207
  countToday,
198
- latestEntry,
199
208
  listApps,
200
209
  gcStore,
201
210
  }
package/lib/take.js CHANGED
@@ -46,8 +46,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
46
46
 
47
47
  /**
48
48
  * 阻塞等一批事件。
49
- * @returns {Promise<{events:array, reason:string, cursor:string|null}>}
49
+ * @returns {Promise<{events:array, reason:string, cursor:string|null, skipped:string[]}>}
50
50
  * reason: batch | timeout | signal
51
+ * skipped: 跳过的损坏事件文件名,调用方负责告警
51
52
  */
52
53
  async function takeBatch(opts) {
53
54
  const {
@@ -65,6 +66,7 @@ async function takeBatch(opts) {
65
66
  const chatSet = new Set(chats)
66
67
  const startedAt = Date.now()
67
68
  const batch = []
69
+ const skipped = []
68
70
  let firstAt = null
69
71
  let lastNewAt = null
70
72
  let cursor = store.readCursor(name, app)
@@ -73,15 +75,24 @@ async function takeBatch(opts) {
73
75
  // 信号退出也交付已攒的批次,提交时机与正常退出一致
74
76
  if (signal && signal.aborted) {
75
77
  if (batch.length && cursor) store.writeCursor(name, app, cursor)
76
- return { events: batch, reason: batch.length ? 'batch' : 'signal', cursor }
78
+ return { events: batch, reason: batch.length ? 'batch' : 'signal', cursor, skipped }
77
79
  }
78
80
 
79
81
  const entries = store.scanAll(app).filter((e) => store.afterCursor(e, cursor))
80
82
  const fresh = []
81
83
  for (const entry of entries) {
82
84
  cursor = store.entryKey(entry)
83
- const ev = store.readEvent(entry)
84
- if (!ev) continue // 半成品/坏文件:跳过
85
+ let ev
86
+ try {
87
+ ev = store.readEvent(entry)
88
+ } catch (err) {
89
+ // 损坏是永久错误:硬失败会让断点停在它前面,把它之后的好消息也永久取不到。
90
+ // 跳过并推进,损失是这一条;由调用方告警,status 也会标记它
91
+ if (!err.corrupt) throw err
92
+ skipped.push(`${entry.day}/${entry.name}`)
93
+ continue
94
+ }
95
+ if (!ev) continue // 扫描后已被 GC 回收
85
96
  if (ev.type !== 'im.message.receive_v1') continue
86
97
  if (chatSet.size && !chatSet.has(ev.chat_id)) continue
87
98
  fresh.push(ev)
@@ -108,13 +119,13 @@ async function takeBatch(opts) {
108
119
  if (quiet || capped) {
109
120
  // at-most-once:交付前提交,不等待业务处理确认
110
121
  if (cursor) store.writeCursor(name, app, cursor)
111
- return { events: batch, reason: 'batch', cursor }
122
+ return { events: batch, reason: 'batch', cursor, skipped }
112
123
  }
113
124
  }
114
125
 
115
126
  if (Date.now() - startedAt >= timeoutMs) {
116
127
  if (batch.length && cursor) store.writeCursor(name, app, cursor)
117
- return { events: batch, reason: batch.length ? 'batch' : 'timeout', cursor }
128
+ return { events: batch, reason: batch.length ? 'batch' : 'timeout', cursor, skipped }
118
129
  }
119
130
 
120
131
  await sleep(pollMs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",