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/README.md +56 -102
- package/bin/lark-relay-collect.js +13 -60
- package/bin/lark-relay.js +54 -120
- package/lib/args.js +29 -77
- package/lib/collect.js +16 -62
- package/lib/filter.js +18 -19
- package/lib/help.js +46 -77
- package/lib/larkcli.js +2 -18
- package/lib/lock.js +5 -15
- package/lib/log.js +2 -27
- package/lib/render.js +4 -8
- package/lib/status.js +7 -23
- package/lib/store.js +5 -82
- package/lib/take.js +16 -29
- package/package.json +2 -2
package/lib/collect.js
CHANGED
|
@@ -1,42 +1,25 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
//
|
|
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 {
|
|
9
|
+
const { logLine } = require('./log')
|
|
16
10
|
|
|
17
|
-
const EVENT_KEY =
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
89
|
-
// 一路顶到 60s 上限再也不降。见 docs/lessons.md
|
|
67
|
+
// 安静的连接也应重置退避
|
|
90
68
|
this.healthyTimer = setTimeout(() => {
|
|
91
69
|
this.backoffMs = 1000
|
|
92
70
|
}, 30_000)
|
|
93
71
|
|
|
94
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
172
|
+
const collectors = chosen.map((p) => new AppCollector(p.name))
|
|
207
173
|
for (const c of collectors) c.start()
|
|
208
174
|
|
|
209
|
-
//
|
|
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
|
-
//
|
|
4
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
9
|
+
2. 验 bot 在群(用户身份)
|
|
21
10
|
lark-cli --profile <app> im chat.members bots --as user --params '{"chat_id":"oc_xxx"}'
|
|
22
|
-
-> bots[]
|
|
23
|
-
-> 不在群:请用户拉 bot 进群(对群可见动作,先征得同意)
|
|
11
|
+
-> bots[] 应含本 app,不在群则先安排入群
|
|
24
12
|
|
|
25
|
-
|
|
13
|
+
3. 取一批
|
|
26
14
|
lark-relay take --app <app> --chats oc_xxx --render text
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
阻塞等消息,有一批就吐到 stdout 并退出(默认防抖 5s、最长等 12h)。
|
|
30
|
-
处理完再起一个,如此循环。
|
|
31
|
-
|
|
32
|
-
可用 app:${appLine}
|
|
15
|
+
-> stderr 出现「监听中」表示参数校验完成并进入等待
|
|
16
|
+
-> 有一批输出到 stdout 后退出,处理完再次运行
|
|
33
17
|
|
|
34
18
|
参数
|
|
35
|
-
--app <name>
|
|
36
|
-
--chats <ids>
|
|
37
|
-
--name <n>
|
|
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
|
|
40
|
-
--max-wait N
|
|
41
|
-
--timeout N
|
|
42
|
-
--render text
|
|
43
|
-
--since 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
|
-
|
|
63
|
-
|
|
64
|
-
未授权或授权过期的 profile 自动跳过并 warn(永久失败,重试是死循环)。
|
|
30
|
+
断点持续保留,不随空闲时间过期;store 只保留采集端设定天数的消息
|
|
31
|
+
使用新的 --name 可从新的起点消费
|
|
65
32
|
|
|
66
|
-
|
|
67
|
-
不是洁癖:同一 app 服务端只放行一个 event bus,两个 collect 会互相挤掉并按
|
|
68
|
-
1s->2s->...->60s 退避重连,实测约 5 分钟才稳定 -- 那段时间的消息永久丢失。
|
|
69
|
-
所以上面那条「前台跑(调试)」在常驻服务已在跑时会直接退出,不会抢它。
|
|
33
|
+
退出码:0=有一批/帮助/信号退出,2=参数错,3=身份被占用,4=超时无消息`
|
|
70
34
|
|
|
71
|
-
|
|
72
|
-
8s 后才起 consumer,收到 0 条)。
|
|
35
|
+
const COLLECT_HELP = `lark-relay-collect -- 常驻收集 im.message.receive_v1 消息并原子落盘
|
|
73
36
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
(launchd 把 stderr 直写该文件,不进统一日志,log show 查不到)
|
|
37
|
+
lark-relay-collect
|
|
38
|
+
lark-relay status
|
|
77
39
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
40
|
+
启动时读取 lark-cli profile list,每个可用 profile 启动一个 consumer
|
|
41
|
+
新增 profile 或重新授权后需重启采集
|
|
42
|
+
每 app 独立退避重连,断线期间的消息无法回放
|
|
43
|
+
同一 app 只允许一个事件总线实例,接管前先停止现有采集
|
|
81
44
|
|
|
82
45
|
参数
|
|
83
|
-
--exclude <apps> 排除指定 profile
|
|
84
|
-
--retain N
|
|
46
|
+
--exclude <apps> 排除指定 profile,逗号分隔
|
|
47
|
+
--retain N 事件保留的整数天数,默认 3;每小时回收,启动时也执行
|
|
48
|
+
-h, --help 用法
|
|
49
|
+
-v, --version 版本
|
|
85
50
|
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
58
|
+
落盘:~/.lark-relay/store/<app>/<YYYY-MM-DD>/<stamp>_<pid>_<seq>.json
|
|
59
|
+
日志写 stderr,每行带时间戳;launchd 部署应配置 StandardErrorPath`
|
|
90
60
|
|
|
91
|
-
|
|
92
|
-
lark-relay status --json # 机器可读
|
|
61
|
+
const STATUS_HELP = `lark-relay status -- 采集状态、事件数量和消费断点
|
|
93
62
|
|
|
94
|
-
|
|
63
|
+
lark-relay status
|
|
64
|
+
lark-relay status --json
|
|
95
65
|
|
|
96
|
-
|
|
97
|
-
collect
|
|
98
|
-
(launchd 不进统一日志),collect 停摆的每一秒都在丢消息。`
|
|
66
|
+
消费者以持锁进程是否存活区分在跑/未运行,未运行的断点仍保留
|
|
67
|
+
collect running 只表示主进程在跑,各 app 的连接状态需查看采集日志`
|
|
99
68
|
|
|
100
|
-
module.exports = {
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
4
|
-
//
|
|
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
|
|
33
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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 ||
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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})` :
|
|
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(
|
|
153
|
+
out.push(
|
|
154
|
+
`游标 ${s.cursorsDir}(保留断点,下次继续取用)`,
|
|
155
|
+
)
|
|
172
156
|
|
|
173
157
|
return out.join('\n')
|
|
174
158
|
}
|