lark-relay 0.6.4 → 0.6.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 CHANGED
@@ -23,9 +23,9 @@ lark-relay take --app <app> --chats oc_xxx --render text
23
23
  lark-relay status
24
24
  ```
25
25
 
26
- 采集在启动时读取 `lark-cli profile list`,每个未被 `--exclude` 排除的 profile 启动一个 bot consumer
26
+ 采集在启动时读取 `lark-cli profile list`,每个 profile 启动一个 bot consumer
27
27
  user 未登录或授权过期不影响 bot 采集,bot 凭据和权限由 `lark-cli event consume --as bot` 检查
28
- 认证或连接失败会记录 CLI 错误并独立退避重试,无需采集的 profile 用 `--exclude` 排除
28
+ 认证或连接失败会记录 CLI 错误并独立退避重试
29
29
  新增或移除 profile 后需重启采集
30
30
  同一 app 的事件总线只允许一个实例,本地互斥锁按 `LARK_RELAY_HOME` 隔离
31
31
 
@@ -37,6 +37,7 @@ user 未登录或授权过期不影响 bot 采集,bot 凭据和权限由 `lark-c
37
37
  - app 和消费名称不能含路径分隔符、控制字符、首尾空白或以点开头
38
38
  - `--filter` 是唯一业务过滤入口,使用 jq 表达式;语法错误在启动前拒绝
39
39
  - jq 求值失败或输出不完整时中止取批,不输出消息、不提交待交付批次的断点;修正后可重取
40
+ - 损坏的事件文件跳过并告警,不中止取批 —— 硬失败会把断点永久停在它前面
40
41
  - 默认防抖 5 秒,从第一条消息起最多攒 25 秒,本次取用总超时 12 小时
41
42
  - `--render` 支持 `ndjson`(默认)和按群/话题分组的 `text`
42
43
  - 参数校验完成后 stderr 输出「监听中」;未知参数、位置参数、缺值和空值均报错
@@ -57,7 +58,9 @@ user 未登录或授权过期不影响 bot 采集,bot 凭据和权限由 `lark-c
57
58
  已有断点始终续接,不按空闲时长过期;首次取用由 `--since now|all` 决定起点,默认 `now`
58
59
  需要从新的起点消费时使用新的 `--name`
59
60
  两种首次起点都会创建断点,没有事件时也可通过 `status` 查看等待中的消费者
60
- 断点和事件文件读取失败或内容损坏时明确报错,不会当成首次消费或空数据继续推进
61
+ 断点和事件文件读取失败时明确报错,不会当成首次消费或空数据继续推进
62
+ 损坏的事件文件不中止 `take`、采集或 `status`:三者都跳过它并告警,断点越过它继续。
63
+ 那几条消息永久丢失 —— `status` 标记该 app 并返回非零,复核后删除坏文件
61
64
 
62
65
  采集端 `--retain` 指定事件保留天数(默认 3 个自然日,含今天),启动时及每小时回收超期目录
63
66
  回收失败写入采集日志,排除文件系统错误后在下一轮重试
@@ -73,21 +76,23 @@ user 未登录或授权过期不影响 bot 采集,bot 凭据和权限由 `lark-c
73
76
  - 写入失败时清理本次临时文件,原断点保持不变
74
77
  - 按日期及数值文件名排序;stamp 在采集进程内严格递增
75
78
  - 按 message_id 去重,进程启动或跨天后的首条消息前从当天文件恢复集合
79
+ - 损坏文件在恢复去重时跳过并告警,其 message_id 不在集合内,该条重投会重复落盘
76
80
  - 每 500ms 扫描保留的全部日期,不引入额外扫描窗口
77
81
  - 参数由 Node 内置解析器处理,不兼容已移除的命令或参数
78
82
 
79
83
  ## macOS 常驻部署
80
84
 
81
85
  - 使用 LaunchDaemon + UserName,开机即起,不依赖 GUI 登录
82
- - ProgramArguments 写可执行文件的绝对路径,显式设置 HOME/PATH/UTF-8 locale
86
+ - ProgramArguments 写可执行文件的绝对路径,显式设置 HOME/PATH
83
87
  - KeepAlive 使用 SuccessfulExit=false;日志路径父目录须预先创建
