lark-relay 0.6.0 → 0.6.2

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/collect.js CHANGED
@@ -1,42 +1,25 @@
1
1
  'use strict'
2
2
 
3
- // collect:底座。全部 profile 各起一个 consume -> 读 stdout NDJSON -> 原子落盘。
4
- // 零参数、零配置 —— app 列表实时读 `lark-cli profile list`。
5
- //
6
- // 必须独立常驻:lark 事件是流式的,进程不在的时刻消息永久丢失
7
- // (实测:消息发出 8s 后才起 consumer,收到 0 条)。
8
- //
9
- // 踩坑史见 docs/lessons.md
3
+ // 启动时读取 profile,每 app 一个 consumer,消息原子落盘
10
4
  const readline = require('readline')
11
5
  const larkcli = require('./larkcli')
12
6
  const store = require('./store')
13
7
  const { paths, ensureDir, dayKey } = require('./paths')
14
8
  const { acquireExclusive } = require('./lock')
15
- const { init: logInit, logLine } = require('./log')
9
+ const { logLine } = require('./log')
16
10
 
17
- const EVENT_KEY = process.env.LARK_RELAY_EVENT_KEY || 'im.message.receive_v1'
11
+ const EVENT_KEY = 'im.message.receive_v1'
18
12
  const GC_INTERVAL_MS = 3600_000
19
13
 
20
- // 游标空闲多久算遗弃。默认 2 天:take 是实时场景的取用口,
21
- // 空闲一两天以上再重挂,下次消费一定从新的开始
22
- const CURSOR_TTL_DAYS = (() => {
23
- const d = Number(process.env.LARK_RELAY_CURSOR_TTL_DAYS)
24
- return Number.isFinite(d) && d > 0 ? d : 2
25
- })()
26
-
27
14
  class AppCollector {
28
- constructor(app, opts) {
15
+ constructor(app) {
29
16
  this.app = app
30
- this.opts = opts
31
17
  this.seq = 0
32
- this.received = 0
33
18
  this.child = null
34
19
  this.stopped = false
35
20
  this.backoffMs = 1000
36
- this.restarts = 0
37
21
  this.healthyTimer = null
38
- // 落盘侧去重:同一条消息在盘上只出现一次,消费侧因此完全不必去重。
39
- // 种子从当天已落盘的事件读回 —— 重启后 bus 重投时它是**新文件**,游标拦不住
22
+ // 启动时从当天落盘事件恢复去重集合
40
23
  this.day = dayKey()
41
24
  this.seen = store.seedIds(app, this.day)
42
25
  }
@@ -48,13 +31,10 @@ class AppCollector {
48
31
  this.day = today
49
32
  this.seen = new Set()
50
33
  }
51
- const id = obj && (obj.message_id || obj.id)
52
- if (id) {
53
- if (this.seen.has(id)) return false
54
- this.seen.add(id)
55
- }
34
+ const id = obj?.message_id
35
+ if (id && this.seen.has(id)) return false
56
36
  store.writeEvent(this.app, obj, this.seq++)
57
- this.received++
37
+ if (id) this.seen.add(id)
58
38
  return true
59
39
  }
60
40
 
@@ -63,8 +43,7 @@ class AppCollector {
63
43
  const child = larkcli.spawnConsume(this.app, EVENT_KEY, ['--as', 'bot', '--timeout', '0'])
64
44
  this.child = child
65
45
 
66
- // stdin 必须是保持打开的管道:unbounded consume 把 EOF 当退出信号,
67
- // 给 /dev/null 会疯狂重启 -- 见 docs/lessons.md#stdin-eof
46
+ // unbounded consume 把 stdin EOF 当退出信号,保持管道打开
68
47
  if (child.stdin) child.stdin.on('error', () => {})
69
48
 
70
49
  const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity })
@@ -85,29 +64,25 @@ class AppCollector {
85
64
  }
86
65
  })
87
66
 
88
- // 退避重置的判据是「活够久」而非「收到消息」-- 后者会让安静的 app
89
- // 一路顶到 60s 上限再也不降。见 docs/lessons.md
67
+ // 安静的连接也应重置退避
90
68
  this.healthyTimer = setTimeout(() => {
91
69
  this.backoffMs = 1000
92
70
  }, 30_000)
93
71
 
94
- // 不加 --quiet:它会隐藏事件丢失。必须走 readline 按行读,不能裸接 'data'
95
- // (归属错乱/记录被切/中文截断三个 bug)-- 见 docs/lessons.md
72
+ // readline 保留跨 chunk 的行和 UTF-8 字符
96
73
  const erl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity })
