lark-relay 0.4.14 → 0.5.0
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 +19 -6
- package/bin/lark-relay-collect.js +12 -8
- package/bin/lark-relay.js +69 -63
- package/lib/args.js +9 -43
- package/lib/collect.js +44 -10
- package/lib/cursors.js +114 -74
- package/lib/help.js +44 -32
- package/lib/larkcli.js +5 -22
- package/lib/render.js +0 -0
- package/lib/status.js +33 -25
- package/lib/store.js +41 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ lark-relay take --app <app> --chats oc_xxx --render text
|
|
|
63
63
|
空参数跑一下会输出完整的照做指导(查群 ID -> 验 bot 在群 -> 监听三步),exit 0。
|
|
64
64
|
起来后 stderr 先打一行「监听中」-- 没这行就是没起来,看 stderr 报错。
|
|
65
65
|
退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
|
|
66
|
-
参数拼错(如 `--chat-id`)
|
|
66
|
+
参数拼错(如 `--chat-id`)会硬失败并给出正确写法,不静默吞掉。
|
|
67
67
|
|
|
68
68
|
业务过滤只有 `--filter`(jq 表达式)一个口子,不为每个业务加参数。
|
|
69
69
|
|
|
@@ -80,11 +80,24 @@ lark-relay take --app <app> --chats oc_xxx --render text
|
|
|
80
80
|
多消费者**共享 store、各持游标**,不做「处理完即删」-- 谁都不能替别人删。
|
|
81
81
|
盯同一 app 请用不同 `--name` 隔离游标。
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
**游标会自动回收**:空闲超过 2 天的游标由 collect 删掉(每小时自查一次,
|
|
84
|
+
与 store 的日期目录回收同一个 gc)。判据是「多久没人用过它」——
|
|
85
|
+
不需要你记得收尾,也不需要你判断该不该删。
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
lark-relay cursors # ● 在跑 / ○ 空闲 Nh / ⚠ 空闲超期待回收
|
|
89
|
+
lark-relay cursors --forget <name> # 不等超期,现在就清
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
为什么是空闲时长而不是「落后多少条」:落后量是**遗弃的症状,不是反面** ——
|
|
93
|
+
被遗弃的游标落后会一路涨,拿它当判据会把该清的保护起来、该留的删掉
|
|
94
|
+
(2026-09-06 实测)。空闲时长与遗弃程度同向单调。
|
|
95
|
+
|
|
96
|
+
为什么 2 天够:take 是**实时场景**的取用口。空闲一两天以上再重挂,
|
|
97
|
+
下一次消费一定从新的开始;要历史消息有专门的接口,不靠游标回放。
|
|
98
|
+
改期限:`LARK_RELAY_CURSOR_TTL_DAYS=<天>`。
|
|
99
|
+
|
|
100
|
+
在跑的游标(有 take 持锁)一律不回收 —— take 会立刻把它写回来。
|
|
88
101
|
|
|
89
102
|
## 设计取舍
|
|
90
103
|
|
|
@@ -45,7 +45,7 @@ async function main() {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const a = parseArgs(argv, { flags: ['help'], keys: KEYS })
|
|
48
|
-
if (a._unknown.length) return rejectUnknown(PROG,
|
|
48
|
+
if (a._unknown.length) return rejectUnknown(PROG, a._unknown, KEYS)
|
|
49
49
|
if (a.help) {
|
|
50
50
|
process.stdout.write(`${help.COLLECT_HELP}\n`)
|
|
51
51
|
return 0
|
|
@@ -64,14 +64,18 @@ async function main() {
|
|
|
64
64
|
return 2
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// retain
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
// retain 必须**正好等于**消费侧的扫描窗口。两个方向都是事故:
|
|
68
|
+
// retain > 窗口:事件留在盘上却永远扫不到(store.js 那条注释警告的)
|
|
69
|
+
// retain < 窗口:窗口最老那几天已被 gc 删空,却仍被算作「窗口内」——
|
|
70
|
+
// 停在那里的游标判成「可续接」,而数据一条都不在了
|
|
71
|
+
// (2026-09-06 实测 retain=3 / 窗口=4 的 D-3 就是这种空档)
|
|
72
|
+
// 两个值在两个进程里,只能在这里把不变量钉死:装完新版直接起不来,好过悄悄丢
|
|
73
|
+
const retain = numStrict(a.retain, store.SCAN_DAYS)
|
|
74
|
+
if (retain === null || retain !== store.SCAN_DAYS) {
|
|
72
75
|
process.stderr.write(
|
|
73
|
-
`--retain
|
|
74
|
-
|
|
76
|
+
`--retain 必须等于消费侧扫描窗口 ${store.SCAN_DAYS} 天,当前:${a.retain}\n` +
|
|
77
|
+
`要改保留期就同时改两边:LARK_RELAY_SCAN_DAYS=<N> 且 --retain <N>。\n` +
|
|
78
|
+
`留得比窗口久 -> 事件在盘上却扫不到;短于窗口 -> 空目录被当成「还能续接」\n`,
|
|
75
79
|
)
|
|
76
80
|
return 2
|
|
77
81
|
}
|
package/bin/lark-relay.js
CHANGED
|
@@ -17,16 +17,14 @@ const COMMANDS = ['take', 'status', 'cursors', 'guide']
|
|
|
17
17
|
|
|
18
18
|
// collect 搬家提示。能力是**搬走了不是删了**,所以不能只说「未知命令」
|
|
19
19
|
const MOVED_HINT =
|
|
20
|
-
`collect 已拆成独立命令
|
|
21
|
-
` lark-relay-collect
|
|
22
|
-
` lark-relay-collect --help # 参数与常驻部署\n` +
|
|
23
|
-
`同一个包就带,不用另外装。\n`
|
|
20
|
+
`collect 已拆成独立命令(同一个包就带,不用另外装):\n` +
|
|
21
|
+
` lark-relay-collect --help # 前台调试与常驻部署\n`
|
|
24
22
|
|
|
25
23
|
const USAGE = `lark-relay ${VERSION} -- Lark 事件中继站(在场取用)
|
|
26
24
|
|
|
27
25
|
lark-relay take ... 在场取用:阻塞等一批 -> 输出 -> 退出
|
|
28
26
|
lark-relay status 谁在跑 / 各 app 积压 / 各游标位置
|
|
29
|
-
lark-relay cursors
|
|
27
|
+
lark-relay cursors 游标状态:谁在值守 / 空闲多久(超期自动回收)
|
|
30
28
|
lark-relay guide 一页用法(装完先读这个)
|
|
31
29
|
|
|
32
30
|
采集底座是独立命令(常驻服务):lark-relay-collect
|
|
@@ -68,7 +66,7 @@ async function main() {
|
|
|
68
66
|
default: {
|
|
69
67
|
// 拼错 collect(collectt / colect)同样走搬家提示 —— 意图明显是采集,
|
|
70
68
|
// 给「未知命令」等于让人以为功能没了。判据:编辑距离 <=2 且不像任何现有命令
|
|
71
|
-
if (
|
|
69
|
+
if (suggest(cmd, ['collect']) === 'collect' && !suggest(cmd, COMMANDS)) {
|
|
72
70
|
process.stderr.write(`未知命令:${cmd}。${MOVED_HINT}`)
|
|
73
71
|
return 2
|
|
74
72
|
}
|
|
@@ -81,7 +79,7 @@ async function main() {
|
|
|
81
79
|
|
|
82
80
|
async function cmdTake(argv) {
|
|
83
81
|
const a = parseArgs(argv, { flags: ['help'], keys: TAKE_KEYS })
|
|
84
|
-
if (a._unknown.length) return rejectUnknown('lark-relay take',
|
|
82
|
+
if (a._unknown.length) return rejectUnknown('lark-relay take', a._unknown, TAKE_KEYS)
|
|
85
83
|
const larkcli = require('../lib/larkcli')
|
|
86
84
|
|
|
87
85
|
// 空参数输出完整照做指导,而非报错 -- AI 的真实行为是「先空参数跑一下看看」。
|
|
@@ -140,32 +138,28 @@ async function cmdTake(argv) {
|
|
|
140
138
|
process.stderr.write(`--since 只能是 now 或 all,当前:${since}\n`)
|
|
141
139
|
return 2
|
|
142
140
|
}
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
141
|
+
// 游标初始化:没有游标就按 --since now 起头(消灭旧架构「预热游标」那一步)。
|
|
142
|
+
//
|
|
143
|
+
// ⚠️ 2026-09-06 删掉了这里的「stale 自愈」。它曾在游标位置超出扫描窗口时
|
|
144
|
+
// 把游标**重置到当前最新**,理由写的是「续接它和 seed now 行为完全一样」。
|
|
145
|
+
// 那个理由是错的,实测证伪:afterCursor 比的是「事件日期 > 游标日期」,
|
|
146
|
+
// 游标越老放行的事件越多。实测游标停在 D-5、窗口内有 3 条未消费时,
|
|
147
|
+
// 保留游标 -> 取到 3 条 重置到 now -> 取到 0 条
|
|
148
|
+
// 也就是说那个「自愈」每次都在**静默丢掉窗口内的全部积压**。
|
|
149
|
+
// 现在:游标一律原样保留,能取到的照常取。
|
|
150
|
+
//
|
|
151
|
+
// 不再需要「位置早于窗口」那条告知了 —— 遗弃的游标由 collect 按空闲时长自动回收
|
|
152
|
+
// (见 lib/cursors.js),不靠位置推断,也不靠人清。
|
|
150
153
|
if (since === 'now') {
|
|
151
|
-
|
|
152
|
-
if (existing === null) {
|
|
153
|
-
store.seedCursorNow(name, a.app)
|
|
154
|
-
} else {
|
|
155
|
-
const { cursorDay, isStaleDay, oldestScannedDay } = require('../lib/cursors')
|
|
156
|
-
const day = cursorDay(existing)
|
|
157
|
-
if (isStaleDay(day)) {
|
|
158
|
-
// 说明行必须打在「监听中」**之前** —— 那一行是约定的启动信号
|
|
159
|
-
// (「没这行就是没起来」),不能被别的输出插到中间
|
|
160
|
-
process.stderr.write(
|
|
161
|
-
`游标 ${name} 停在 ${day},已超扫描窗口(最早 ${oldestScannedDay()})\n` +
|
|
162
|
-
`-> 那段消息已被 gc 回收,按 --since now 重新起头\n`,
|
|
163
|
-
)
|
|
164
|
-
store.seedCursorNow(name, a.app)
|
|
165
|
-
}
|
|
166
|
-
}
|
|
154
|
+
if (store.readCursor(name, a.app) === null) store.seedCursorNow(name, a.app)
|
|
167
155
|
}
|
|
168
156
|
|
|
157
|
+
// 标记「有人来取过」。回收判据是空闲时长,而它读 mtime --
|
|
158
|
+
// ⚠️ 必须在这里显式 touch:take 的正常路径只在**有批次**时写游标,
|
|
159
|
+
// 空手超时/被 SIGTERM 都不写(实测起一个 take 再 kill,mtime 一动不动)。
|
|
160
|
+
// 不 touch 就会把「安静的群盯了两天没消息」误判成遗弃并回收
|
|
161
|
+
store.touchCursor(name, a.app)
|
|
162
|
+
|
|
169
163
|
// 时长参数校验:负数/非数字过去会静默退回默认值或原样生效
|
|
170
164
|
// (实测 --debounce -5 被接受并打印「防抖=-5s」,--timeout abc 悄悄变成 12 小时)
|
|
171
165
|
const dur = (val, def, label) => {
|
|
@@ -217,52 +211,78 @@ async function cmdTake(argv) {
|
|
|
217
211
|
return 0
|
|
218
212
|
}
|
|
219
213
|
|
|
214
|
+
// cursors:**只读展示 + 一个提前忘掉的逃生舱**。
|
|
215
|
+
//
|
|
216
|
+
// ⚠️ 这里曾有 --prune / --force 与一套「该不该删」的判断(2026-09-06 删)。
|
|
217
|
+
// 删的理由不是简化:那套东西要求人拿着一个数字做破坏性决定,而**判据错了两次**
|
|
218
|
+
// (先按游标日期、后按待消费数,方向都是反的 —— 见 lib/cursors.js 文件头)。
|
|
219
|
+
// 现在回收由 collect 按空闲时长自动做,人不必判断,所以那些档位和确认步骤
|
|
220
|
+
// 一起失去了存在理由。留一个 --forget 给「现在就想清干净」。
|
|
220
221
|
function cmdCursors(argv) {
|
|
221
|
-
const CURSORS_FLAGS = ['
|
|
222
|
+
const CURSORS_FLAGS = ['forget', 'json', 'help']
|
|
222
223
|
const a = parseArgs(argv, { flags: CURSORS_FLAGS, keys: [] })
|
|
223
|
-
if (a._unknown.length) return rejectUnknown('lark-relay cursors',
|
|
224
|
+
if (a._unknown.length) return rejectUnknown('lark-relay cursors', a._unknown, CURSORS_FLAGS)
|
|
224
225
|
if (a.help) {
|
|
225
226
|
process.stdout.write(`${help.CURSORS_HELP}\n`)
|
|
226
227
|
return 0
|
|
227
228
|
}
|
|
228
229
|
const cursors = require('../lib/cursors')
|
|
230
|
+
const ttlDays = Math.round(cursors.IDLE_TTL_MS / 86400000)
|
|
231
|
+
|
|
232
|
+
// 人话的空闲时长。回收判据就是这个数,所以它必须是最显眼的一列
|
|
233
|
+
const idleTxt = (ms) => {
|
|
234
|
+
if (!Number.isFinite(ms)) return '未知'
|
|
235
|
+
// 向下取整:这个数是回收判据,宁可少报不可多报 --
|
|
236
|
+
// Math.round 会把 2.5 天显示成「3d」,看着像已经超了 2 天的 TTL 更多
|
|
237
|
+
const m = Math.floor(ms / 60000)
|
|
238
|
+
if (m < 60) return `${m}m`
|
|
239
|
+
const h = Math.floor(m / 60)
|
|
240
|
+
return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`
|
|
241
|
+
}
|
|
229
242
|
|
|
230
|
-
//
|
|
231
|
-
if (!a.
|
|
243
|
+
// 只读模式:列清单。删数据必须显式 --forget
|
|
244
|
+
if (!a.forget) {
|
|
232
245
|
const rows = cursors.listCursors()
|
|
233
246
|
if (a.json) {
|
|
234
247
|
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`)
|
|
235
248
|
return 0
|
|
236
249
|
}
|
|
237
250
|
if (!rows.length) {
|
|
238
|
-
process.stdout.write('没有游标(还没跑过 take
|
|
251
|
+
process.stdout.write('没有游标(还没跑过 take,或已全部回收)\n')
|
|
239
252
|
return 0
|
|
240
253
|
}
|
|
241
|
-
const mark = { live: '●', idle: '○',
|
|
254
|
+
const mark = { live: '●', idle: '○', expiring: '⚠' }
|
|
242
255
|
const note = {
|
|
243
|
-
live: (r) => `在跑 (pid ${r.pid})`,
|
|
244
|
-
idle: () =>
|
|
245
|
-
|
|
256
|
+
live: (r) => `在跑 (pid ${r.pid}, 待消费 ${r.pending})`,
|
|
257
|
+
idle: (r) => `空闲 ${idleTxt(r.idleMs)}(待消费 ${r.pending},重挂 take 接着盯)`,
|
|
258
|
+
expiring: (r) => `空闲 ${idleTxt(r.idleMs)} 超 ${ttlDays} 天 -> 下轮 gc 自动回收`,
|
|
246
259
|
}
|
|
247
260
|
for (const r of rows) {
|
|
261
|
+
process.stdout.write(`${mark[r.state]} ${r.name} app=${r.app} ${note[r.state](r)}\n`)
|
|
262
|
+
}
|
|
263
|
+
const expiring = rows.filter((r) => r.state === 'expiring').length
|
|
264
|
+
if (expiring) {
|
|
248
265
|
process.stdout.write(
|
|
249
|
-
|
|
266
|
+
`\n${expiring} 个已超 ${ttlDays} 天空闲,collect 每小时自查时会回收 —— 不用手动清\n`,
|
|
250
267
|
)
|
|
251
268
|
}
|
|
252
|
-
const stale = rows.filter((r) => r.state === 'stale').length
|
|
253
|
-
if (stale) process.stdout.write(`\n${stale} 个 stale -> lark-relay cursors --prune\n`)
|
|
254
269
|
return 0
|
|
255
270
|
}
|
|
256
271
|
|
|
257
|
-
// --
|
|
272
|
+
// --forget <name>...:不等 TTL,现在就清
|
|
258
273
|
const names = a._
|
|
259
|
-
|
|
274
|
+
if (!names.length) {
|
|
275
|
+
process.stderr.write(
|
|
276
|
+
`--forget 需要点名要忘掉哪个身份:lark-relay cursors --forget <name>...\n` +
|
|
277
|
+
`(不点名的批量清理已经不需要了 —— 空闲超 ${ttlDays} 天的由 collect 自动回收)\n`,
|
|
278
|
+
)
|
|
279
|
+
return 2
|
|
280
|
+
}
|
|
281
|
+
const res = cursors.forget(names)
|
|
260
282
|
|
|
261
283
|
// removed 的元素是 `name/app` —— 删的单位是 app,一个身份可能盯多个
|
|
262
|
-
for (const n of res.removed) process.stdout.write(
|
|
284
|
+
for (const n of res.removed) process.stdout.write(`已忘掉 ${n}\n`)
|
|
263
285
|
for (const n of res.missing) process.stderr.write(`没有这个游标身份:${n}\n`)
|
|
264
|
-
|
|
265
|
-
// 在跑的不删 -- take 会立刻把游标写回来。只有显式点名时才报告(见 cursors.prune)
|
|
266
286
|
for (const r of res.refused) {
|
|
267
287
|
process.stderr.write(
|
|
268
288
|
`跳过 ${r.name}:有 take 正在用它(pid ${r.pid})。\n` +
|
|
@@ -270,28 +290,14 @@ function cmdCursors(argv) {
|
|
|
270
290
|
)
|
|
271
291
|
}
|
|
272
292
|
|
|
273
|
-
// 「○ 暂停」的游标还能续接,删了就丢消费位置 -- 要 --force 再确认一次。
|
|
274
|
-
// 2026-09-06 真实踩到:点名删一个刚退出 take 的 idle 游标,那段消息的消费位置没了
|
|
275
|
-
for (const f of res.needForce) {
|
|
276
|
-
process.stderr.write(
|
|
277
|
-
`⚠ ${f.name}/${f.app} 是「○ 暂停」,位置 ${f.day} 仍在扫描窗口内\n` +
|
|
278
|
-
` 删了就丢续接凭据:重挂时会按 --since now 跳过这段已落盘的消息\n` +
|
|
279
|
-
` 确认要删 -> lark-relay cursors --prune ${f.name} --force\n`,
|
|
280
|
-
)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (!res.removed.length && !res.missing.length && !res.refused.length && !res.needForce.length) {
|
|
284
|
-
process.stdout.write(names.length ? '没删任何东西\n' : '没有 stale 游标,无需清理\n')
|
|
285
|
-
}
|
|
286
|
-
// 点名的目标一个都没删成才算失败
|
|
287
293
|
if (res.missing.length) return 2
|
|
288
|
-
if (
|
|
294
|
+
if (res.refused.length && !res.removed.length) return 3
|
|
289
295
|
return 0
|
|
290
296
|
}
|
|
291
297
|
|
|
292
298
|
async function cmdStatus(argv) {
|
|
293
299
|
const a = parseArgs(argv, { flags: ['json', 'help'], keys: [] })
|
|
294
|
-
if (a._unknown.length) return rejectUnknown('lark-relay status',
|
|
300
|
+
if (a._unknown.length) return rejectUnknown('lark-relay status', a._unknown, ['json'])
|
|
295
301
|
if (a.help) {
|
|
296
302
|
process.stdout.write(`${help.STATUS_HELP}\n`)
|
|
297
303
|
return 0
|
package/lib/args.js
CHANGED
|
@@ -104,14 +104,18 @@ function suggest(unknown, allowed) {
|
|
|
104
104
|
return null
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// 未知参数 -> stderr 短错误 +
|
|
107
|
+
// 未知参数 -> stderr 短错误 + 候选提示,exit 2。
|
|
108
108
|
// 不打印完整用法 -- 那看着像 --help 成功,会被当成「正常退出但没消息」
|
|
109
109
|
//
|
|
110
110
|
// ⚠️ `prog` 必须由调用方传入,不能写死 'lark-relay':两个 bin 共用这段,
|
|
111
|
-
//
|
|
111
|
+
// 而提示里那行是要照抄执行的 —— 打出一个不存在的命令
|
|
112
112
|
// (如已拆走的 `lark-relay collect`)会把人引向死路。
|
|
113
113
|
// lark-relay 传 `lark-relay <子命令>`,lark-relay-collect 传自己的名字。
|
|
114
|
-
|
|
114
|
+
//
|
|
115
|
+
// 注:2026-09-06 删掉了 rewriteArgv(把整条 argv 重写成可照跑的命令行,约 35 行)。
|
|
116
|
+
// 它是纯投机 —— 参数总共 9 个,`suggest` 已经点名了该写哪个,
|
|
117
|
+
// 再拼一条完整命令行属于替调用方猜意图,而那 35 行要长期跟着参数表走
|
|
118
|
+
function rejectUnknown(prog, unknown, allowed, out = process.stderr) {
|
|
115
119
|
const names = [...new Set(unknown)]
|
|
116
120
|
const shown = names.map((k) => `--${k}`).join(', ')
|
|
117
121
|
let hint = ''
|
|
@@ -122,46 +126,8 @@ function rejectUnknown(prog, argv, unknown, allowed, out = process.stderr) {
|
|
|
122
126
|
break
|
|
123
127
|
}
|
|
124
128
|
}
|
|
125
|
-
|
|
126
|
-
const tail = rewrite.length ? `\n你是不是想跑这个?\n ${prog} ${rewrite.join(' ')}` : ''
|
|
127
|
-
out.write(`未知参数:${shown}${hint}${tail}\n跑 \`${prog}\` 看完整用法\n`)
|
|
129
|
+
out.write(`未知参数:${shown}${hint}\n跑 \`${prog}\` 看完整用法\n`)
|
|
128
130
|
return 2
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
|
|
132
|
-
function rewriteArgv(argv, unknown, allowed) {
|
|
133
|
-
const map = new Map()
|
|
134
|
-
for (const k of unknown) {
|
|
135
|
-
const s = suggest(k, allowed)
|
|
136
|
-
if (s && !map.has(k)) map.set(k, s)
|
|
137
|
-
}
|
|
138
|
-
if (!map.size) return []
|
|
139
|
-
const out = []
|
|
140
|
-
for (let i = 0; i < argv.length; i++) {
|
|
141
|
-
const a = argv[i]
|
|
142
|
-
if (a.startsWith('--') && a !== '--') {
|
|
143
|
-
const eq = a.indexOf('=')
|
|
144
|
-
const k = eq !== -1 ? a.slice(2, eq) : a.slice(2)
|
|
145
|
-
if (map.has(k)) {
|
|
146
|
-
out.push(eq !== -1 ? `--${map.get(k)}=${a.slice(eq + 1)}` : `--${map.get(k)}`)
|
|
147
|
-
if (eq === -1) {
|
|
148
|
-
const next = argv[i + 1]
|
|
149
|
-
if (next !== undefined && !next.startsWith('--')) {
|
|
150
|
-
out.push(next)
|
|
151
|
-
i++
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
continue
|
|
155
|
-
}
|
|
156
|
-
if (eq === -1) {
|
|
157
|
-
const next = argv[i + 1]
|
|
158
|
-
if (next !== undefined && !next.startsWith('--')) i++
|
|
159
|
-
}
|
|
160
|
-
continue
|
|
161
|
-
}
|
|
162
|
-
out.push(a)
|
|
163
|
-
}
|
|
164
|
-
return out
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
module.exports = { parseArgs, numStrict, list, suggest, rejectUnknown, rewriteArgv }
|
|
133
|
+
module.exports = { parseArgs, numStrict, list, suggest, rejectUnknown }
|
package/lib/collect.js
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
const readline = require('readline')
|
|
10
10
|
const larkcli = require('./larkcli')
|
|
11
11
|
const store = require('./store')
|
|
12
|
+
const cursors = require('./cursors')
|
|
12
13
|
const { paths, ensureDir } = require('./paths')
|
|
14
|
+
const { acquireExclusive } = require('./lock')
|
|
13
15
|
const { init: logInit, logLine } = require('./log')
|
|
14
16
|
|
|
15
17
|
const EVENT_KEY = process.env.LARK_RELAY_EVENT_KEY || 'im.message.receive_v1'
|
|
@@ -120,6 +122,26 @@ async function runCollect(opts) {
|
|
|
120
122
|
// 非 launchd 场景(前台调试)是 no-op,logLine 退化成裸 stderr 直写
|
|
121
123
|
logInit()
|
|
122
124
|
|
|
125
|
+
// 互斥:同一台机器只该有一个 collect。
|
|
126
|
+
// ⚠️ 这比 take 的锁更要紧,而它长期没有:同一 app 服务端只放行一个 event bus,
|
|
127
|
+
// 第二个 collect 起来会把线上那个挤掉,双方按 1s->2s->...->60s 退避互抢,
|
|
128
|
+
// 实测每 app 重启约 10 次、累计约 5 分钟才稳定 —— 那 5 分钟的消息**永久丢失**。
|
|
129
|
+
// 现实诱因不是手滑:COLLECT_HELP 教「前台跑(调试)」,deploy-launchd.sh 在
|
|
130
|
+
// bootstrap 失败时更直接建议「应急顶住采集:lark-relay-collect」--
|
|
131
|
+
// 若 launchd 那个其实还活着,照做就是自己抢自己。
|
|
132
|
+
// 拿不到锁就退出并给出判据,不留「看着在跑其实在互抢」的中间态
|
|
133
|
+
const lock = acquireExclusive('collect')
|
|
134
|
+
if (!lock.ok) {
|
|
135
|
+
logLine(
|
|
136
|
+
`error: 已有一个 lark-relay-collect 在跑(pid ${lock.holder}),本进程退出。\n` +
|
|
137
|
+
`同一 app 服务端只放行一个 event bus —— 两个 collect 会互相挤掉并退避重连,\n` +
|
|
138
|
+
`实测约 5 分钟才稳定,那段时间的消息永久丢失。\n` +
|
|
139
|
+
`确认是谁在跑: launchctl print system/com.adaex.lark-relay-collect\n` +
|
|
140
|
+
`要接管请先停掉它:sudo launchctl bootout system/com.adaex.lark-relay-collect`,
|
|
141
|
+
)
|
|
142
|
+
return 1
|
|
143
|
+
}
|
|
144
|
+
|
|
123
145
|
const exclude = new Set(opts.exclude || [])
|
|
124
146
|
let profiles
|
|
125
147
|
try {
|
|
@@ -170,22 +192,34 @@ async function runCollect(opts) {
|
|
|
170
192
|
const collectors = chosen.map((p) => new AppCollector(p.name, opts))
|
|
171
193
|
for (const c of collectors) c.start()
|
|
172
194
|
|
|
173
|
-
// gc
|
|
174
|
-
// 另起 gc 命令 +
|
|
175
|
-
|
|
195
|
+
// gc 在本进程内每小时自查:回收逻辑就是「删超期的东西」,
|
|
196
|
+
// 另起 gc 命令 + timer 是为几行逻辑加两个部署件。
|
|
197
|
+
// store 按天删目录,cursors 按空闲时长删 —— 同构,故同处一地
|
|
198
|
+
const runGc = () => {
|
|
176
199
|
try {
|
|
177
200
|
const s = store.gcStore(opts.retain)
|
|
178
|
-
if (s.length) {
|
|
179
|
-
|
|
201
|
+
if (s.length) logLine(`gc: 删 store ${s.length} 个日期目录`)
|
|
202
|
+
} catch (err) {
|
|
203
|
+
logLine(`gc: store 回收失败 ${err.message}`)
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
// 游标回收:空闲超 TTL 的自动删。**这是纪律「不盯了就清游标」的落地方式** --
|
|
207
|
+
// 那条纪律三处成文却零执行,靠人记不住;而判据本身还错过两次
|
|
208
|
+
// (见 lib/cursors.js 文件头)。引擎自己过期,不需要人判断
|
|
209
|
+
const c = cursors.gcCursors()
|
|
210
|
+
if (c.length) {
|
|
211
|
+
logLine(
|
|
212
|
+
`gc: 回收 ${c.length} 个空闲超 ${Math.round(cursors.IDLE_TTL_MS / 86400000)} 天的游标:` +
|
|
213
|
+
`${c.join(' ')}`,
|
|
214
|
+
)
|
|
180
215
|
}
|
|
181
216
|
} catch (err) {
|
|
182
|
-
logLine(`gc:
|
|
217
|
+
logLine(`gc: 游标回收失败 ${err.message}`)
|
|
183
218
|
}
|
|
184
|
-
}
|
|
219
|
+
}
|
|
220
|
+
const gcTimer = setInterval(runGc, GC_INTERVAL_MS)
|
|
185
221
|
// 启动时先跑一次,别等一小时
|
|
186
|
-
|
|
187
|
-
store.gcStore(opts.retain)
|
|
188
|
-
} catch {}
|
|
222
|
+
runGc()
|
|
189
223
|
|
|
190
224
|
return await new Promise((resolve) => {
|
|
191
225
|
let shuttingDown = false
|
package/lib/cursors.js
CHANGED
|
@@ -1,41 +1,47 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// cursors:游标治理 --
|
|
3
|
+
// cursors:游标治理 -- 只做两件事:**如实展示**每个游标的状态,
|
|
4
|
+
// 以及**按空闲时长自动回收**(由 collect 的 gc 每小时调一次)。
|
|
4
5
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
6
|
+
// ⚠️ 这个模块 2026-09-06 一天内改了三版。前两版都错在同一个地方:
|
|
7
|
+
// **让人拿着一个推断量去做破坏性决定**。记下来免得再走一遍:
|
|
8
|
+
//
|
|
9
|
+
// ① 第一版判据「游标日期超出扫描窗口 -> 删掉无损」。
|
|
10
|
+
// 实测证伪:afterCursor 比的是「事件日期 > 游标日期」,游标越老放行的事件**越多**。
|
|
11
|
+
// 停在 D-5 的游标能取到窗口内 3 条,而 --since now 取到 0 条。
|
|
12
|
+
// 照它删/重置就是丢掉全部积压。
|
|
13
|
+
// ② 第二版改成实测量「pending = 按这个游标续接还剩几条」,以为方向对了。
|
|
14
|
+
// **仍然是反的** —— 实测两种剧本:
|
|
15
|
+
// 被遗弃两天的游标(app 还在收消息) -> pending 50 -> 判 backlog -> 保护它
|
|
16
|
+
// 活跃任务刚处理完一批、take 暂退 -> pending 0 -> 判 drained -> 删掉它
|
|
17
|
+
// 该清的被保护,该留的被删。因为**落后量是遗弃的症状,不是遗弃的反面**。
|
|
18
|
+
// 更糟:只要 app 还在收消息,被遗弃游标的 pending 单调增长,永不归零 --
|
|
19
|
+
// 裸 prune 对真正该清的东西**永久无效**(实测 gc 删掉游标所在那天后 pending 仍不变)。
|
|
20
|
+
//
|
|
21
|
+
// **正解:判据是「多久没人用过它」,不是「它落后多少」。**
|
|
22
|
+
// mtime 一直都在,从来没被读过。这个方向与遗弃程度同向单调:越久没碰越该清。
|
|
23
|
+
// 而且它让清理**不再需要人做判断** -- 前两次犯错都发生在
|
|
24
|
+
// 「人拿着一个数字决定删不删」这一步,这一版把那一步整个删掉:引擎自己过期。
|
|
12
25
|
const fs = require('fs')
|
|
13
26
|
const path = require('path')
|
|
14
27
|
const store = require('./store')
|
|
15
|
-
const { paths
|
|
28
|
+
const { paths } = require('./paths')
|
|
16
29
|
const { lockPathFor, holderPid } = require('./lock')
|
|
17
30
|
|
|
18
|
-
//
|
|
19
|
-
// 游标位置的日期早于扫描窗口最老的一天时,续接它和 --since now 行为**完全等价** --
|
|
20
|
-
// scanWindow 只看最近 SCAN_DAYS 天的目录,更早的日期 collect 的 gc 已经整目录删了,
|
|
21
|
-
// afterCursor 过滤后必然为空。也就是说**删掉这种游标是无损的**,不会丢任何还能取到的消息。
|
|
31
|
+
// 空闲多久算遗弃。默认 2 天。
|
|
22
32
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
function isStaleDay(day) {
|
|
36
|
-
if (!day) return true // 读不出日期的游标续接不了,按失效处理
|
|
37
|
-
return day < oldestScannedDay()
|
|
38
|
-
}
|
|
33
|
+
// 为什么是 2 天而不是更长(2026-09-06 用户定):take 是**实时场景**的取用口 --
|
|
34
|
+
// 「关了会话过阵子接着盯」的窗口本来就短。空闲一两天以上再重挂,
|
|
35
|
+
// 下一次消费一定是从新的开始;真要历史消息有专门的接口,不靠游标回放。
|
|
36
|
+
//
|
|
37
|
+
// 代价说清楚:超过 TTL 的游标被回收后,重挂按 --since now 起头,
|
|
38
|
+
// 跳过这段已落盘的消息。但 store 只保留 SCAN_DAYS(默认 3)天 --
|
|
39
|
+
// TTL 2 天时,被回收的游标最多也就跳过盘上还剩的那一天多,
|
|
40
|
+
// 而那种情况本身就是「两天没人管」。这个代价是可接受的,且是**显式**的
|
|
41
|
+
const IDLE_TTL_MS = (() => {
|
|
42
|
+
const d = Number(process.env.LARK_RELAY_CURSOR_TTL_DAYS)
|
|
43
|
+
return (Number.isFinite(d) && d > 0 ? d : 2) * 86400_000
|
|
44
|
+
})()
|
|
39
45
|
|
|
40
46
|
// 活消费者判定:复用 take 已经在用的锁,不另造注册表。
|
|
41
47
|
// take 用 `take-<name>` 作锁键(bin/lark-relay.js),锁目录里的 pid 文件就是持有者
|
|
@@ -43,9 +49,17 @@ function livePidOf(name) {
|
|
|
43
49
|
return holderPid(`take-${name}`)
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
/**
|
|
53
|
+
* 枚举 cursors/ 下的全部游标。
|
|
54
|
+
*
|
|
55
|
+
* 一个 name 目录下可能有多个 app 文件,外加 dedup 的 <app>.seen.json 与落盘用的 .tmp --
|
|
56
|
+
* 只有「没后缀的那些」才是游标本身,别把 dedup 文件当成一个 app。
|
|
57
|
+
*
|
|
58
|
+
* @returns {Array<{name,app,cursor,day,state,pid,idleMs,pending}>}
|
|
59
|
+
* state: live(有 take 持锁) | idle(空闲但在 TTL 内) | expiring(超 TTL,下轮 gc 回收)
|
|
60
|
+
* idleMs: 距最后一次被使用多久 —— **回收判据**
|
|
61
|
+
* pending: 按此游标续接还剩几条 —— 仅作展示(它不是回收判据,见文件头 ②)
|
|
62
|
+
*/
|
|
49
63
|
function listCursors() {
|
|
50
64
|
let names
|
|
51
65
|
try {
|
|
@@ -53,6 +67,14 @@ function listCursors() {
|
|
|
53
67
|
} catch {
|
|
54
68
|
return [] // 还没跑过任何 take
|
|
55
69
|
}
|
|
70
|
+
// 同一 app 可能被多个身份盯着,scanWindow 一次就够 —— 每行都扫一遍是平方级浪费
|
|
71
|
+
const scanCache = new Map()
|
|
72
|
+
const scanOf = (app) => {
|
|
73
|
+
if (!scanCache.has(app)) scanCache.set(app, store.scanWindow(app))
|
|
74
|
+
return scanCache.get(app)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const now = Date.now()
|
|
56
78
|
const out = []
|
|
57
79
|
for (const name of names.sort()) {
|
|
58
80
|
const dir = path.join(paths.cursors, name)
|
|
@@ -68,11 +90,20 @@ function listCursors() {
|
|
|
68
90
|
if (f.startsWith('.') || f.endsWith('.tmp') || f.endsWith('.seen.json')) continue
|
|
69
91
|
const key = store.readCursor(name, f)
|
|
70
92
|
if (!key) continue
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
const state = pid ? 'live' :
|
|
75
|
-
out.push({
|
|
93
|
+
const mtime = store.cursorMtime(name, f)
|
|
94
|
+
const idleMs = mtime === null ? Infinity : Math.max(0, now - mtime)
|
|
95
|
+
// live 优先:take 正在跑就是在用,不看空闲时长
|
|
96
|
+
const state = pid ? 'live' : idleMs >= IDLE_TTL_MS ? 'expiring' : 'idle'
|
|
97
|
+
out.push({
|
|
98
|
+
name,
|
|
99
|
+
app: f,
|
|
100
|
+
cursor: key,
|
|
101
|
+
day: String(key).split('/')[0] || null,
|
|
102
|
+
state,
|
|
103
|
+
pid,
|
|
104
|
+
idleMs,
|
|
105
|
+
pending: scanOf(f).filter((e) => store.afterCursor(e, key)).length,
|
|
106
|
+
})
|
|
76
107
|
}
|
|
77
108
|
}
|
|
78
109
|
return out
|
|
@@ -83,9 +114,9 @@ function listCursors() {
|
|
|
83
114
|
// 被误判成重复而静默丢掉(TTL 6 小时,见 dedup.js)。
|
|
84
115
|
//
|
|
85
116
|
// ⚠️ 粒度必须是 app 而不是整个身份目录:一个 --name 可以同时盯多个 app
|
|
86
|
-
// (cursors/<name>/appA、<name>/appB)
|
|
87
|
-
// 早先这里 rm -rf 整个 name 目录,于是「appA
|
|
88
|
-
// 清 appA 会把 appB
|
|
117
|
+
// (cursors/<name>/appA、<name>/appB),而空闲时长是**逐 app 判定**的。
|
|
118
|
+
// 早先这里 rm -rf 整个 name 目录,于是「appA 空闲很久、appB 昨天还在用」时
|
|
119
|
+
// 清 appA 会把 appB 一起删掉(实测复现)。
|
|
89
120
|
function removeCursorApp(name, app) {
|
|
90
121
|
const dir = path.join(paths.cursors, name)
|
|
91
122
|
let ok = false
|
|
@@ -103,7 +134,7 @@ function removeCursorApp(name, app) {
|
|
|
103
134
|
}
|
|
104
135
|
|
|
105
136
|
// 顺带清掉该身份的锁目录(若持有者已死)。
|
|
106
|
-
//
|
|
137
|
+
// 删了游标却留着 take-<name>.lock 会在 run/ 里积累孤儿锁 --
|
|
107
138
|
// 不致命(下次抢锁会夺走陈旧锁),但既然在清理就一并收干净
|
|
108
139
|
function removeStaleLock(name) {
|
|
109
140
|
if (holderPid(`take-${name}`)) return // 还活着,不碰
|
|
@@ -113,17 +144,42 @@ function removeStaleLock(name) {
|
|
|
113
144
|
}
|
|
114
145
|
|
|
115
146
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
147
|
+
* 自动回收:删掉空闲超过 TTL 的游标。**由 collect 的 gc 每小时调一次**,
|
|
148
|
+
* 与 store.gcStore 完全同构 —— 都是「删超期的东西」,不需要人参与。
|
|
149
|
+
*
|
|
150
|
+
* 这是本模块存在的理由。前两版把回收做成「人拿着一个数字敲 --prune」,
|
|
151
|
+
* 结果纪律三处成文、零执行,而判据本身还错了两次。
|
|
152
|
+
* 引擎自己过期不需要人记得,也不需要人判断。
|
|
153
|
+
*
|
|
154
|
+
* ⚠️ 有 take 持锁的一律不动 —— 它正在用,而且它会立刻把游标写回来。
|
|
155
|
+
*
|
|
156
|
+
* @returns {string[]} 被回收的 `name/app`
|
|
157
|
+
*/
|
|
158
|
+
function gcCursors() {
|
|
159
|
+
const removed = []
|
|
160
|
+
const touchedNames = new Set()
|
|
161
|
+
for (const c of listCursors()) {
|
|
162
|
+
if (c.state !== 'expiring') continue
|
|
163
|
+
if (removeCursorApp(c.name, c.app)) {
|
|
164
|
+
removed.push(`${c.name}/${c.app}`)
|
|
165
|
+
touchedNames.add(c.name)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const n of touchedNames) removeStaleLock(n)
|
|
169
|
+
return removed
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 逃生舱:立刻忘掉指定身份(该身份下全部 app),不等 TTL。
|
|
174
|
+
* 用于「刚验完、现在就想清干净」。
|
|
175
|
+
*
|
|
176
|
+
* 不设 --force 二次确认:回收本来就会自动发生,人只是提前几天,
|
|
177
|
+
* 没有「删了会丢消息」的额外判断负担 —— 那正是前两版的坑。
|
|
178
|
+
*
|
|
179
|
+
* @returns {{removed:string[], refused:Array<{name,pid}>, missing:string[]}}
|
|
123
180
|
*/
|
|
124
|
-
function
|
|
181
|
+
function forget(names = []) {
|
|
125
182
|
const all = listCursors()
|
|
126
|
-
// 一个 name 可能有多个 app 行,按身份归并 -- live 判定是按身份的(锁以 name 为键)
|
|
127
183
|
const byName = new Map()
|
|
128
184
|
for (const c of all) {
|
|
129
185
|
if (!byName.has(c.name)) byName.set(c.name, [])
|
|
@@ -133,11 +189,8 @@ function prune(names = [], opts = {}) {
|
|
|
133
189
|
const removed = []
|
|
134
190
|
const refused = []
|
|
135
191
|
const missing = []
|
|
136
|
-
const needForce = []
|
|
137
192
|
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
for (const name of targets) {
|
|
193
|
+
for (const name of names) {
|
|
141
194
|
const rows = byName.get(name)
|
|
142
195
|
if (!rows) {
|
|
143
196
|
missing.push(name)
|
|
@@ -145,24 +198,12 @@ function prune(names = [], opts = {}) {
|
|
|
145
198
|
}
|
|
146
199
|
const live = rows.find((r) => r.state === 'live')
|
|
147
200
|
if (live) {
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
// 报告它是噪音(实测:3 个 take 在跑时,一次日常清理吐 3 段无关提示)
|
|
151
|
-
if (names.length) refused.push({ name, pid: live.pid })
|
|
201
|
+
// 在跑的不删:take 会立刻把游标写回来,删了只是骗自己
|
|
202
|
+
refused.push({ name, pid: live.pid })
|
|
152
203
|
continue
|
|
153
204
|
}
|
|
154
|
-
// 显式点名 = 清该身份的全部 app(用户明确不盯了);
|
|
155
|
-
// 裸 prune = 只清 stale 的那些 app,窗口内的留着续接
|
|
156
|
-
const victims = names.length ? rows : rows.filter((r) => r.state === 'stale')
|
|
157
205
|
let deletedAny = false
|
|
158
|
-
for (const r of
|
|
159
|
-
// ⚠️ 「○ 暂停」= 位置还在扫描窗口内,**删了就丢续接凭据**(重挂会按 --since now
|
|
160
|
-
// 跳过这段已落盘的消息)。stale 删掉无损,idle 不是 -- 故 idle 要 --force 兜一道。
|
|
161
|
-
// 2026-09-06 真实踩到:点名删一个刚退出 take 的 idle 游标,消费位置就此丢失
|
|
162
|
-
if (r.state === 'idle' && !opts.force) {
|
|
163
|
-
needForce.push({ name, app: r.app, day: r.day })
|
|
164
|
-
continue
|
|
165
|
-
}
|
|
206
|
+
for (const r of rows) {
|
|
166
207
|
if (removeCursorApp(name, r.app)) {
|
|
167
208
|
removed.push(`${name}/${r.app}`)
|
|
168
209
|
deletedAny = true
|
|
@@ -171,14 +212,13 @@ function prune(names = [], opts = {}) {
|
|
|
171
212
|
if (deletedAny) removeStaleLock(name)
|
|
172
213
|
}
|
|
173
214
|
|
|
174
|
-
return { removed, refused, missing
|
|
215
|
+
return { removed, refused, missing }
|
|
175
216
|
}
|
|
176
217
|
|
|
177
218
|
module.exports = {
|
|
219
|
+
IDLE_TTL_MS,
|
|
178
220
|
listCursors,
|
|
179
|
-
|
|
221
|
+
gcCursors,
|
|
222
|
+
forget,
|
|
180
223
|
removeCursorApp,
|
|
181
|
-
cursorDay,
|
|
182
|
-
isStaleDay,
|
|
183
|
-
oldestScannedDay,
|
|
184
224
|
}
|
package/lib/help.js
CHANGED
|
@@ -48,10 +48,8 @@ function takeHelp(apps) {
|
|
|
48
48
|
最佳实践
|
|
49
49
|
· 一直等不到消息 -> 九成是 bot 不在群,回第二步验(最常见故障)
|
|
50
50
|
· 多个消费者盯同一 app 用不同 --name,游标互不干扰
|
|
51
|
-
·
|
|
52
|
-
|
|
53
|
-
一直挂「落后 N 条」,而 store 只保留几天 -- N 会随过期删除而变小,
|
|
54
|
-
看着像追上了,其实是消息没了`
|
|
51
|
+
· **不用手动清游标**:空闲超 2 天的由 collect 自动回收。
|
|
52
|
+
想立刻清干净:lark-relay cursors --forget <name>`
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
const COLLECT_HELP = `把全部 lark-cli profile 的事件收下来,原子落盘。零参数、零配置。
|
|
@@ -67,6 +65,11 @@ app 列表实时读 \`lark-cli profile list\`,新增 profile 自动纳入,无需
|
|
|
67
65
|
tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是死循环)。
|
|
68
66
|
用户重新 login 后下次启动自动纳入。
|
|
69
67
|
|
|
68
|
+
⚠️ **同一台机器只能有一个 collect**,第二个会拒绝启动并报出持有者 pid。
|
|
69
|
+
不是洁癖:同一 app 服务端只放行一个 event bus,两个 collect 会互相挤掉并按
|
|
70
|
+
1s->2s->...->60s 退避重连,实测约 5 分钟才稳定 -- 那段时间的消息永久丢失。
|
|
71
|
+
所以下面那条「前台跑(调试)」在常驻服务已在跑时会直接退出,不会抢它。
|
|
72
|
+
|
|
70
73
|
为什么必须常驻:lark 事件是流式的,进程不在的时刻消息永久丢失(实测:消息发出
|
|
71
74
|
8s 后才起 consumer,收到 0 条)。collect 独立常驻,才能让 take 侧
|
|
72
75
|
崩了、AI 跑 30 分钟、会话关几小时,都不丢消息。
|
|
@@ -81,8 +84,9 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
|
|
|
81
84
|
|
|
82
85
|
参数
|
|
83
86
|
--exclude <apps> 排除指定 profile(逗号分隔)
|
|
84
|
-
--retain N store 保留天数(默认 3
|
|
85
|
-
|
|
87
|
+
--retain N store 保留天数(默认 3,**必须等于**消费侧扫描窗口;
|
|
88
|
+
留久了「在盘上却扫不到」,短了「空目录被当成还能续接」,
|
|
89
|
+
要改就同时设 LARK_RELAY_SCAN_DAYS)
|
|
86
90
|
|
|
87
91
|
环境变量
|
|
88
92
|
LARK_RELAY_LOG_MAX_MB 单个日志文件上限(默认 16),超了轮转
|
|
@@ -105,37 +109,47 @@ const STATUS_HELP = `一眼看清全局:谁在跑、积压多少、游标在哪
|
|
|
105
109
|
lark-relay status # 概览
|
|
106
110
|
lark-relay status --json # 机器可读
|
|
107
111
|
|
|
108
|
-
|
|
109
|
-
● 在跑
|
|
110
|
-
○
|
|
111
|
-
⚠
|
|
112
|
+
消费者状态:
|
|
113
|
+
● 在跑 有 take 持锁,pid 是它
|
|
114
|
+
○ 空闲 Nh 没人在跑,但最近用过 —— 重挂 take 接着盯
|
|
115
|
+
⚠ 空闲 Nd 超过空闲期,下轮 gc 自动回收(不用手动清)
|
|
116
|
+
|
|
117
|
+
判据是**空闲多久**,不是落后多少条。落后量只作展示:
|
|
118
|
+
它是遗弃的症状而不是反面 —— 被遗弃的游标落后会一路涨,
|
|
119
|
+
拿它当清理判据会把该清的保护起来、该留的删掉(2026-09-06 实测)。
|
|
112
120
|
|
|
113
|
-
排障入口:「○
|
|
121
|
+
排障入口:「○ 空闲」而你以为它在盯 -> 它的 take 没起来或早退了;
|
|
114
122
|
「无消费者」= 白采,可考虑 --exclude。
|
|
115
|
-
stale 游标清理:lark-relay cursors --prune
|
|
116
123
|
collect 行不是 running -> tail -f ~/.lark-relay/logs/collect.err.log
|
|
117
124
|
(launchd 不进统一日志),collect 停摆的每一秒都在丢消息。`
|
|
118
125
|
|
|
119
|
-
const CURSORS_HELP =
|
|
126
|
+
const CURSORS_HELP = `游标状态:谁在值守、空闲多久。**清理是自动的,不用你记。**
|
|
120
127
|
|
|
121
128
|
lark-relay cursors # 列出全部游标及状态
|
|
122
|
-
lark-relay cursors --
|
|
123
|
-
lark-relay cursors --
|
|
124
|
-
|
|
129
|
+
lark-relay cursors --json # 机器可读
|
|
130
|
+
lark-relay cursors --forget <name>... # 不等超期,现在就清掉
|
|
131
|
+
|
|
132
|
+
状态:● 在跑 / ○ 空闲(最近用过)/ ⚠ 空闲超期(下轮 gc 回收)
|
|
133
|
+
|
|
134
|
+
**空闲超过 2 天的游标由 collect 自动回收**(每小时自查一次,与 store 的
|
|
135
|
+
日期目录回收同一个 gc)。判据是「多久没人用过它」-- 与遗弃程度同向单调,
|
|
136
|
+
且不需要人做判断。默认 2 天:take 是实时场景的取用口,
|
|
137
|
+
空闲一两天以上再重挂,下一次消费一定从新的开始;要历史消息有专门的接口。
|
|
138
|
+
改期限:LARK_RELAY_CURSOR_TTL_DAYS=<天>
|
|
125
139
|
|
|
126
|
-
|
|
140
|
+
⚠️ 判据**不是**「落后多少条」,也不是「游标停在哪天」 -- 这两个都试过、都错了
|
|
141
|
+
(方向反的,见下)。落后量只作展示。
|
|
127
142
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
⚠ stale 直接清。那段消息 gc 已回收,续接和 --since now 等价 -> 删它无损
|
|
143
|
+
● 在跑的一律不回收 —— take 会立刻把游标写回来
|
|
144
|
+
被回收后重挂 = 按 --since now 起头,跳过那段已落盘的消息。
|
|
145
|
+
这正是「两天没人管」该有的行为;真要接着取,在超期前重挂就行
|
|
132
146
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
--forget 是逃生舱(验完了、现在就想清干净)。它不需要二次确认 --
|
|
148
|
+
回收本来会自动发生,人只是提前几天。
|
|
149
|
+
删的单位是 app 目录下的每个 app,连带删该 app 的 dedup 记录
|
|
150
|
+
(<app>.seen.json),不留会让重挂后头几条被误判重复。
|
|
136
151
|
|
|
137
|
-
退出码:0 正常,2 参数错或点名的身份不存在,
|
|
138
|
-
3 点名的目标一个都没清掉(在跑,或需要 --force)`
|
|
152
|
+
退出码:0 正常,2 参数错或点名的身份不存在,3 点名的在跑(没清掉)`
|
|
139
153
|
|
|
140
154
|
const GUIDE = `lark-relay -- Lark 事件中继站。两件事:收下来 / 我来取。
|
|
141
155
|
|
|
@@ -158,12 +172,10 @@ take 四步(照做)
|
|
|
158
172
|
起来后 stderr 有一行「监听中」-- 没有就是没起来,看 stderr。
|
|
159
173
|
一批处理完再起一个。不要自己写 while 循环 -- 进程退出会通知你。
|
|
160
174
|
退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
|
|
161
|
-
4.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
看着像追上了,其实是消息永久没了)。
|
|
166
|
-
显式清掉某个身份:lark-relay cursors --prune <name>
|
|
175
|
+
4. 不盯了就走开 什么都不用做
|
|
176
|
+
空闲超 2 天的游标由 collect 自动回收(每小时自查),不用你记得清理。
|
|
177
|
+
想立刻清干净:lark-relay cursors --forget <name>
|
|
178
|
+
看状态:lark-relay cursors(○ 空闲 / ⚠ 超期待回收 / ● 在跑)
|
|
167
179
|
|
|
168
180
|
常见错误
|
|
169
181
|
· 拿不到消息 -> 九成是 bot 不在群。验 bot 必须 --as user,
|
package/lib/larkcli.js
CHANGED
|
@@ -23,32 +23,15 @@ function run(args, opts = {}) {
|
|
|
23
23
|
})
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// lark-cli 的 stdout 可能有前置非 JSON 行(如「installed successfully」自更新提示),
|
|
27
|
-
// 剥到第一个 { 起。这是 lark-cli 的输出契约,故与 run 同处一地。
|
|
28
|
-
//
|
|
29
|
-
// ⚠️ **只适用于对象响应**:数组响应会被切坏 ——
|
|
30
|
-
// stripToJson('[{"a":1}]') → '{"a":1}]' → JSON.parse 抛错。
|
|
31
|
-
// listProfiles 解析的正是数组,所以它不能用这个(它直接 JSON.parse)
|
|
32
|
-
function stripToJson(s) {
|
|
33
|
-
const i = String(s).indexOf('{')
|
|
34
|
-
return i === -1 ? '{}' : String(s).slice(i)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// execFile 失败时错误信息散在三个字段里,取第一个有内容的
|
|
38
|
-
function errText(err) {
|
|
39
|
-
return String(err.stdout || err.stderr || err.message || err)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// 压成单行并截断,给 stderr 日志用。行首那段才是错误码/原因所在
|
|
43
|
-
function tail(s, n = 300) {
|
|
44
|
-
return String(s).replace(/\n/g, ' ').slice(0, n)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
26
|
// profile list 默认输出 JSON,无需 --json。
|
|
48
27
|
// 两类不可用,都要跳过,否则 consume 一直失败重试(死循环):
|
|
49
28
|
// - tokenStatus 为 expired:授权过期,永久失败直到用户重新 login
|
|
50
29
|
// - 没有 user 字段:该 profile 从未 auth 过(实测未登录的 profile 只有
|
|
51
30
|
// name/appId/brand/active 四个字段,无 user 也无 tokenStatus)
|
|
31
|
+
//
|
|
32
|
+
// 注:2026-09-06 删掉了 stripToJson / errText / tail 三个导出 —— 零调用方
|
|
33
|
+
// (含测试)。stripToJson 还带着 6 行「只适用于对象响应」的警告,
|
|
34
|
+
// 等于为没人调用的函数维护陷阱说明。要用再从 git 历史取
|
|
52
35
|
async function listProfiles() {
|
|
53
36
|
const { stdout } = await run(['profile', 'list'])
|
|
54
37
|
const arr = JSON.parse(stdout)
|
|
@@ -78,4 +61,4 @@ function spawnConsume(profile, eventKey, extraArgs = []) {
|
|
|
78
61
|
return spawn(CLI, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
79
62
|
}
|
|
80
63
|
|
|
81
|
-
module.exports = { CLI, run,
|
|
64
|
+
module.exports = { CLI, run, listProfiles, spawnConsume }
|
package/lib/render.js
CHANGED
|
Binary file
|
package/lib/status.js
CHANGED
|
@@ -50,19 +50,13 @@ function collectHint() {
|
|
|
50
50
|
return `sudo launchctl bootstrap system /Library/LaunchDaemons/${COLLECT_LABEL}.plist`
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// 消费者 = cursors/
|
|
54
|
-
//
|
|
53
|
+
// 消费者 = cursors/ 下的目录。枚举与状态判定都在 lib/cursors.js,这里只做展示 ——
|
|
54
|
+
// 「哪些游标存在、谁还活着、还剩多少没消费」是治理逻辑,
|
|
55
|
+
// status 和 cursors 子命令必须用同一份口径
|
|
55
56
|
function listConsumers() {
|
|
56
57
|
return cursors.listCursors()
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
// 落后多少条:游标之后还剩几个事件没消费
|
|
60
|
-
function lagOf(app, cursor) {
|
|
61
|
-
const all = store.scanWindow(app)
|
|
62
|
-
if (!cursor) return all.length
|
|
63
|
-
return all.filter((e) => store.afterCursor(e, cursor)).length
|
|
64
|
-
}
|
|
65
|
-
|
|
66
60
|
async function buildStatus() {
|
|
67
61
|
const { state, pid } = serviceState()
|
|
68
62
|
|
|
@@ -90,10 +84,18 @@ async function buildStatus() {
|
|
|
90
84
|
latestMs = Number(ev?.timestamp || ev?.create_time) || null
|
|
91
85
|
}
|
|
92
86
|
const cs = (byApp.get(app) || []).map((c) => {
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
return {
|
|
87
|
+
// lag = 实测待消费条数。它是**展示量,不是治理判据** ——
|
|
88
|
+
// 判据是空闲时长(见 lib/cursors.js):落后量是遗弃的症状,不是遗弃的反面,
|
|
89
|
+
// 拿它决定删不删会把「该清的保护起来、该留的删掉」(2026-09-06 实测)
|
|
90
|
+
return {
|
|
91
|
+
name: c.name,
|
|
92
|
+
lag: c.pending,
|
|
93
|
+
state: c.state,
|
|
94
|
+
pid: c.pid,
|
|
95
|
+
cursor: c.cursor,
|
|
96
|
+
day: c.day,
|
|
97
|
+
idleMs: c.idleMs,
|
|
98
|
+
}
|
|
97
99
|
})
|
|
98
100
|
rows.push({ app, today: store.countToday(app), latestMs, consumers: cs })
|
|
99
101
|
}
|
|
@@ -127,9 +129,18 @@ function formatStatus(s) {
|
|
|
127
129
|
}
|
|
128
130
|
const w = (str, n) => String(str) + ' '.repeat(Math.max(1, n - dispWidth(str)))
|
|
129
131
|
out.push(`${w('app', 8)}${w('今日事件', 10)}${w('最新事件', 13)}消费者`)
|
|
130
|
-
let
|
|
132
|
+
let expiringCount = 0
|
|
133
|
+
const idleTxt = (ms) => {
|
|
134
|
+
if (!Number.isFinite(ms)) return '未知'
|
|
135
|
+
// 向下取整:这个数是回收判据,宁可少报不可多报 --
|
|
136
|
+
// Math.round 会把 2.5 天显示成「3d」,看着像已经超了 2 天的 TTL 更多
|
|
137
|
+
const m = Math.floor(ms / 60000)
|
|
138
|
+
if (m < 60) return `${m}m`
|
|
139
|
+
const h = Math.floor(m / 60)
|
|
140
|
+
return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`
|
|
141
|
+
}
|
|
131
142
|
for (const r of s.apps) {
|
|
132
|
-
//
|
|
143
|
+
// 消费者各占一行:状态要一眼分得清,挤在一行里 ●/○/⚠ 会被淹掉。
|
|
133
144
|
// 「谁在跑」是排障第一问,以前得靠猜(全都长成 `name(落后 N 条)`)
|
|
134
145
|
if (!r.consumers.length) {
|
|
135
146
|
out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}—(无消费者)`)
|
|
@@ -137,28 +148,25 @@ function formatStatus(s) {
|
|
|
137
148
|
}
|
|
138
149
|
out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}`.trimEnd())
|
|
139
150
|
for (const c of r.consumers) {
|
|
140
|
-
const lagTxt = c.lag === 0 ? '游标追平' : `落后 ${c.lag} 条`
|
|
141
151
|
let mark
|
|
142
152
|
let note
|
|
143
153
|
if (c.state === 'live') {
|
|
144
154
|
mark = '●'
|
|
145
|
-
note = `在跑 (pid ${c.pid}, ${
|
|
146
|
-
} else if (c.state === '
|
|
147
|
-
|
|
155
|
+
note = `在跑 (pid ${c.pid}, ${c.lag === 0 ? '游标追平' : `落后 ${c.lag} 条`})`
|
|
156
|
+
} else if (c.state === 'expiring') {
|
|
157
|
+
expiringCount++
|
|
148
158
|
mark = '⚠'
|
|
149
|
-
|
|
150
|
-
note = `stale (游标停在 ${c.day || '?'},已超 ${cursors.oldestScannedDay()} 的扫描窗口,续接取不到东西)`
|
|
159
|
+
note = `空闲 ${idleTxt(c.idleMs)} —— 已超期,下轮 gc 自动回收`
|
|
151
160
|
} else {
|
|
152
161
|
mark = '○'
|
|
153
|
-
note =
|
|
162
|
+
note = `空闲 ${idleTxt(c.idleMs)} (待消费 ${c.lag},重挂 take 接着盯)`
|
|
154
163
|
}
|
|
155
164
|
out.push(` ${w(c.name, 24)}${mark} ${note}`)
|
|
156
165
|
}
|
|
157
166
|
}
|
|
158
|
-
if (
|
|
167
|
+
if (expiringCount) {
|
|
159
168
|
out.push('')
|
|
160
|
-
out.push(`⚠ ${
|
|
161
|
-
out.push(' 清理:lark-relay cursors --prune')
|
|
169
|
+
out.push(`⚠ ${expiringCount} 个游标已超空闲期 —— collect 每小时自查时回收,不用手动清`)
|
|
162
170
|
}
|
|
163
171
|
|
|
164
172
|
return out.join('\n')
|
package/lib/store.js
CHANGED
|
@@ -7,11 +7,17 @@ const fs = require('fs')
|
|
|
7
7
|
const path = require('path')
|
|
8
8
|
const { paths, ensureDir, eventFilename, dayKey, dayKeyOffset } = require('./paths')
|
|
9
9
|
|
|
10
|
-
// scan
|
|
11
|
-
//
|
|
12
|
-
// ⚠️
|
|
10
|
+
// scan 窗口天数。只扫窗口内是为了避免全目录排序(旧架构每轮全扫 4467 个文件)。
|
|
11
|
+
//
|
|
12
|
+
// ⚠️ 必须**等于** store 保留天数,不是「>= 就行」。两边差 1 会造出一个三不管的日子:
|
|
13
|
+
// 2026-09-06 实测 SCAN_DAYS=4 / retain=3 时,D-3 被 gcStore 整目录删掉了,
|
|
14
|
+
// 却仍被算作「窗口内」-> 停在那天的游标被当成「还能续接」,而那天的数据一条都不在了。
|
|
15
|
+
// 方向反过来(窗口 < 保留)则是「留着却扫不到」。
|
|
16
|
+
// 两个方向都错,所以判据不是不等式而是等号 —— 默认值就按等号配。
|
|
17
|
+
//
|
|
18
|
+
// 与 collect 的 --retain 是两个进程里的两个值,没法在运行时互相读取,
|
|
13
19
|
// 故由 collect 启动时校验(见 bin/lark-relay-collect.js),这里导出给它用
|
|
14
|
-
const SCAN_DAYS = Number(process.env.LARK_RELAY_SCAN_DAYS ||
|
|
20
|
+
const SCAN_DAYS = Number(process.env.LARK_RELAY_SCAN_DAYS || 3)
|
|
15
21
|
|
|
16
22
|
// 原子落盘:同目录 写 .tmp → rename。
|
|
17
23
|
// 这是 partial write 的正解 —— lark-cli --output-dir 先建 0 字节再填充,
|
|
@@ -122,6 +128,35 @@ function writeCursor(name, app, key) {
|
|
|
122
128
|
fs.renameSync(tmp, file)
|
|
123
129
|
}
|
|
124
130
|
|
|
131
|
+
// 把游标标记成「刚被用过」。回收判据是空闲时长(见 lib/cursors.js),
|
|
132
|
+
// 而它读的是 mtime —— 所以「有人在用」必须能刷新 mtime。
|
|
133
|
+
//
|
|
134
|
+
// ⚠️ 为什么不能只靠 writeCursor 顺带刷新:take 的 abort 路径是
|
|
135
|
+
// `if (batch.length && commit) writeCursor` —— **空手被 SIGTERM 时不写**。
|
|
136
|
+
// 实测:游标写入后起一个 take、3 秒后 kill,mtime 一动不动。
|
|
137
|
+
// 于是「一个安静的群盯了两天没消息」会被误判成遗弃并回收。
|
|
138
|
+
// 故 take 启动时显式 touch 一次,让「有人来取过」这件事本身就算活动
|
|
139
|
+
function touchCursor(name, app) {
|
|
140
|
+
const file = paths.cursor(name, app)
|
|
141
|
+
try {
|
|
142
|
+
const now = new Date()
|
|
143
|
+
fs.utimesSync(file, now, now)
|
|
144
|
+
return true
|
|
145
|
+
} catch {
|
|
146
|
+
return false // 还没有游标文件(首次跑),seedCursorNow 会建
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 游标最后一次被使用的时间(ms)。读不到返回 null。
|
|
151
|
+
// mtime 而非 atime:atime 会被 status/cursors 的只读扫描刷新,那不算「在用」
|
|
152
|
+
function cursorMtime(name, app) {
|
|
153
|
+
try {
|
|
154
|
+
return fs.statSync(paths.cursor(name, app)).mtimeMs
|
|
155
|
+
} catch {
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
125
160
|
// --since now:游标落到当前最新,不重放历史。
|
|
126
161
|
// 默认值反过来就消灭了旧架构 adhoc SOP 的「预热游标」那一步。
|
|
127
162
|
function seedCursorNow(name, app) {
|
|
@@ -184,6 +219,8 @@ module.exports = {
|
|
|
184
219
|
readEvent,
|
|
185
220
|
readCursor,
|
|
186
221
|
writeCursor,
|
|
222
|
+
touchCursor,
|
|
223
|
+
cursorMtime,
|
|
187
224
|
seedCursorNow,
|
|
188
225
|
afterCursor,
|
|
189
226
|
entryKey,
|