84
88
  - 代码升级后用 `kickstart -k`,修改 plist 后用 `bootout`、等 label 释放、再 `bootstrap`
85
89
 
86
90
  日志直写 stderr,每行带 ISO 时间戳,不做轮转
87
91
  需要缩减日志时截断文件,不要删除或替换 launchd 持有的文件
88
92
  `status` 中 collect running 只说明主进程在跑,各 app 是否连接成功需查看采集日志
89
- `collect.profiles` 列出已配置 profile,不表示正在采集,也不反映 collect 的 `--exclude`
93
+ `collect.profiles` 列出已配置 profile,不表示正在采集
90
94
  profile 查询失败时 `status` 保留本地状态并以 exit 1 返回,错误见 `collect.profileError` 或文本输出
95
+ 某个 app 有损坏事件时同样以 exit 1 返回,错误见该 app 的 `error` 字段或文本中的 ⚠ 标记
91
96
 
92
97
  ## 许可
93
98
 
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
- require('../lib/utf8').ensureUtf8()
5
4
  process.title = 'lark-relay-collect'
6
5
 
7
6
  const { parseArgs, numberArg } = require('../lib/args')
@@ -9,7 +8,7 @@ const { COLLECT_HELP } = require('../lib/help')
9
8
  const VERSION = require('../package.json').version
10
9
 
11
10
  async function main() {
12
- const a = parseArgs(process.argv.slice(2), { keys: ['exclude', 'retain'], flags: ['version'] })
11
+ const a = parseArgs(process.argv.slice(2), { keys: ['retain'], flags: ['version'] })
13
12
  if (a.help || a.version) {
14
13
  process.stdout.write(`${a.version ? VERSION : COLLECT_HELP}\n`)
15
14
  return 0
@@ -20,10 +19,7 @@ async function main() {
20
19
  return 2
21
20
  }
22
21
  const { runCollect } = require('../lib/collect')
23
- return runCollect({
24
- exclude: a.exclude ? a.exclude.split(',').map((s) => s.trim()).filter(Boolean) : [],
25
- retain,
26
- })
22
+ return runCollect({ retain })
27
23
  }
28
24
 
29
25
  main()
package/bin/lark-relay.js CHANGED
@@ -1,8 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
- require('../lib/utf8').ensureUtf8()
5
-
6
4
  const { parseArgs, numberArg } = require('../lib/args')
7
5
  const help = require('../lib/help')
8
6
  const VERSION = require('../package.json').version
@@ -104,6 +102,14 @@ async function cmdTake(argv) {
104
102
  timeoutMs,
105
103
  signal: ac.signal,
106
104
  })
105
+ // 跳过的坏文件必须显式说出来:它们是永久丢掉的消息,不是延迟送达。
106
+ // 退出码保持批次语义,否则调用方会把已交付的一批当成失败丢弃
107
+ if (res.skipped.length) {
108
+ process.stderr.write(
109
+ `warn: 跳过 ${res.skipped.length} 个损坏的事件文件,这些消息永久丢失:` +
110
+ `${res.skipped.join(' ')}\n 用 lark-relay status 复核,确认后删除坏文件\n`,
111
+ )
112
+ }
107
113
  if (!res.events.length) {
108
114
  process.stderr.write(`没等到消息(${res.reason}),再起一个继续\n`)
109
115
  return res.reason === 'timeout' ? 4 : 0
@@ -122,7 +128,8 @@ async function cmdStatus(argv) {
122
128
  const { buildStatus, formatStatus } = require('../lib/status')
123
129
  const s = await buildStatus()
124
130
  process.stdout.write(`${a.json ? JSON.stringify(s, null, 2) : formatStatus(s)}\n`)
125
- return s.collect.profileError ? 1 : 0
131
+ // 输出完整,但异常仍以退出码透出,便于脚本发现
132
+ return s.collect.profileError || s.apps.some((r) => r.error) ? 1 : 0
126
133
  }
127
134
 
128
135
  // 自然排空 stdout,避免大批次经管道输出时被截断
package/lib/collect.js CHANGED
@@ -31,8 +31,19 @@ class AppCollector {
31
31
  this.day = today
32
32
  this.seen = null
33
33
  }
34
- // 首条消息前恢复当天去重;读取失败可在下次重试,不阻断其他 app 启动
35
- if (this.seen === null) this.seen = store.seedIds(this.app, this.day)
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
+ }
46
+ }
36
47
  const id = obj?.message_id
