lark-watch 0.0.0 → 1.0.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 +59 -1
- package/bin/lark-watch.js +105 -0
- package/lib/args.js +37 -0
- package/lib/chats.js +53 -0
- package/lib/help.js +37 -0
- package/lib/render.js +64 -0
- package/lib/watch.js +193 -0
- package/package.json +27 -4
package/README.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
1
|
# lark-watch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lark(飞书)消息浅封装:直接消费 `lark-cli event consume`,按防抖窗口攒一批消息输出后退出
|
|
4
|
+
|
|
5
|
+
依赖 Node.js >=22 和 [@larksuite/cli](https://www.npmjs.com/package/@larksuite/cli)(命令名 `lark-cli`,
|
|
6
|
+
需支持 `event consume` 的 `--jq`/`--timeout` bounded 模式,本机实测基准 1.0.96),零 npm 运行依赖
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm i -g lark-watch
|
|
10
|
+
lark-watch --help
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 模型
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
lark-watch -> lark-cli event consume(bounded) -> lark-cli event bus daemon -> Lark
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
事件连接、认证、重连由 lark-cli 的 per-app event bus daemon 维护;`lark-watch` 只做三件事:
|
|
20
|
+
|
|
21
|
+
1. 按参数起一个一次性 consumer
|
|
22
|
+
2. 群白名单 + jq 过滤交给 CLI 的 `--jq`,按防抖窗口把 NDJSON 攒成一批
|
|
23
|
+
3. 输出到 stdout 后退出,由调用方处理完再启动下一次
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
lark-watch --app <profile> --chats oc_xxx --render text
|
|
27
|
+
lark-watch --app <profile> --chats @chats.txt --filter '.sender_id != "ou_xxx"'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 语义
|
|
31
|
+
|
|
32
|
+
- **无状态**:不存消息、不记进度。退出即结束,再启动只收到启动之后的新消息
|
|
33
|
+
- Lark 事件不回放离线消息(发出约 8 秒后再挂的 consumer 收不到),需要补漏就回读群历史
|
|
34
|
+
- 就绪以 stderr 的「监听中」为准;CLI 的连接日志与 drop 诊断原样透传,不做静默
|
|
35
|
+
- 默认防抖 5 秒(最后一条消息后的静默窗口),从第一条起最多攒 25 秒,单次最长等 12 小时
|
|
36
|
+
- 停止用 SIGTERM:CLI 会优雅卸载服务端订阅,不要 SIGKILL
|
|
37
|
+
|
|
38
|
+
## 参数
|
|
39
|
+
|
|
40
|
+
| 参数 | 说明 |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `--app <name>` | 必填,lark-cli profile 名 |
|
|
43
|
+
| `--chats <ids>` | 必填,逗号分隔 chat_id,或 `@文件`(取首列,忽略空行与 `#` 注释) |
|
|
44
|
+
| `--filter <jq>` | 业务过滤表达式,与群白名单取交集 |
|
|
45
|
+
| `--debounce N` | 最后一条消息后静默 N 秒吐批,默认 5 |
|
|
46
|
+
| `--max-wait N` | 从第一条起最多攒 N 秒,默认 25 |
|
|
47
|
+
| `--timeout N` | 单次最长阻塞 N 秒,默认 43200;`0` 为不限时 |
|
|
48
|
+
| `--render text\|ndjson` | 默认 ndjson;text 按群/话题分组,带发送者与消息元数据 |
|
|
49
|
+
|
|
50
|
+
退出码:0=有一批/帮助/信号退出,1=运行错误,2=参数错或过滤表达式无效,4=超时无消息
|
|
51
|
+
|
|
52
|
+
监听要求 bot 已在群内。查群与验成员:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
lark-cli --profile <profile> im +chat-search --as user --query "<群名>"
|
|
56
|
+
lark-cli --profile <profile> im chat.members bots --as user --params '{"chat_id":"oc_xxx"}'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 许可
|
|
60
|
+
|
|
61
|
+
MIT
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
const { parseCliArgs, numberArg } = require('../lib/args')
|
|
5
|
+
const { parseChats, checkChats, buildJq } = require('../lib/chats')
|
|
6
|
+
const { render } = require('../lib/render')
|
|
7
|
+
const { watch } = require('../lib/watch')
|
|
8
|
+
const pkg = require('../package.json')
|
|
9
|
+
const { HELP } = require('../lib/help')
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
const argv = process.argv.slice(2)
|
|
13
|
+
if (argv.length === 0 || argv.includes('-h') || argv.includes('--help')) {
|
|
14
|
+
process.stdout.write(`${HELP}\n`)
|
|
15
|
+
return 0
|
|
16
|
+
}
|
|
17
|
+
if (argv.includes('-v') || argv.includes('--version')) {
|
|
18
|
+
process.stdout.write(`${pkg.version}\n`)
|
|
19
|
+
return 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const a = parseCliArgs(argv)
|
|
23
|
+
|
|
24
|
+
if (!a.app) {
|
|
25
|
+
process.stderr.write('需要 --app <name>,跑 `lark-watch --help` 看用法\n')
|
|
26
|
+
return 2
|
|
27
|
+
}
|
|
28
|
+
let chats
|
|
29
|
+
try {
|
|
30
|
+
chats = parseChats(a.chats)
|
|
31
|
+
checkChats(chats)
|
|
32
|
+
} catch (err) {
|
|
33
|
+
process.stderr.write(`${err.message}\n`)
|
|
34
|
+
return err.exitCode || 2
|
|
35
|
+
}
|
|
36
|
+
if (!chats.length) {
|
|
37
|
+
process.stderr.write('需要 --chats oc_xxx,多个 chat_id 用逗号分隔\n')
|
|
38
|
+
return 2
|
|
39
|
+
}
|
|
40
|
+
const mode = a.render ?? 'ndjson'
|
|
41
|
+
if (!['text', 'ndjson'].includes(mode)) {
|
|
42
|
+
process.stderr.write(`--render 只能是 text 或 ndjson,当前:${mode}\n`)
|
|
43
|
+
return 2
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let debounceSec, maxWaitSec, timeoutSec
|
|
47
|
+
try {
|
|
48
|
+
debounceSec = numberArg(a['debounce'], 5, 'debounce')
|
|
49
|
+
maxWaitSec = numberArg(a['max-wait'], 25, 'max-wait')
|
|
50
|
+
timeoutSec = numberArg(a['timeout'], 43200, 'timeout')
|
|
51
|
+
} catch (err) {
|
|
52
|
+
process.stderr.write(`${err.message}\n`)
|
|
53
|
+
return 2
|
|
54
|
+
}
|
|
55
|
+
if (timeoutSec !== 0 && timeoutSec < maxWaitSec) {
|
|
56
|
+
process.stderr.write('--timeout 不能小于 --max-wait,否则首批可能被整体超时截断\n')
|
|
57
|
+
return 2
|
|
58
|
+
}
|
|
59
|
+
const jq = buildJq(chats, a.filter)
|
|
60
|
+
|
|
61
|
+
// 参数全部有效后再 spawn
|
|
62
|
+
const ac = new AbortController()
|
|
63
|
+
process.on('SIGTERM', () => ac.abort())
|
|
64
|
+
process.on('SIGINT', () => ac.abort())
|
|
65
|
+
|
|
66
|
+
const res = await watch({
|
|
67
|
+
app: a.app,
|
|
68
|
+
jq,
|
|
69
|
+
debounceMs: debounceSec * 1000,
|
|
70
|
+
maxWaitMs: maxWaitSec * 1000,
|
|
71
|
+
timeoutSec,
|
|
72
|
+
externalSignal: ac.signal,
|
|
73
|
+
onReady: () => {
|
|
74
|
+
const total = timeoutSec === 0 ? '不限时' : `${timeoutSec}s`
|
|
75
|
+
process.stderr.write(
|
|
76
|
+
`监听中:app=${a.app} 群=${chats.length} 个(${chats.join(',')}) ` +
|
|
77
|
+
`防抖=${debounceSec}s 首批封顶=${maxWaitSec}s 总超时=${total}\n`,
|
|
78
|
+
)
|
|
79
|
+
},
|
|
80
|
+
onWarn: (msg) => process.stderr.write(`warn: ${msg}\n`),
|
|
81
|
+
// CLI 例行行(consuming/listening/exited)与 drop 诊断原样保留:
|
|
82
|
+
// 不用 --quiet,它会连丢消息提示一起吞掉
|
|
83
|
+
onCliStderr: (line) => process.stderr.write(`${line}\n`),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (!res.events.length) {
|
|
87
|
+
if (res.reason === 'error') {
|
|
88
|
+
process.stderr.write('监听失败,见上方 lark-cli 输出\n')
|
|
89
|
+
return res.exitCode || 1
|
|
90
|
+
}
|
|
91
|
+
process.stderr.write(`没等到消息(${res.reason}),再起一个继续\n`)
|
|
92
|
+
return res.reason === 'timeout' ? 4 : 0
|
|
93
|
+
}
|
|
94
|
+
process.stdout.write(`${render(res.events, mode)}\n`)
|
|
95
|
+
process.stderr.write(`${res.events.length} 条\n`)
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 自然排空 stdout,避免大批次经管道输出时被截断
|
|
100
|
+
main()
|
|
101
|
+
.then((code) => { process.exitCode = code })
|
|
102
|
+
.catch((err) => {
|
|
103
|
+
process.stderr.write(`${err.userFacing ? err.message : err.stack}\n`)
|
|
104
|
+
process.exitCode = err.exitCode || 1
|
|
105
|
+
})
|
package/lib/args.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { parseArgs: parse } = require('node:util')
|
|
4
|
+
|
|
5
|
+
const KEYS = ['app', 'chats', 'filter', 'debounce', 'max-wait', 'timeout', 'render']
|
|
6
|
+
|
|
7
|
+
function parseCliArgs(args) {
|
|
8
|
+
const options = { help: { type: 'boolean', short: 'h' } }
|
|
9
|
+
for (const key of KEYS) options[key] = { type: 'string' }
|
|
10
|
+
try {
|
|
11
|
+
const { values } = parse({ args, options, strict: true, allowPositionals: false })
|
|
12
|
+
for (const key of KEYS) {
|
|
13
|
+
if (values[key] !== undefined && !values[key].trim()) {
|
|
14
|
+
throw new Error(`--${key} 不能为空`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return values
|
|
18
|
+
} catch (err) {
|
|
19
|
+
err.message += `\n可用参数:${['help', ...KEYS].map((k) => `--${k}`).join(' ')}`
|
|
20
|
+
err.userFacing = true
|
|
21
|
+
err.exitCode = 2
|
|
22
|
+
throw err
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function numberArg(value, fallback, label) {
|
|
27
|
+
const n = value === undefined ? fallback : Number(value)
|
|
28
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
29
|
+
throw Object.assign(new Error(`--${label} 需要有限的非负数,当前:${value}`), {
|
|
30
|
+
userFacing: true,
|
|
31
|
+
exitCode: 2,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
return n
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { parseCliArgs, numberArg, KEYS }
|
package/lib/chats.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// 只放行形如 oc_xxx 的普通 id:白名单同时是 jq 字符串注入的防线,
|
|
4
|
+
// 合成 --jq 时 id 会被包进双引号字面量
|
|
5
|
+
const CHAT_ID = /^oc_[A-Za-z0-9_-]+$/
|
|
6
|
+
|
|
7
|
+
// --chats 支持 @文件(取首列,忽略空行与 # 注释)
|
|
8
|
+
function parseChats(spec) {
|
|
9
|
+
if (!spec) return []
|
|
10
|
+
let raw = spec
|
|
11
|
+
if (spec.startsWith('@')) {
|
|
12
|
+
const fs = require('fs')
|
|
13
|
+
const file = spec.slice(1)
|
|
14
|
+
let text
|
|
15
|
+
try {
|
|
16
|
+
text = fs.readFileSync(file, 'utf8')
|
|
17
|
+
} catch {
|
|
18
|
+
const e = new Error(`--chats 指向的文件读不到:${file}`)
|
|
19
|
+
e.userFacing = true
|
|
20
|
+
e.exitCode = 2
|
|
21
|
+
throw e
|
|
22
|
+
}
|
|
23
|
+
raw = text
|
|
24
|
+
.split('\n')
|
|
25
|
+
.map((l) => l.trim())
|
|
26
|
+
.filter((l) => l && !l.startsWith('#'))
|
|
27
|
+
.map((l) => l.split(/[\s,\t]+/)[0])
|
|
28
|
+
.join(',')
|
|
29
|
+
}
|
|
30
|
+
return [...new Set(raw.split(',').map((s) => s.trim()).filter(Boolean))]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function checkChats(chats) {
|
|
34
|
+
const bad = chats.find((c) => !CHAT_ID.test(c))
|
|
35
|
+
if (bad) {
|
|
36
|
+
const e = new Error(`非法 chat_id:${bad}(需要 oc_ 前缀的字母数字串)`)
|
|
37
|
+
e.userFacing = true
|
|
38
|
+
e.exitCode = 2
|
|
39
|
+
throw e
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 群白名单与业务过滤合成一个 select,全部交给 CLI 的 --jq,本地不 spawn jq。
|
|
44
|
+
// chats 已过白名单,可安全拼进字符串字面量;filter 是用户自己的表达式,原样包裹
|
|
45
|
+
function buildJq(chats, filter) {
|
|
46
|
+
const chatExpr = chats.map((c) => `.chat_id==${JSON.stringify(c)}`).join(' or ')
|
|
47
|
+
if (chats.length && filter) return `select((${chatExpr}) and (${filter}))`
|
|
48
|
+
if (chats.length) return `select(${chatExpr})`
|
|
49
|
+
if (filter) return `select(${filter})`
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { parseChats, checkChats, buildJq, CHAT_ID }
|
package/lib/help.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const HELP = `lark-watch -- 直接消费 lark-cli 事件流,攒一批消息输出后退出
|
|
4
|
+
|
|
5
|
+
需要 --app 和 --chats,监听要求 bot 已在群内
|
|
6
|
+
|
|
7
|
+
1. 查群 ID(用户身份)
|
|
8
|
+
lark-cli --profile <app> im +chat-search --as user --query "<群名>"
|
|
9
|
+
-> 取 oc_ 开头的 chat_id,多个匹配时确认目标群
|
|
10
|
+
|
|
11
|
+
2. 验 bot 在群(用户身份)
|
|
12
|
+
lark-cli --profile <app> im chat.members bots --as user --params '{"chat_id":"oc_xxx"}'
|
|
13
|
+
-> bots[] 应含本 app,不在群则先安排入群
|
|
14
|
+
|
|
15
|
+
3. 监听一批
|
|
16
|
+
lark-watch --app <app> --chats oc_xxx --render text
|
|
17
|
+
-> stderr 先出现 lark-cli 的连接日志,「监听中」表示已就绪进入等待
|
|
18
|
+
-> 攒出一批输出到 stdout 后退出,处理完再次运行
|
|
19
|
+
|
|
20
|
+
参数
|
|
21
|
+
--app <name> 必填,lark-cli profile 名(用 lark-cli profile list 查询)
|
|
22
|
+
--chats <ids> 必填,逗号分隔的 chat_id,或 @文件(首列 chat_id)
|
|
23
|
+
--filter <jq> 业务过滤,与群白名单取交集,如 '.sender_id != "ou_xxx"'
|
|
24
|
+
--debounce N 最后一条消息后静默多久吐批,默认 5 秒
|
|
25
|
+
--max-wait N 从第一条起最多攒多久,默认 25 秒
|
|
26
|
+
--timeout N 本次最多阻塞多少秒,默认 43200(12 小时);0 = 不限时
|
|
27
|
+
--render <mode> text 或 ndjson,默认 ndjson
|
|
28
|
+
-h, --help 用法
|
|
29
|
+
|
|
30
|
+
语义
|
|
31
|
+
无状态:不存消息、不记进度,退出即结束;重跑只收到启动之后的新消息
|
|
32
|
+
Lark 事件不回放离线消息(发出约 8 秒后再挂就收不到),需要补漏就回读群历史
|
|
33
|
+
事件连接由 lark-cli 的 event bus daemon 维护,不要用 lark-cli event stop 拆共享总线
|
|
34
|
+
|
|
35
|
+
退出码:0=有一批/帮助/信号退出,1=运行错误,2=参数错或过滤表达式无效,4=超时无消息`
|
|
36
|
+
|
|
37
|
+
module.exports = { HELP }
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
function fmtTime(ts) {
|
|
4
|
+
const ms = Number(ts)
|
|
5
|
+
if (!Number.isFinite(ms) || ms <= 0) return '?'
|
|
6
|
+
const d = new Date(ms)
|
|
7
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
8
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// 事件体里没有发送者姓名:mentions 只装被 @ 的人,实测 861 条真实事件
|
|
12
|
+
// 无一条把 sender_id 收进 mentions。要姓名需另查通讯录,不在渲染层做
|
|
13
|
+
function senderLabel(e) {
|
|
14
|
+
return e.sender_id || 'unknown'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderNdjson(events) {
|
|
18
|
+
return events.map((e) => JSON.stringify(e)).join('\n')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 群与话题共同确定分组,使用转义 NUL 分隔
|
|
22
|
+
function renderText(events) {
|
|
23
|
+
const groups = new Map()
|
|
24
|
+
for (const e of events) {
|
|
25
|
+
const key = `${e.chat_id || '?'}\x00${e.thread_id || ''}`
|
|
26
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
27
|
+
groups.get(key).push(e)
|
|
28
|
+
}
|
|
29
|
+
const out = []
|
|
30
|
+
for (const [key, list] of groups) {
|
|
31
|
+
const [chatId, threadId] = key.split('\x00')
|
|
32
|
+
let head = `## chat ${chatId}`
|
|
33
|
+
if (threadId) head += ` thread ${threadId}`
|
|
34
|
+
head += ` (${list.length} 条)`
|
|
35
|
+
out.push(head)
|
|
36
|
+
for (const e of list) {
|
|
37
|
+
const line = [`[${fmtTime(e.create_time)}]`, senderLabel(e)]
|
|
38
|
+
if (e.message_type && e.message_type !== 'text') line.push(`<${e.message_type}>`)
|
|
39
|
+
out.push(`${line.join(' ')}: ${e.content ?? ''}`)
|
|
40
|
+
const meta = []
|
|
41
|
+
meta.push(`message_id=${e.message_id || '?'}`)
|
|
42
|
+
meta.push(`sender_id=${e.sender_id || '?'}`)
|
|
43
|
+
// sender_type 是业务判据(如「自家 bot 的消息跳过」),必须渲染出来
|
|
44
|
+
meta.push(`sender_type=${e.sender_type || '?'}`)
|
|
45
|
+
const mentions = (e.mentions || []).filter(Boolean)
|
|
46
|
+
if (mentions.length) {
|
|
47
|
+
meta.push(`mentions=${mentions.map((m) => `${m.name || '?'}(${m.id || '?'})`).join(',')}`)
|
|
48
|
+
}
|
|
49
|
+
if (e.reply_to) meta.push(`reply_to=${e.reply_to}`)
|
|
50
|
+
if (e.root_id) meta.push(`root_id=${e.root_id}`)
|
|
51
|
+
if (e.thread_id) meta.push(`thread_id=${e.thread_id}`)
|
|
52
|
+
out.push(` ${meta.join(' ')}`)
|
|
53
|
+
}
|
|
54
|
+
out.push('')
|
|
55
|
+
}
|
|
56
|
+
return out.join('\n').trimEnd()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function render(events, mode) {
|
|
60
|
+
if (mode === 'text') return renderText(events)
|
|
61
|
+
return renderNdjson(events)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { render }
|
package/lib/watch.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// 浅封装核心:spawn 一个 bounded `lark-cli event consume`,逐行收 NDJSON,
|
|
4
|
+
// 按防抖窗口攒成一批;CLI 退出、超时或收到信号时交付并结束。
|
|
5
|
+
// 不做落盘、断点、重连 -- 连接与保活是 lark-cli event bus daemon 的职责。
|
|
6
|
+
const { spawn } = require('child_process')
|
|
7
|
+
const readline = require('readline')
|
|
8
|
+
|
|
9
|
+
const EVENT_KEY = 'im.message.receive_v1'
|
|
10
|
+
|
|
11
|
+
// LARK_WATCH_CLI 测试接缝:字符串 = 可执行路径(生产用 lark-cli);
|
|
12
|
+
// JSON 数组 = [可执行, ...前置参数],测试借此用真 node 直接跑假 CLI 脚本,
|
|
13
|
+
// 绕开 macOS 对新建 shebang 文件首次 execve 的策略评估延迟(实测可达数百毫秒)
|
|
14
|
+
function cliCommand() {
|
|
15
|
+
const raw = process.env.LARK_WATCH_CLI
|
|
16
|
+
if (!raw) return { file: 'lark-cli', preArgs: [] }
|
|
17
|
+
if (raw.trimStart().startsWith('[')) {
|
|
18
|
+
const arr = JSON.parse(raw)
|
|
19
|
+
return { file: arr[0], preArgs: arr.slice(1) }
|
|
20
|
+
}
|
|
21
|
+
return { file: raw, preArgs: [] }
|
|
22
|
+
}
|
|
23
|
+
// CLI 自己打的就绪行;bus daemon 首次建连的 connected 行复用时不会再打,不能拿它判就绪
|
|
24
|
+
const READY_RE = /\[event\] ready event_key=/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} opts
|
|
28
|
+
* app: lark-cli profile 名
|
|
29
|
+
* jq: 合成好的 select 表达式,null 表示不过滤
|
|
30
|
+
* debounceMs / maxWaitMs: 攒批窗口
|
|
31
|
+
* timeoutSec: 透传给 CLI 的 bounded 超时;0 = unbounded
|
|
32
|
+
* externalSignal: { aborted } -- 外部(SIGTERM/SIGINT)要求收尾
|
|
33
|
+
* onReady(line) / onWarn(msg) / onCliStderr(line)
|
|
34
|
+
* @returns {Promise<{events:array, reason:string, exitCode?:number}>}
|
|
35
|
+
* reason: batch | timeout | signal | error
|
|
36
|
+
*/
|
|
37
|
+
function watch(opts) {
|
|
38
|
+
const {
|
|
39
|
+
app,
|
|
40
|
+
jq,
|
|
41
|
+
debounceMs,
|
|
42
|
+
maxWaitMs,
|
|
43
|
+
timeoutSec,
|
|
44
|
+
externalSignal,
|
|
45
|
+
onReady = () => {},
|
|
46
|
+
onWarn = () => {},
|
|
47
|
+
onCliStderr = () => {},
|
|
48
|
+
} = opts
|
|
49
|
+
|
|
50
|
+
const args = [
|
|
51
|
+
'--profile', app,
|
|
52
|
+
'event', 'consume', EVENT_KEY,
|
|
53
|
+
'--as', 'bot',
|
|
54
|
+
'--timeout', `${timeoutSec}s`,
|
|
55
|
+
]
|
|
56
|
+
if (jq) args.push('--jq', jq)
|
|
57
|
+
|
|
58
|
+
// bounded 运行忽略 stdin EOF;只有 --timeout 0 的 unbounded 模式把 EOF 当退出信号,
|
|
59
|
+
// 那时必须给一根保持打开的管道(给 /dev/null 或 ignore 会立刻 EOF)
|
|
60
|
+
const stdio = timeoutSec === 0 ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe']
|
|
61
|
+
const { file, preArgs } = cliCommand()
|
|
62
|
+
// detached:子进程自建进程组。PATH 上的 lark-cli 在 fnm 等版本管理器下是个
|
|
63
|
+
// 包装脚本,真二进制是它派生的孙子;child.kill() 只杀包装层,孙子会带着
|
|
64
|
+
// stdout 管道成孤儿,close 永不触发。结束必须按进程组(负 pid)杀整棵树
|
|
65
|
+
const child = spawn(file, [...preArgs, ...args], { stdio, detached: true })
|
|
66
|
+
if (timeoutSec === 0 && child.stdin) child.stdin.on('error', () => {})
|
|
67
|
+
|
|
68
|
+
const stopChild = () => {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(-child.pid, "SIGTERM")
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err.code !== 'ESRCH') child.kill('SIGTERM')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const batch = []
|
|
77
|
+
let ready = false
|
|
78
|
+
let shuttingDown = false
|
|
79
|
+
let startupExit = null // ready 前的致命错误用退出码 2(参数/过滤表达式问题)
|
|
80
|
+
let runtimeError = null
|
|
81
|
+
let debounceTimer = null
|
|
82
|
+
let maxWaitTimer = null
|
|
83
|
+
let firstAt = 0
|
|
84
|
+
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
let settled = false
|
|
87
|
+
const settle = (reason) => {
|
|
88
|
+
if (settled) return
|
|
89
|
+
settled = true
|
|
90
|
+
clearTimeout(debounceTimer)
|
|
91
|
+
clearTimeout(maxWaitTimer)
|
|
92
|
+
if (reason === 'error') {
|
|
93
|
+
resolve({ events: batch, reason, exitCode: startupExit || 1 })
|
|
94
|
+
} else if (batch.length) {
|
|
95
|
+
resolve({ events: batch, reason: 'batch' })
|
|
96
|
+
} else {
|
|
97
|
+
resolve({ events: batch, reason })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const armDebounce = () => {
|
|
102
|
+
clearTimeout(debounceTimer)
|
|
103
|
+
debounceTimer = setTimeout(() => finish('batch'), debounceMs)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const accept = (obj) => {
|
|
107
|
+
batch.push(obj)
|
|
108
|
+
if (!firstAt) {
|
|
109
|
+
firstAt = Date.now()
|
|
110
|
+
// 绝对封顶:热闹群也不能无限攒下去
|
|
111
|
+
maxWaitTimer = setTimeout(() => finish('batch'), maxWaitMs)
|
|
112
|
+
}
|
|
113
|
+
armDebounce()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 防抖到点或封顶:请 CLI 进程组优雅退出(禁 SIGKILL -- 会漏卸载服务端订阅),
|
|
117
|
+
// close 后统一交付;退出前在途到达的行照收进本批
|
|
118
|
+
const finish = () => {
|
|
119
|
+
if (shuttingDown) return
|
|
120
|
+
shuttingDown = true
|
|
121
|
+
clearTimeout(debounceTimer)
|
|
122
|
+
stopChild()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (externalSignal) {
|
|
126
|
+
externalSignal.addEventListener('abort', () => {
|
|
127
|
+
shuttingDown = true
|
|
128
|
+
stopChild()
|
|
129
|
+
}, { once: true })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
child.on('error', (err) => {
|
|
133
|
+
const hint = err.code === 'ENOENT'
|
|
134
|
+
? `找不到 ${file},检查它已安装且在 PATH 中`
|
|
135
|
+
: `spawn ${file} 失败:${err.message}`
|
|
136
|
+
onWarn(hint)
|
|
137
|
+
runtimeError = err
|
|
138
|
+
settle('error')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity })
|
|
142
|
+
rl.on('line', (line) => {
|
|
143
|
+
const s = line.trim()
|
|
144
|
+
if (!s || s[0] !== '{') {
|
|
145
|
+
if (s) onWarn(`非 JSON 行,已跳过:${s.slice(0, 200)}`)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
let obj
|
|
149
|
+
try {
|
|
150
|
+
obj = JSON.parse(s)
|
|
151
|
+
} catch {
|
|
152
|
+
onWarn(`非 JSON 行,已跳过:${s.slice(0, 200)}`)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
// CLI 的错误信封(如 --jq 编译失败)从 stdout 出来,不是事件
|
|
156
|
+
if (obj.ok === false) {
|
|
157
|
+
const detail = obj.error?.message || s.slice(0, 500)
|
|
158
|
+
onWarn(`lark-cli 报错:${detail}`)
|
|
159
|
+
if (!ready) startupExit = 2
|
|
160
|
+
else runtimeError = new Error(detail)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
accept(obj)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const erl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity })
|
|
167
|
+
erl.on('line', (line) => {
|
|
168
|
+
const text = line.trimEnd()
|
|
169
|
+
if (!text) return
|
|
170
|
+
onCliStderr(text)
|
|
171
|
+
if (!ready && READY_RE.test(text)) {
|
|
172
|
+
ready = true
|
|
173
|
+
onReady(text)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
child.on('close', (code, signal) => {
|
|
178
|
+
if (externalSignal?.aborted) return settle('signal')
|
|
179
|
+
if (shuttingDown) return settle('batch')
|
|
180
|
+
if (!ready) {
|
|
181
|
+
// ready 前退出:没连上就什么都没收到,原因 CLI 已打到 stderr。
|
|
182
|
+
// 错误信封已置过 startupExit=2(过滤表达式/参数问题),其余按运行错 1
|
|
183
|
+
if (startupExit === null) startupExit = 1
|
|
184
|
+
return settle('error')
|
|
185
|
+
}
|
|
186
|
+
if (code === 0 || signal === 'SIGTERM') return settle(batch.length ? 'batch' : 'timeout')
|
|
187
|
+
runtimeError = runtimeError || new Error(`lark-cli 异常退出(code ${code},signal ${signal})`)
|
|
188
|
+
settle('error')
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { watch, cliCommand, EVENT_KEY }
|
package/package.json
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lark-watch",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Watch Lark messages: spawn lark-cli event consume, debounce a batch, print and exit.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"lark",
|
|
7
|
+
"feishu",
|
|
8
|
+
"events",
|
|
9
|
+
"watch",
|
|
10
|
+
"bot",
|
|
11
|
+
"agent"
|
|
12
|
+
],
|
|
5
13
|
"license": "MIT",
|
|
6
14
|
"author": "adaex",
|
|
7
|
-
"
|
|
8
|
-
"
|
|
15
|
+
"type": "commonjs",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"lark-watch": "bin/lark-watch.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"lib",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test test/*.test.js",
|
|
29
|
+
"prepublishOnly": "node --test test/*.test.js && npm run check-pack",
|
|
30
|
+
"check-pack": "node scripts/check-pack.js"
|
|
31
|
+
}
|
|
9
32
|
}
|