lark-relay 0.6.1 → 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/README.md +56 -103
- package/bin/lark-relay-collect.js +13 -60
- package/bin/lark-relay.js +53 -125
- package/lib/args.js +29 -77
- package/lib/collect.js +14 -50
- package/lib/filter.js +18 -19
- package/lib/help.js +46 -77
- package/lib/larkcli.js +2 -18
- package/lib/lock.js +3 -11
- package/lib/log.js +2 -27
- package/lib/render.js +4 -8
- package/lib/status.js +5 -23
- package/lib/store.js +4 -90
- package/lib/take.js +16 -29
- package/package.json +2 -2
package/lib/store.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
// 多消费者共享 store、各持游标,不做「处理完即删」—— 谁都不能替别人删。
|
|
5
|
-
// 回收由 collect 进程整目录删(store 按天、cursors 按空闲时长)。
|
|
6
|
-
//
|
|
7
|
-
// 踩坑史见 docs/lessons.md
|
|
3
|
+
// 多消费者共享按天存储的事件,各持消费断点;GC 只回收事件目录
|
|
8
4
|
const fs = require('fs')
|
|
9
5
|
const path = require('path')
|
|
10
6
|
const { paths, ensureDir, eventFilename, dayKey, dayKeyOffset } = require('./paths')
|
|
@@ -24,7 +20,6 @@ function writeEvent(app, obj, seq) {
|
|
|
24
20
|
}
|
|
25
21
|
|
|
26
22
|
// 排序键:stamp -> pid -> seq,数值比较(字典序在位宽变化时会错序)。
|
|
27
|
-
// 必须容忍非法文件名 -- 见 docs/lessons.md#杂项文件
|
|
28
23
|
const NAME_RE = /^(\d+)_(\d+)_(\d+)$/
|
|
29
24
|
|
|
30
25
|
function parseKey(name) {
|
|
@@ -63,8 +58,7 @@ function afterCursor(entry, cursor) {
|
|
|
63
58
|
return cmpName(entry.name, cName) > 0
|
|
64
59
|
}
|
|
65
60
|
|
|
66
|
-
//
|
|
67
|
-
// 稳态下「全部日期」就是「retain 天」。见 docs/lessons.md#扫描窗口
|
|
61
|
+
// 扫描盘上保留的全部日期
|
|
68
62
|
function scanAll(app) {
|
|
69
63
|
const out = []
|
|
70
64
|
let days
|
|
@@ -100,8 +94,7 @@ function readEvent(entry) {
|
|
|
100
94
|
}
|
|
101
95
|
}
|
|
102
96
|
|
|
103
|
-
//
|
|
104
|
-
// collect 重启后 bus 重投时它是新文件,游标拦不住。见 docs/lessons.md#去重
|
|
97
|
+
// 重启后从当天事件恢复 message_id 去重集合
|
|
105
98
|
function seedIds(app, day = dayKey()) {
|
|
106
99
|
const ids = new Set()
|
|
107
100
|
let names
|
|
@@ -113,7 +106,7 @@ function seedIds(app, day = dayKey()) {
|
|
|
113
106
|
for (const name of names) {
|
|
114
107
|
if (!isEventName(name)) continue
|
|
115
108
|
const ev = readEvent({ file: path.join(paths.appDay(app, day), name) })
|
|
116
|
-
const id = ev
|
|
109
|
+
const id = ev?.message_id
|
|
117
110
|
if (id) ids.add(id)
|
|
118
111
|
}
|
|
119
112
|
return ids
|
|
@@ -136,27 +129,6 @@ function writeCursor(name, app, key) {
|
|
|
136
129
|
fs.renameSync(tmp, file)
|
|
137
130
|
}
|
|
138
131
|
|
|
139
|
-
// 标记「刚被用过」。gc 判据是 mtime,而 take 空手退出时不写游标 --
|
|
140
|
-
// 不 touch 会把「盯着一个安静的群」误判成遗弃
|
|
141
|
-
function touchCursor(name, app) {
|
|
142
|
-
try {
|
|
143
|
-
const now = new Date()
|
|
144
|
-
fs.utimesSync(paths.cursor(name, app), now, now)
|
|
145
|
-
return true
|
|
146
|
-
} catch {
|
|
147
|
-
return false // 还没有游标文件(首次跑),seedCursorNow 会建
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// mtime 而非 atime:atime 会被 status 的只读扫描刷新,那不算「在用」
|
|
152
|
-
function cursorMtime(name, app) {
|
|
153
|
-
try {
|
|
154
|
-
return fs.statSync(paths.cursor(name, app)).mtimeMs
|
|
155
|
-
} catch {
|
|
156
|
-
return null
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
132
|
// --since now:游标落到当前最新,不重放历史
|
|
161
133
|
function seedCursorNow(name, app) {
|
|
162
134
|
const all = scanAll(app)
|
|
@@ -210,70 +182,13 @@ function gcStore(retainDays) {
|
|
|
210
182
|
return removed
|
|
211
183
|
}
|
|
212
184
|
|
|
213
|
-
// 游标空闲多久算遗弃。默认 2 天:take 是实时场景的取用口,
|
|
214
|
-
// 空闲一两天以上再重挂,下次消费一定从新的开始。
|
|
215
|
-
//
|
|
216
|
-
// 定义在这里(gcCursors 的所在地)而非 collect.js —— status 要拿它渲染提示,
|
|
217
|
-
// 两处各写一个 2 就会漂移:改了环境变量而 status 仍说「2 天」是在撒谎
|
|
218
|
-
const CURSOR_TTL_DAYS = (() => {
|
|
219
|
-
const d = Number(process.env.LARK_RELAY_CURSOR_TTL_DAYS)
|
|
220
|
-
return Number.isFinite(d) && d > 0 ? d : 2
|
|
221
|
-
})()
|
|
222
|
-
|
|
223
|
-
// gc:删空闲超期的游标。与 gcStore 同构(都是「删超期的东西」),故放一起。
|
|
224
|
-
//
|
|
225
|
-
// 判据是**多久没人用过它**(mtime)。「落后多少条」和「停在哪天」都试过、
|
|
226
|
-
// 方向都是反的 -- 见 docs/lessons.md#游标判据。
|
|
227
|
-
//
|
|
228
|
-
// 不必守「在跑的别删」:take 启动即 touch,默认 --timeout(12h)远小于 TTL(2 天),
|
|
229
|
-
// 在跑的游标不可能超期。test 锁住了这个不变量
|
|
230
|
-
function gcCursors(ttlDays) {
|
|
231
|
-
const cutoff = Date.now() - ttlDays * 86400_000
|
|
232
|
-
const removed = []
|
|
233
|
-
let names
|
|
234
|
-
try {
|
|
235
|
-
names = fs.readdirSync(paths.cursors)
|
|
236
|
-
} catch {
|
|
237
|
-
return removed
|
|
238
|
-
}
|
|
239
|
-
for (const name of names) {
|
|
240
|
-
if (name.startsWith('.')) continue
|
|
241
|
-
const dir = path.join(paths.cursors, name)
|
|
242
|
-
let files
|
|
243
|
-
try {
|
|
244
|
-
if (!fs.statSync(dir).isDirectory()) continue
|
|
245
|
-
files = fs.readdirSync(dir)
|
|
246
|
-
} catch {
|
|
247
|
-
continue
|
|
248
|
-
}
|
|
249
|
-
// 粒度是 app 而非整个身份 -- 见 docs/lessons.md#回收粒度
|
|
250
|
-
for (const app of files) {
|
|
251
|
-
if (app.startsWith('.') || app.endsWith('.tmp')) continue
|
|
252
|
-
const file = path.join(dir, app)
|
|
253
|
-
try {
|
|
254
|
-
if (fs.statSync(file).mtimeMs >= cutoff) continue
|
|
255
|
-
fs.rmSync(file, { force: true })
|
|
256
|
-
fs.rmSync(`${file}.tmp`, { force: true })
|
|
257
|
-
removed.push(`${name}/${app}`)
|
|
258
|
-
} catch {}
|
|
259
|
-
}
|
|
260
|
-
try {
|
|
261
|
-
if (!fs.readdirSync(dir).length) fs.rmdirSync(dir)
|
|
262
|
-
} catch {}
|
|
263
|
-
}
|
|
264
|
-
return removed
|
|
265
|
-
}
|
|
266
|
-
|
|
267
185
|
module.exports = {
|
|
268
|
-
CURSOR_TTL_DAYS,
|
|
269
186
|
writeEvent,
|
|
270
187
|
scanAll,
|
|
271
188
|
readEvent,
|
|
272
189
|
seedIds,
|
|
273
190
|
readCursor,
|
|
274
191
|
writeCursor,
|
|
275
|
-
touchCursor,
|
|
276
|
-
cursorMtime,
|
|
277
192
|
seedCursorNow,
|
|
278
193
|
afterCursor,
|
|
279
194
|
entryKey,
|
|
@@ -283,5 +198,4 @@ module.exports = {
|
|
|
283
198
|
latestEntry,
|
|
284
199
|
listApps,
|
|
285
200
|
gcStore,
|
|
286
|
-
gcCursors,
|
|
287
201
|
}
|
package/lib/take.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
// 循环由 harness 承担(Claude Code run_in_background 进程退出会主动通知 AI),
|
|
5
|
-
// 包不做 --follow、不做 nohup 循环、不落中间日志文件。
|
|
3
|
+
// 阻塞取一批,调用方负责处理后再次取用
|
|
6
4
|
const fs = require('fs')
|
|
7
5
|
const crypto = require('crypto')
|
|
8
6
|
const store = require('./store')
|
|
@@ -31,6 +29,7 @@ function parseChats(spec) {
|
|
|
31
29
|
} catch (err) {
|
|
32
30
|
const e = new Error(`--chats 指向的文件读不到:${file}`)
|
|
33
31
|
e.userFacing = true
|
|
32
|
+
e.exitCode = 2
|
|
34
33
|
throw e
|
|
35
34
|
}
|
|
36
35
|
raw = text
|
|
@@ -60,44 +59,35 @@ async function takeBatch(opts) {
|
|
|
60
59
|
maxWaitMs,
|
|
61
60
|
timeoutMs,
|
|
62
61
|
pollMs = 500,
|
|
63
|
-
eventKey = 'im.message.receive_v1',
|
|
64
62
|
signal,
|
|
65
63
|
} = opts
|
|
66
64
|
|
|
67
65
|
const chatSet = new Set(chats)
|
|
68
66
|
const startedAt = Date.now()
|
|
69
67
|
const batch = []
|
|
70
|
-
const seen = new Set()
|
|
71
68
|
let firstAt = null
|
|
72
69
|
let lastNewAt = null
|
|
73
70
|
let cursor = store.readCursor(name, app)
|
|
74
|
-
let commit = cursor
|
|
75
71
|
|
|
76
72
|
for (;;) {
|
|
77
|
-
//
|
|
78
|
-
// (AI 已看到、可能已回群),游标却没推,重启后同一批会**重跑一遍**(实测:重复回群)
|
|
73
|
+
// 信号退出也交付已攒的批次,提交时机与正常退出一致
|
|
79
74
|
if (signal && signal.aborted) {
|
|
80
|
-
if (batch.length &&
|
|
81
|
-
return { events: batch, reason: batch.length ? 'batch' : 'signal', cursor
|
|
75
|
+
if (batch.length && cursor) store.writeCursor(name, app, cursor)
|
|
76
|
+
return { events: batch, reason: batch.length ? 'batch' : 'signal', cursor }
|
|
82
77
|
}
|
|
83
78
|
|
|
84
79
|
const entries = store.scanAll(app).filter((e) => store.afterCursor(e, cursor))
|
|
85
|
-
let scannedLast = null
|
|
86
80
|
const fresh = []
|
|
87
81
|
for (const entry of entries) {
|
|
88
|
-
|
|
89
|
-
scannedLast = key
|
|
90
|
-
if (seen.has(key)) continue
|
|
91
|
-
seen.add(key)
|
|
82
|
+
cursor = store.entryKey(entry)
|
|
92
83
|
const ev = store.readEvent(entry)
|
|
93
84
|
if (!ev) continue // 半成品/坏文件:跳过
|
|
94
|
-
if (
|
|
85
|
+
if (ev.type !== 'im.message.receive_v1') continue
|
|
95
86
|
if (chatSet.size && !chatSet.has(ev.chat_id)) continue
|
|
96
87
|
fresh.push(ev)
|
|
97
88
|
}
|
|
98
89
|
|
|
99
|
-
|
|
100
|
-
if (fresh.length) passed = applyFilter(fresh, filter)
|
|
90
|
+
const passed = applyFilter(fresh, filter)
|
|
101
91
|
|
|
102
92
|
if (passed.length) {
|
|
103
93
|
batch.push(...passed)
|
|
@@ -106,12 +96,9 @@ async function takeBatch(opts) {
|
|
|
106
96
|
lastNewAt = now
|
|
107
97
|
}
|
|
108
98
|
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (batch.length === 0 && commit && commit !== cursor) {
|
|
113
|
-
store.writeCursor(name, app, commit)
|
|
114
|
-
cursor = commit
|
|
99
|
+
// 内存游标每轮前移;有待交付批次时,磁盘断点保留到交付前
|
|
100
|
+
if (batch.length === 0 && entries.length) {
|
|
101
|
+
store.writeCursor(name, app, cursor)
|
|
115
102
|
}
|
|
116
103
|
|
|
117
104
|
if (batch.length) {
|
|
@@ -119,15 +106,15 @@ async function takeBatch(opts) {
|
|
|
119
106
|
const quiet = now - lastNewAt >= debounceMs
|
|
120
107
|
const capped = now - firstAt >= maxWaitMs
|
|
121
108
|
if (quiet || capped) {
|
|
122
|
-
//
|
|
123
|
-
if (
|
|
124
|
-
return { events: batch, reason: 'batch', cursor
|
|
109
|
+
// at-most-once:交付前提交,不等待业务处理确认
|
|
110
|
+
if (cursor) store.writeCursor(name, app, cursor)
|
|
111
|
+
return { events: batch, reason: 'batch', cursor }
|
|
125
112
|
}
|
|
126
113
|
}
|
|
127
114
|
|
|
128
115
|
if (Date.now() - startedAt >= timeoutMs) {
|
|
129
|
-
if (
|
|
130
|
-
return { events: batch, reason: batch.length ? 'batch' : 'timeout', cursor
|
|
116
|
+
if (batch.length && cursor) store.writeCursor(name, app, cursor)
|
|
117
|
+
return { events: batch, reason: batch.length ? 'batch' : 'timeout', cursor }
|
|
131
118
|
}
|
|
132
119
|
|
|
133
120
|
await sleep(pollMs)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lark-relay",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Lark event relay: collect events to disk, take a batch when you need it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"lark",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"author": "adaex",
|
|
15
15
|
"type": "commonjs",
|
|
16
16
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
17
|
+
"node": ">=22"
|
|
18
18
|
},
|
|
19
19
|
"bin": {
|
|
20
20
|
"lark-relay": "bin/lark-relay.js",
|