37
48
  if (id && this.seen.has(id)) return false
38
49
  store.writeEvent(this.app, obj, this.seq++)
@@ -128,7 +139,6 @@ async function runCollect(opts) {
128
139
  return 1
129
140
  }
130
141
 
131
- const exclude = new Set(opts.exclude || [])
132
142
  let profiles
133
143
  try {
134
144
  profiles = await larkcli.listProfiles()
@@ -143,24 +153,18 @@ async function runCollect(opts) {
143
153
  return 1
144
154
  }
145
155
 
146
- const chosen = profiles.filter((name) => !exclude.has(name))
147
- const skipped = profiles.filter((name) => exclude.has(name))
148
-
149
- if (skipped.length) {
150
- logLine(`跳过 --exclude: ${skipped.join(' ')}`)
151
- }
152
- if (!chosen.length) {
153
- logLine('error: 没有待采集的 profile,检查 `lark-cli profile list` 和 --exclude')
156
+ if (!profiles.length) {
157
+ logLine('error: 没有待采集的 profile,检查 `lark-cli profile list`')
154
158
  return 1
155
159
  }
156
160
 
157
161
  logLine(
158
- `lark-relay-collect: ${chosen.length}/${profiles.length} profiles → ${paths.store}\n` +
159
- ` ${chosen.join(' ')}\n` +
162
+ `lark-relay-collect: ${profiles.length} profiles → ${paths.store}\n` +
163
+ ` ${profiles.join(' ')}\n` +
160
164
  ` event_key=${EVENT_KEY} store 保留 ${opts.retain}天`,
161
165
  )
162
166
 
163
- const collectors = chosen.map((name) => new AppCollector(name))
167
+ const collectors = profiles.map((name) => new AppCollector(name))
164
168
  for (const c of collectors) c.start()
165
169
 
166
170
  // 只回收超期事件,保留消费者的断点
package/lib/help.js CHANGED
@@ -30,7 +30,8 @@ const TAKE_HELP = `需要 --app 和 --chats,监听要求 bot 已在群内
30
30
  断点持续保留,不随空闲时间过期;store 只保留采集端设定天数的消息
31
31
  使用新的 --name 可从新的起点消费
32
32
  app 和消费名称不能含路径分隔符、控制字符、首尾空白或以点开头
33
- 读取或过滤失败时中止取批并保留待交付批次的断点,修正后可重取
33
+ 过滤失败时中止取批并保留待交付批次的断点,修正后可重取
34
+ 损坏的事件文件跳过并在 stderr 告警,断点越过它继续 —— 那几条消息永久丢失
34
35
 
35
36
  退出码:0=有一批/帮助/信号退出,1=运行错误,2=参数错,3=身份被占用,4=超时无消息`
36
37
 
@@ -39,21 +40,20 @@ const COLLECT_HELP = `lark-relay-collect -- 常驻收集 im.message.receive_v1
39
40
  lark-relay-collect
40
41
  lark-relay status
41
42
 
42
- 启动时读取 lark-cli profile list,每个未被 --exclude 排除的 profile 启动一个 bot consumer
43
+ 启动时读取 lark-cli profile list,每个 profile 启动一个 bot consumer
43
44
  不依赖 user 登录态,bot 凭据和权限由 lark-cli 检查;失败会记录并退避重试
44
45
  新增或移除 profile 后需重启采集
45
46
  每 app 独立退避重连,断线期间的消息无法回放
46
47
  同一 app 只允许一个事件总线实例,接管前先停止现有采集
47
48
 
48
49
  参数
49
- --exclude <apps> 排除指定 profile,逗号分隔
50
50
  --retain N 事件保留的整数天数,默认 3;每小时回收,启动时也执行
51
51
  -h, --help 用法
52
52
  -v, --version 版本
53
53
 
54
54
  macOS 常驻部署
55
55
  使用 LaunchDaemon + UserName,开机启动且不依赖 GUI 登录
56
- ProgramArguments 写绝对路径,显式设置 HOME/PATH/UTF-8 locale
56
+ ProgramArguments 写绝对路径,显式设置 HOME/PATH
57
57
  KeepAlive 使用 SuccessfulExit=false,日志路径的父目录须已存在
58
58
  sudo launchctl bootstrap system /Library/LaunchDaemons/<label>.plist
59
59
  修改 plist 后需 bootout + bootstrap,代码升级后用 kickstart -k
@@ -70,6 +70,7 @@ const STATUS_HELP = `lark-relay status -- 采集状态、事件数量和消费
70
70
  首次 --since all 等待中也会保留起始断点
71
71
  profiles 列出已配置项,不表示正在采集,也不反映 collect 的 --exclude
72
72
  profile 查询失败时保留本地状态并以 exit 1 返回,错误见 collect.profileError
73
+ 扫描全部保留日期,任意位置的损坏事件都标记 ⚠ 并以 exit 1 返回,其余 app 照常显示
73
74
  collect running 只表示主进程在跑,各 app 的连接状态需查看采集日志`
74
75
 
75
76
  module.exports = { TAKE_HELP, COLLECT_HELP, STATUS_HELP }
package/lib/lock.js CHANGED
@@ -87,4 +87,4 @@ function holderPid(key) {
87
87
  return alive(pid) ? pid : null
88
88
  }
89
89
 
90
- module.exports = { acquireExclusive, lockPathFor, holderPid }
90
+ module.exports = { acquireExclusive, holderPid }
package/lib/paths.js CHANGED
@@ -48,12 +48,13 @@ function readDir(dir, options) {
48
48
  }
49
49
  }
50
50
 
51
- // stamp 在进程内严格单调,文件名按 stamp/pid/seq 数值排序
52
- let lastStamp = 0n
51
+ // stamp(毫秒)在进程内严格单调,文件名按 stamp/pid/seq 数值排序:
52
+ // 同毫秒内 seq 保序,跨进程由 pid 防撞名
53
+ let lastStamp = 0
53
54
 
54
55
  function eventFilename(seq) {
55
- const wall = BigInt(Date.now()) * 1000000n
56
- lastStamp = wall > lastStamp ? wall : lastStamp + 1n
56
+ const wall = Date.now()
57
+ lastStamp = wall > lastStamp ? wall : lastStamp + 1
57
58
  return `${lastStamp}_${process.pid}_${seq}.json`
58
59
  }
59
60
 
package/lib/render.js CHANGED
@@ -8,9 +8,9 @@ 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
 
@@ -61,4 +61,4 @@ function render(events, mode) {
61
61
  return renderNdjson(events)
62
62
  }
63
63
 
64
- module.exports = { render, renderText }
64
+ module.exports = { render }
package/lib/status.js CHANGED
@@ -73,12 +73,27 @@ async function buildStatus() {
73
73
 
74
74
  const apps = [...new Set([...store.listApps(), ...profiles, ...byApp.keys()])].sort()
75
75
  const rows = apps.map((app) => {
76
- const latest = store.latestEntry(app)
77
- 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
+ }
78
92
  return {
79
93
  app,
80
94
  today: store.countToday(app),
81
- latestMs: Number(ev?.timestamp) || null,
95
+ latestMs,
96
+ error: corrupt.length ? `${corrupt.length} 个事件文件损坏:${corrupt.join(' ')}` : null,
82
97
  consumers: byApp.get(app) || [],
83
98
  }
84
99
  })
@@ -127,14 +142,16 @@ function formatStatus(s) {
127
142
  for (const r of s.apps) {
128
143
  if (!r.consumers.length) {
129
144
  out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}—(无消费者)`)
130
- continue
131
- }
132
- // 消费者各占一行:「谁在跑」是排障第一问,挤在一行里 ●/○ 会被淹掉
133
- out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}`.trimEnd())
134
- for (const c of r.consumers) {
135
- const note = c.pid ? `在跑 (pid ${c.pid})` : '未运行 (断点保留)'
136
- 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
+ }
137
152
  }
153
+ // 坏文件会让该 app 的采集跳过去重、take 直接失败,必须显式暴露
154
+ if (r.error) out.push(` ⚠ ${r.error}`)
138
155
  }
139
156
  out.push('')
140
157
  out.push(
package/lib/store.js CHANGED
@@ -36,7 +36,7 @@ const NAME_RE = /^(\d+)_(\d+)_(\d+)\.json$/
36
36
  function parseKey(name) {
37
37
  const m = NAME_RE.exec(name)
38
38
  if (!m) throw new Error(`事件文件名无效:${name}`)
39
- return { stamp: BigInt(m[1]), pid: Number(m[2]), seq: Number(m[3]) }
39
+ return { stamp: Number(m[1]), pid: Number(m[2]), seq: Number(m[3]) }
40
40
  }
41
41
 
42
42
  function isEventName(name) {
@@ -95,20 +95,37 @@ function readEvent(entry) {
95
95
  if (!event || typeof event !== 'object' || Array.isArray(event)) throw new Error('需要 JSON 对象')
96
96
  return event
97
97
  } catch (err) {
98
- throw Object.assign(new Error(`事件文件损坏:${entry.file}: ${err.message}`), { userFacing: true })
98
+ // corrupt:盘上数据坏了,重试不会好转。与 EACCES 等瞬时错误区分开,
99
+ // 让只读概览和采集能跳过单个坏文件继续跑;take 仍然硬失败
100
+ throw Object.assign(new Error(`事件文件损坏:${entry.file}: ${err.message}`), {
101
+ userFacing: true, corrupt: true,
102
+ })
99
103
  }
100
104
  }
101
105
 
102
- // 重启后从当天事件恢复 message_id 去重集合
106
+ // 重启后从当天事件恢复 message_id 去重集合。
107
+ // 去重是尽力而为的优化,不是落盘的前置条件:任何读不出的文件都跳过并回报,
108
+ // 最坏结果是那几条重投时重复落盘,比为它们丢掉后续所有消息轻得多
103
109
  function seedIds(app, day = dayKey()) {
104
110
  const ids = new Set()
105
- for (const name of readDir(paths.appDay(app, day))) {
111
+ const skipped = []
112
+ const dir = paths.appDay(app, day)
113
+ let names
114
+ try {
115
+ names = readDir(dir) // ENOENT 已按空目录处理,这里兜的是 EACCES 等
116
+ } catch (err) {
117
+ return { ids, skipped: [`整个目录读不出(${err.code || err.message})`] }
118
+ }
119
+ for (const name of names) {
106
120
  if (!isEventName(name)) continue
107
- const ev = readEvent({ file: path.join(paths.appDay(app, day), name) })
108
- const id = ev?.message_id
109
- 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
+ }
110
127
  }
111
- return ids
128
+ return { ids, skipped }
112
129
  }
113
130
 
114
131
  function readCursor(name, app) {
@@ -186,10 +203,8 @@ module.exports = {
186
203
  seedCursor,
187
204
  afterCursor,
188
205
  entryKey,
189
- cmpEntry,
190
206
  cmpName,
191
207
  countToday,
192
- latestEntry,
193
208
  listApps,
194
209
  gcStore,
195
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,14 +75,23 @@ 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)
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
+ }
84
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
@@ -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.4",
3
+ "version": "0.6.6",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",
package/lib/utf8.js DELETED
@@ -1,15 +0,0 @@
1
- 'use strict'
2
-
3
- // UTF-8 兜底:launchd 环境无 LANG -> C locale。虽然 Node 的字符串处理
4
- // 不像 bash 那样按字节切片,但子进程(lark-cli)会继承 locale,中文仍会被切坏。
5
- //
6
- // 两个 bin(lark-relay / lark-relay-collect)都要,故抽成模块 —— 复制两份会漂移。
7
- // 必须在任何 spawn 之前执行,所以两个入口都在最顶上 require 它。
8
- function ensureUtf8(env = process.env) {
9
- if (!/utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || '')) {
10
- env.LANG = 'C.UTF-8'
11
- env.LC_ALL = 'C.UTF-8'
12
- }
13
- }
14
-
15
- module.exports = { ensureUtf8 }