97
74
  erl.on('line', (line) => {
98
75
  const text = line.trimEnd()
99
76
  if (text) logLine(`[${this.app}] ${text}`)
100
77
  })
101
78
 
102
- // spawn 失败只发 error+close,**没有 exit** -- 重启逻辑必须挂 close,
103
- // 否则该 app 永久停摆而主进程看着还 active。见 docs/lessons.md
79
+ // spawn 失败也发 close,重启逻辑统一挂在这里
104
80
  let restarted = false
105
81
  const scheduleRestart = (why) => {
106
82
  if (restarted || this.stopped) return
107
83
  restarted = true
108
84
  clearTimeout(this.healthyTimer)
109
85
  this.child = null
110
- this.restarts++
111
86
  logLine(
112
87
  `[${this.app}] consume 退出(${why}),${Math.round(this.backoffMs / 1000)}s 后重启 ` +
113
88
  `—— 这段时间该 app 的消息会永久丢失`,
@@ -138,20 +113,13 @@ class AppCollector {
138
113
  async function runCollect(opts) {
139
114
  ensureDir(paths.store)
140
115
  ensureDir(paths.cursors)
141
- // 最早做 —— 启动横幅、profile 报错都该带时间戳
142
- logInit()
143
116
 
144
- // 互斥:一个 collect 就够。同一 app 服务端只放行一个 event bus,第二个起来会
145
- // 互相挤掉并退避互抢,实测约 5 分钟才稳定 -- 那 5 分钟的消息永久丢失。
146
- // 锁按 LARK_RELAY_HOME 隔离,不是全机唯一。见 docs/lessons.md#事件总线单实例
117
+ // 同一 app 的事件总线只允许一个实例;本地锁按 LARK_RELAY_HOME 隔离
147
118
  const lock = acquireExclusive('collect')
148
119
  if (!lock.ok) {
149
120
  logLine(
150
121
  `error: 已有一个 lark-relay-collect 在跑(pid ${lock.holder}),本进程退出。\n` +
151
- `同一 app 服务端只放行一个 event bus —— 两个 collect 会互相挤掉并退避重连,\n` +
152
- `实测约 5 分钟才稳定,那段时间的消息永久丢失。\n` +
153
- `确认是谁在跑: launchctl print system/com.adaex.lark-relay-collect\n` +
154
- `要接管请先停掉它:sudo launchctl bootout system/com.adaex.lark-relay-collect`,
122
+ `检查持锁进程或 lark-relay status,接管前先停止现有采集`,
155
123
  )
156
124
  return 1
157
125
  }
@@ -161,8 +129,6 @@ async function runCollect(opts) {
161
129
  try {
162
130
  profiles = await larkcli.listProfiles()
163
131
  } catch (err) {
164
- // 不抛给 bin 的顶层 catch —— 那行是所有命令共用的裸 stderr,
165
- // 会让「collect 崩了」这条最关键的记录反而没有时间戳
166
132
  logLine(
167
133
  `error: 读不到 lark-cli profile 列表:${(err.message || '').split('\n')[0]}\n` +
168
134
  `collect 依赖 lark-cli 提供认证与事件总线。检查:\n` +
@@ -203,12 +169,10 @@ async function runCollect(opts) {
203
169
  ` event_key=${EVENT_KEY} store 保留 ${opts.retain}天`,
204
170
  )
205
171
 
206
- const collectors = chosen.map((p) => new AppCollector(p.name, opts))
172
+ const collectors = chosen.map((p) => new AppCollector(p.name))
207
173
  for (const c of collectors) c.start()
208
174
 
209
- // gc 在本进程内每小时自查:回收逻辑就是「删超期的东西」,
210
- // 另起 gc 命令 + timer 是为几行逻辑加两个部署件。
211
- // store 按天删目录,cursors 按空闲时长删 —— 同构,故同处一地
175
+ // 只回收超期事件,保留消费者的断点
212
176
  const runGc = () => {
213
177
  try {
214
178
  const s = store.gcStore(opts.retain)
@@ -216,16 +180,6 @@ async function runCollect(opts) {
216
180
  } catch (err) {
217
181
  logLine(`gc: store 回收失败 ${err.message}`)
218
182
  }
219
- try {
220
- // 游标按空闲时长自动回收 —— 引擎自己过期,不需要人判断。
221
- // 判据错过两次,见 docs/lessons.md#游标判据
222
- const c = store.gcCursors(CURSOR_TTL_DAYS)
223
- if (c.length) {
224
- logLine(`gc: 回收游标 ${c.length} 个(空闲超 ${CURSOR_TTL_DAYS} 天):${c.join(' ')}`)
225
- }
226
- } catch (err) {
227
- logLine(`gc: 游标回收失败 ${err.message}`)
228
- }
229
183
  }
230
184
  const gcTimer = setInterval(runGc, GC_INTERVAL_MS)
231
185
  // 启动时先跑一次,别等一小时
package/lib/filter.js CHANGED
@@ -1,42 +1,41 @@
1
1
  'use strict'
2
2
 
3
- // 业务过滤只准进 --filter。硬约束:曾有 6 个逐业务加出来的过滤参数,这条线会一直长。
4
- // 收敛成 --chats(配置)+ --filter(逻辑)两项,中心不随业务生长。
5
- const { execFileSync, spawnSync } = require('child_process')
3
+ // 业务逻辑统一由 jq 表达式提供
4
+ const { execFileSync } = require('child_process')
6
5
 
7
6
  const JQ = process.env.LARK_RELAY_JQ || 'jq'
8
7
 
9
- let jqChecked = false
10
- function ensureJq() {
11
- if (jqChecked) return
12
- const r = spawnSync(JQ, ['--version'], { encoding: 'utf8' })
13
- if (r.error || r.status !== 0) {
14
- const e = new Error(
15
- `--filter 需要 jq,但没找到可执行的 \`${JQ}\`。\n` +
16
- `装一个(apt install jq / brew install jq),或去掉 --filter。`,
17
- )
18
- e.userFacing = true
19
- throw e
8
+ function validateFilter(expr) {
9
+ if (!expr) return
10
+ try {
11
+ // 空输入只编译,表达式与实际过滤一致,不执行虚构事件
12
+ execFileSync(JQ, ['-c', `[${expr}] | any`], {
13
+ input: '', encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
14
+ })
15
+ } catch (err) {
16
+ const reason = (err.stderr || err.message).trim().split('\n')[0]
17
+ throw Object.assign(new Error(`--filter 无法编译,请检查 jq 和表达式:${reason}`), {
18
+ userFacing: true,
19
+ exitCode: 2,
20
+ })
20
21
  }
21
- jqChecked = true
22
22
  }
23
23
 
24
24
  // 一批只 spawn 一次 jq:把事件按 NDJSON 喂进去,表达式逐行求值成 true/false,
25
25
  // 按行号对回原事件。比每条 spawn 一次快两个数量级。
26
26
  function applyFilter(events, expr) {
27
27
  if (!expr || events.length === 0) return events
28
- ensureJq()
29
28
  const input = events.map((e) => JSON.stringify(e)).join('\n')
30
29
  let out
31
30
  try {
32
31
  out = execFileSync(JQ, ['-c', `[${expr}] | any`], {
33
32
  input,
34
33
  encoding: 'utf8',
34
+ stdio: ['pipe', 'pipe', 'pipe'],
35
35
  maxBuffer: 64 * 1024 * 1024,
36
36
  })
37
37
  } catch (err) {
38
- // 保守放行 + warn,**绝不抛** -- 抛出去会让消费者崩溃循环、任务彻底停摆。
39
- // 见 docs/lessons.md#filter
38
+ // 数据导致的求值错误保守放行,避免同一条消息卡住消费
40
39
  process.stderr.write(
41
40
  `warn: --filter 求值失败,本批不过滤(${events.length} 条放行)。表达式:${expr}\n` +
42
41
  ` ${(err.stderr || err.message || '').trim().split('\n')[0]}\n` +
@@ -56,4 +55,4 @@ function applyFilter(events, expr) {
56
55
  return events.filter((_, i) => verdicts[i] === 'true')
57
56
  }
58
57
 
59
- module.exports = { applyFilter }
58
+ module.exports = { validateFilter, applyFilter }
package/lib/help.js CHANGED
@@ -1,100 +1,69 @@
1
1
  'use strict'
2
2
 
3
- // 帮助文本是 AI 的主入口 -- 实际行为是「先空参数跑一下看看」,
4
- // 故 take 空参输出必须是完整的照做指导,而非报错。
5
- // 所有提示按「AI 读到后下一步该做什么」来写:给完整命令、给退出码语义、给判据。
6
- //
7
- // 说理、部署教程、踩坑史都不在这里 -- 它们在 README.md 与 docs/lessons.md。
8
- // 帮助只回答「现在该敲什么」。
9
- //
10
- // ⚠️ 公网包:示例只用占位符(oc_xxx/<app>/ou_xxx),绝不含真实 chat_id/open_id/profile 名。
11
-
12
- function takeHelp(apps) {
13
- const appLine = apps && apps.length ? apps.join(' ') : '(跑 `lark-cli profile list` 看)'
14
- return `需要 --app 和 --chats。监听要求 bot 已在群内。
15
-
16
- 一、查群 ID(用户身份;bot 搜不到自己没加入的群)
3
+ const TAKE_HELP = `需要 --app 和 --chats,监听要求 bot 已在群内
4
+
5
+ 1. 查群 ID(用户身份)
17
6
  lark-cli --profile <app> im +chat-search --as user --query "<群名>"
18
- -> 取 oc_ 开头的 chat_id;多个匹配时向用户确认是哪个
7
+ -> 取 oc_ 开头的 chat_id,多个匹配时确认目标群
19
8
 
20
- 二、验 bot 在群(用户身份;bot 不在群时用 bot 身份查不到,不能作判据)
9
+ 2. 验 bot 在群(用户身份)
21
10
  lark-cli --profile <app> im chat.members bots --as user --params '{"chat_id":"oc_xxx"}'
22
- -> bots[] 含本 app 才算在群
23
- -> 不在群:请用户拉 bot 进群(对群可见动作,先征得同意)
11
+ -> bots[] 应含本 app,不在群则先安排入群
24
12
 
25
- 三、监听(Claude Code 用 run_in_background 起,有消息会自动通知你)
13
+ 3. 取一批
26
14
  lark-relay take --app <app> --chats oc_xxx --render text
27
-
28
- 起来后 stderr 先打一行「监听中 ...」-- 没这行就是没起来,看 stderr 报错。
29
- 阻塞等消息,有一批就吐到 stdout 并退出(默认防抖 5s、最长等 12h)。
30
- 处理完再起一个,如此循环。
31
-
32
- 可用 app:${appLine}
15
+ -> stderr 出现「监听中」表示参数校验完成并进入等待
16
+ -> 有一批输出到 stdout 后退出,处理完再次运行
33
17
 
34
18
  参数
35
- --app <name> 必填,= lark-cli profile 名
36
- --chats <ids> 必填,oc_ 开头;逗号分隔多个;或 @文件(首列 chat_id)
37
- --name <n> 游标身份,省略时按 app+chats 自动派生
19
+ --app <name> 必填,lark-cli profile 名(用 lark-cli profile list 查询)
20
+ --chats <ids> 必填,逗号分隔的 chat_id,或 @文件(首列 chat_id)
21
+ --name <n> 消费断点身份,默认按 app+chats 派生;并行消费使用不同身份
38
22
  --filter <jq> 业务过滤,如 '.sender_id != "ou_xxx"'
39
- --debounce N 防抖秒数(默认 5;聊天场景建议 15)
40
- --max-wait N 从第一条起最多攒多久就吐(默认 25 秒)
41
- --timeout N 没消息时最多阻塞多久,到点空手退出码 4(默认 12 小时)
42
- --render text 按群分组紧凑文本(默认 NDJSON)
43
- --since now|all 起始位置(默认 now,不重放历史)
44
-
45
- 退出码
46
- 0 吐了一批(stdout);空参求教也是 0
47
- 2 参数错 -- stderr 有短错误和正确写法,照着重跑
48
- 3 游标身份被另一个 take 占用 -- 换 --name
49
- 4 超时没消息 -- 正常,再起一个继续
50
-
51
- 最佳实践
52
- · 一直等不到消息 -> 九成是 bot 不在群,回第二步验(最常见故障)
53
- · 多个消费者盯同一 app 用不同 --name,游标互不干扰
54
- · 不盯了直接走开 -- 空闲超 2 天的游标由 collect 自动回收`
55
- }
56
-
57
- const COLLECT_HELP = `把全部 lark-cli profile 的事件收下来,原子落盘。零参数、零配置。
58
-
59
- lark-relay-collect # 前台跑(调试)
60
- 常驻:macOS launchd,部署见 README
23
+ --debounce N 新消息静默多久后吐批,默认 5 秒
24
+ --max-wait N 从第一条起最多攒多久,默认 25 秒
25
+ --timeout N 本次取用最多阻塞多久,默认 43200 秒(12 小时)
26
+ --render <mode> text 或 ndjson,默认 ndjson
27
+ --since <mode> 首次取用的起点:now 或 all,默认 now;已有断点始终续接
28
+ -h, --help 用法
61
29
 
62
- app 列表实时读 \`lark-cli profile list\`,新增 profile 自动纳入,无需改配置。
63
- 每 app 一个子进程;某个挂了单独重启,不影响其他。
64
- 未授权或授权过期的 profile 自动跳过并 warn(永久失败,重试是死循环)。
30
+ 断点持续保留,不随空闲时间过期;store 只保留采集端设定天数的消息
31
+ 使用新的 --name 可从新的起点消费
65
32
 
66
- ⚠️ **一个 collect 就够,第二个会拒绝启动并报出持有者 pid**。
67
- 不是洁癖:同一 app 服务端只放行一个 event bus,两个 collect 会互相挤掉并按
68
- 1s->2s->...->60s 退避重连,实测约 5 分钟才稳定 -- 那段时间的消息永久丢失。
69
- 所以上面那条「前台跑(调试)」在常驻服务已在跑时会直接退出,不会抢它。
33
+ 退出码:0=有一批/帮助/信号退出,2=参数错,3=身份被占用,4=超时无消息`
70
34
 
71
- 为什么必须常驻:lark 事件是流式的,进程不在的时刻消息永久丢失(实测:消息发出
72
- 8s 后才起 consumer,收到 0 条)。
35
+ const COLLECT_HELP = `lark-relay-collect -- 常驻收集 im.message.receive_v1 消息并原子落盘
73
36
 
74
- 装完先 \`lark-relay status\` 确认 -- collect 行应为 running;不在跑看日志:
75
- tail -f ~/.lark-relay/logs/collect.err.log
76
- (launchd 把 stderr 直写该文件,不进统一日志,log show 查不到)
37
+ lark-relay-collect
38
+ lark-relay status
77
39
 
78
- 落盘:~/.lark-relay/store/<app>/<YYYY-MM-DD>/<纳秒>_<pid>_<seq>.json
79
- 同一 message_id 只落一次。回收在本进程内每小时自查(启动时先跑一次):
80
- store 删超过 --retain 天的日期目录,游标删空闲超 TTL 的。
40
+ 启动时读取 lark-cli profile list,每个可用 profile 启动一个 consumer
41
+ 新增 profile 或重新授权后需重启采集
42
+ 每 app 独立退避重连,断线期间的消息无法回放
43
+ 同一 app 只允许一个事件总线实例,接管前先停止现有采集
81
44
 
82
45
  参数
83
- --exclude <apps> 排除指定 profile(逗号分隔)
84
- --retain N store 保留天数(默认 3)
46
+ --exclude <apps> 排除指定 profile,逗号分隔
47
+ --retain N 事件保留的整数天数,默认 3;每小时回收,启动时也执行
48
+ -h, --help 用法
49
+ -v, --version 版本
85
50
 
86
- 环境变量
87
- LARK_RELAY_CURSOR_TTL_DAYS 游标空闲多久算遗弃(默认 2)`
51
+ macOS 常驻部署
52
+ 使用 LaunchDaemon + UserName,开机启动且不依赖 GUI 登录
53
+ ProgramArguments 写绝对路径,显式设置 HOME/PATH/UTF-8 locale
54
+ KeepAlive 使用 SuccessfulExit=false,日志路径的父目录须已存在
55
+ sudo launchctl bootstrap system /Library/LaunchDaemons/<label>.plist
56
+ 修改 plist 后需 bootout + bootstrap,代码升级后用 kickstart -k
88
57
 
89
- const STATUS_HELP = `一眼看清全局:谁在跑、各 app 收了多少、游标在哪。
58
+ 落盘:~/.lark-relay/store/<app>/<YYYY-MM-DD>/<stamp>_<pid>_<seq>.json
59
+ 日志写 stderr,每行带时间戳;launchd 部署应配置 StandardErrorPath`
90
60
 
91
- lark-relay status # 概览
92
- lark-relay status --json # 机器可读
61
+ const STATUS_HELP = `lark-relay status -- 采集状态、事件数量和消费断点
93
62
 
94
- 消费者:● 有 take 持锁在跑 / ○ 空闲 Nh(重挂 take 接着盯)
63
+ lark-relay status
64
+ lark-relay status --json
95
65
 
96
- 排障入口:「○ 空闲」而你以为它在盯 -> 它的 take 没起来或早退了。
97
- collect 行不是 running -> tail -f ~/.lark-relay/logs/collect.err.log
98
- (launchd 不进统一日志),collect 停摆的每一秒都在丢消息。`
66
+ 消费者以持锁进程是否存活区分在跑/未运行,未运行的断点仍保留
67
+ collect running 只表示主进程在跑,各 app 的连接状态需查看采集日志`
99
68
 
100
- module.exports = { takeHelp, COLLECT_HELP, STATUS_HELP }
69
+ module.exports = { TAKE_HELP, COLLECT_HELP, STATUS_HELP }
package/lib/larkcli.js CHANGED
@@ -23,15 +23,7 @@ function run(args, opts = {}) {
23
23
  })
24
24
  }
25
25
 
26
- // profile list 默认输出 JSON,无需 --json。
27
- // 两类不可用,都要跳过,否则 consume 一直失败重试(死循环):
28
- // - tokenStatus 为 expired:授权过期,永久失败直到用户重新 login
29
- // - 没有 user 字段:该 profile 从未 auth 过(实测未登录的 profile 只有
30
- // name/appId/brand/active 四个字段,无 user 也无 tokenStatus)
31
- //
32
- // 注:2026-09-06 删掉了 stripToJson / errText / tail 三个导出 —— 零调用方
33
- // (含测试)。stripToJson 还带着 6 行「只适用于对象响应」的警告,
34
- // 等于为没人调用的函数维护陷阱说明。要用再从 git 历史取
26
+ // profile list 输出 JSON;授权过期或缺少 user 的 profile 不参与采集
35
27
  async function listProfiles() {
36
28
  const { stdout } = await run(['profile', 'list'])
37
29
  const arr = JSON.parse(stdout)
@@ -41,21 +33,13 @@ async function listProfiles() {
41
33
  else if (!p.user) reason = 'not-authed'
42
34
  return {
43
35
  name: p.name,
44
- appId: p.appId,
45
- tokenStatus: p.tokenStatus || 'unknown',
46
36
  usable: reason === null,
47
37
  reason,
48
38
  }
49
39
  })
50
40
  }
51
41
 
52
- // 事件流:NDJSON 到 stdout。我们自己接 stdout 后原子落盘,
53
- // 不用 --output-dir —— 它非原子写(先 0 字节再填充),是 partial write 的根因。
54
- //
55
- // ⚠️ stdin 必须是**保持打开的管道**:unbounded consume 把 stdin EOF 当退出信号
56
- // (帮助文本原话:"Bounded runs ignore stdin EOF" —— 反过来 unbounded 不忽略)。
57
- // 给 'ignore' 会拿到 /dev/null,立刻读到 EOF 就退出 → 变成疯狂重启循环(实测)。
58
- // 旧架构 bash 侧用 `< <(tail -f /dev/null)` 解决同一问题;Node 里开管道且永不写即可。
42
+ // 保持 stdin 管道打开,stdout NDJSON 由 collect 原子写入
59
43
  function spawnConsume(profile, eventKey, extraArgs = []) {
60
44
  const args = ['--profile', profile, 'event', 'consume', eventKey, ...extraArgs]
61
45
  return spawn(CLI, args, { stdio: ['pipe', 'pipe', 'pipe'] })
package/lib/lock.js CHANGED
@@ -1,13 +1,7 @@
1
1
  'use strict'
2
2
 
3
- // 消费者锁(非阻塞):同一 --name 不该有两个 take 在跑,抢不到直接退出并说清楚。
4
- // 否则两个进程互相推游标,批次会被撕成两半。collect 也用它保证全局单实例。
5
- //
6
- // 原语:`mkdir` 非 recursive —— 目标已存在就 EEXIST,这就是原子的 test-and-set。
7
- // 建目录与写 pid 之间有个微秒级窗口(目录在但 pid 还没写),此时**保守拒绝**,
8
- // 不夺锁 —— 恢复动作只是「再跑一次」,而夺错锁会造成双实例。
9
- // 曾用 staging+rename+steal 的 5 次重试循环应对 12 进程并发,那个并发度不存在,
10
- // 见 docs/lessons.md#锁
3
+ // 非阻塞目录锁:collect 单实例,同一消费身份只能有一个 take
4
+ // mkdir 与写 pid 之间的空目录保守拒绝,避免抢走正在初始化的锁
11
5
  const fs = require('fs')
12
6
  const path = require('path')
13
7
  const { paths, ensureDir } = require('./paths')
@@ -29,13 +23,9 @@ function acquireExclusive(key) {
29
23
 
30
24
  const holder = readPid(pidFile)
31
25
  if (holder === process.pid) return mkHandle(dir, pidFile, me) // 自己已持有,复用
32
- if (holder === null && attempt === 0) {
33
- // 要么是上面那个微秒级窗口,要么是持有者被 SIGKILL 后留下的空壳。
34
- // 等一拍再读一次:还是空就当陈旧锁清掉
35
- try {
36
- fs.rmSync(dir, { recursive: true, force: true })
37
- } catch {}
38
- continue
26
+ if (holder === null) {
27
+ // 无法区分初始化中的锁与崩溃留下的空壳,需确认持有者后处理
28
+ return { ok: false, file: dir, holder: null }
39
29
  }
40
30
  if (holder && alive(holder)) return { ok: false, file: dir, holder }
41
31
  // 持有者已死:清掉重试一次
package/lib/log.js CHANGED
@@ -1,38 +1,13 @@
1
1
  'use strict'
2
2
 
3
- // 常驻日志:给每行加 ISO 时间戳。**只给 collect 这类常驻服务用**,
4
- // CLI 的用户可见输出(take 的 NDJSON、status --json)绝不能过这里 —— 会破坏解析契约。
5
- //
6
- // 为什么要时间戳:launchd 只把 stderr 直写文件,没有 journald 那种免费的时间戳。
7
- // 而本项目的核心排查问题恰是「**什么时候**断线、断了多久、那个窗口丢了几条」。
8
- //
9
- // 不做轮转:实测 6 天 172KB(约 10MB/年),而轮转要自己 open 一份 fd、
10
- // 做 dev/ino 校验、管 KEEP 代数 —— 为一年后的事写 140 行。嫌大就
11
- // `> ~/.lark-relay/logs/collect.err.log`(launchd 持有 fd,truncate 即可,别 rm)
12
- let active = false
13
-
14
- // ⚠️ 必须按 \n 逐行加前缀,不能只在整块前面拼一个:
15
- // 调用方存在单次输出多行的情况(启动横幅 3 行、子进程转发的多行诊断),
16
- // 只给首行加前缀会让后续行无法归属到时间点
3
+ // 仅用于采集日志,逐行加时间戳;CLI 数据输出直接写 stdout
17
4
  function logLine(text) {
18
5
  const s = String(text)
19
6
  const body = s.endsWith('\n') ? s.slice(0, -1) : s
20
- if (!active) return void process.stderr.write(`${body}\n`)
21
7
  const ts = new Date().toISOString()
22
8
  let out = ''
23
9
  for (const line of body.split('\n')) out += `${ts} ${line}\n`
24
10
  process.stderr.write(out)
25
11
  }
26
12
 
27
- // 常驻入口调一次
28
- function init() {
29
- active = true
30
- return active
31
- }
32
-
33
- // 测试用:重置模块态
34
- function _reset() {
35
- active = false
36
- }
37
-
38
- module.exports = { init, logLine, _reset }
13
+ module.exports = { logLine }
package/lib/render.js CHANGED
@@ -1,7 +1,5 @@
1
1
  'use strict'
2
2
 
3
- // 渲染字段取并集(sender/mentions/reply_to/root_id/thread_id 全渲染),多几个不碍事
4
-
5
3
  function fmtTime(ts) {
6
4
  const ms = Number(ts)
7
5
  if (!Number.isFinite(ms) || ms <= 0) return '?'
@@ -38,9 +36,7 @@ function renderNdjson(events) {
38
36
  return events.map((e) => JSON.stringify(e)).join('\n')
39
37
  }
40
38
 
41
- // 按群分组的紧凑文本。分组必须按 (chat_id, thread_id) 双维度,只按 chat_id 会串话题。
42
- // 复合 key 的分隔符是 **NUL**,且必须写成 `\x00` 转义(裸字节会让 git 判成 binary,
43
- // 那次改动就没法 review)。见 docs/lessons.md#渲染分组
39
+ // 群与话题共同确定分组,使用转义 NUL 分隔
44
40
  function renderText(events) {
45
41
  const groups = new Map()
46
42
  for (const e of events) {
@@ -56,11 +52,11 @@ function renderText(events) {
56
52
  head += ` (${list.length} 条)`
57
53
  out.push(head)
58
54
  for (const e of list) {
59
- const line = [`[${fmtTime(e.create_time || e.timestamp)}]`, senderLabel(e)]
55
+ const line = [`[${fmtTime(e.create_time)}]`, senderLabel(e)]
60
56
  if (e.message_type && e.message_type !== 'text') line.push(`<${e.message_type}>`)
61
57
  out.push(`${line.join(' ')}: ${bodyOf(e)}`)
62
58
  const meta = []
63
- meta.push(`message_id=${e.message_id || e.id || '?'}`)
59
+ meta.push(`message_id=${e.message_id || '?'}`)
64
60
  meta.push(`sender_id=${e.sender_id || '?'}`)
65
61
  // sender_type 是业务判据(如「自家 bot 的消息跳过」),必须渲染出来
66
62
  meta.push(`sender_type=${e.sender_type || '?'}`)
@@ -68,7 +64,7 @@ function renderText(events) {
68
64
  if (mentions.length) {
69
65
  meta.push(`mentions=${mentions.map((m) => `${m.name || '?'}(${m.id || '?'})`).join(',')}`)
70
66
  }
71
- if (e.parent_id) meta.push(`reply_to=${e.parent_id}`)
67
+ if (e.reply_to) meta.push(`reply_to=${e.reply_to}`)
72
68
  if (e.root_id) meta.push(`root_id=${e.root_id}`)
73
69
  if (e.thread_id) meta.push(`thread_id=${e.thread_id}`)
74
70
  out.push(` ${meta.join(' ')}`)
package/lib/status.js CHANGED
@@ -18,15 +18,6 @@ function ago(ms) {
18
18
  return `${Math.round(s / 86400)}d ago`
19
19
  }
20
20
 
21
- // 空闲时长。向下取整:它是 gc 判据,宁可少报不可多报
22
- function idleTxt(ms) {
23
- if (!Number.isFinite(ms)) return '未知'
24
- const m = Math.floor(ms / 60000)
25
- if (m < 60) return `${m}m`
26
- const h = Math.floor(m / 60)
27
- return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`
28
- }
29
-
30
21
  const COLLECT_LABEL = process.env.LR_COLLECT_LABEL || 'com.adaex.lark-relay-collect'
31
22
 
32
23
  function serviceState() {
@@ -36,8 +27,7 @@ function serviceState() {
36
27
  encoding: 'utf8',
37
28
  stdio: ['ignore', 'pipe', 'ignore'],
38
29
  })
39
- // 取值到行尾再 trim,不能用 (\S+):launchd 输出 `state = not running`,
40
- // (\S+) 只吃到 "not" —— 而那正是最要命的状态(见 docs/lessons.md#launchd-state)
30
+ // state 可以是多词值,如 not running
41
31
  const state = (out.match(/^\s*state\s*=\s*(.+)$/m) || [])[1]
42
32
  const pid = Number((out.match(/^\s*pid\s*=\s*(\d+)/m) || [])[1]) || null
43
33
  const s = (state || '').trim()
@@ -55,7 +45,6 @@ function listCursors() {
55
45
  } catch {
56
46
  return []
57
47
  }
58
- const now = Date.now()
59
48
  const out = []
60
49
  for (const name of names.sort()) {
61
50
  const dir = path.join(paths.cursors, name)
@@ -71,14 +60,7 @@ function listCursors() {
71
60
  if (app.startsWith('.') || app.endsWith('.tmp')) continue
72
61
  const cursor = store.readCursor(name, app)
73
62
  if (!cursor) continue
74
- const mtime = store.cursorMtime(name, app)
75
- out.push({
76
- name,
77
- app,
78
- cursor,
79
- pid,
80
- idleMs: mtime === null ? Infinity : Math.max(0, now - mtime),
81
- })
63
+ out.push({ name, app, cursor, pid })
82
64
  }
83
65
  }
84
66
  return out
@@ -107,7 +89,7 @@ async function buildStatus() {
107
89
  return {
108
90
  app,
109
91
  today: store.countToday(app),
110
- latestMs: Number(ev?.timestamp || ev?.create_time) || null,
92
+ latestMs: Number(ev?.timestamp) || null,
111
93
  consumers: byApp.get(app) || [],
112
94
  }
113
95
  })
@@ -163,12 +145,14 @@ function formatStatus(s) {
163
145
  // 消费者各占一行:「谁在跑」是排障第一问,挤在一行里 ●/○ 会被淹掉
164
146
  out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}`.trimEnd())
165
147
  for (const c of r.consumers) {
166
- const note = c.pid ? `在跑 (pid ${c.pid})` : `空闲 ${idleTxt(c.idleMs)}(重挂 take 接着盯)`
148
+ const note = c.pid ? `在跑 (pid ${c.pid})` : '未运行 (断点保留)'
167
149
  out.push(` ${w(c.name, 24)}${c.pid ? '●' : '○'} ${note}`)
168
150
  }
169
151
  }
170
152
  out.push('')
171
- out.push(`游标 ${s.cursorsDir}(空闲超 2 天自动回收;等不及就 rm -rf 它)`)
153
+ out.push(
154
+ `游标 ${s.cursorsDir}(保留断点,下次继续取用)`,
155
+ )
172
156
 
173
157
  return out.join('\n')
174
158
  }