Skip to content

Commit 1caea2f

Browse files
nieaoclaude
andcommitted
fix(bot): lark-cli stdout NDJSON 失效 — 改用 --output-dir + fs.watch
VPS 部署后发现 lark-cli event +subscribe 默认 stdout 模式实测不输出 event body (--help 写的 NDJSON output 没生效, 仅 stderr 有 status counter "[1] im.message.receive_v1"). 已验证 --output-dir 模式可拿到完整 schema 2.0 event JSON. 改 bot 接收路径: - spawn 加 --output-dir ./events --quiet (env FEISHU_EVENTS_DIR 可覆盖) - 用 fs.watch 监听文件创建 → readFile + JSON.parse + handle{Message,CardAction} → 处理后 unlink 避免目录无限增长 - 启动时 mkdirSync + 清理上次残留, 避免重启时旧文件复活 - 30s 去重窗口防 fs.watch 同一文件多次 rename 触发 handleMessage 同步修正 envelope 格式: schema 2.0 是 evt.event.message 不是 evt.message; sender_id 在 evt.event.sender.sender_id.open_id systemd unit 配套: - WorkingDirectory=/var/cache/know-canvas-feishubot (lark-cli --output-dir 必须 cwd-relative) - CacheDirectory=know-canvas-feishubot (systemd 自动创建 + 重启时清理) - FEISHU_EVENTS_DIR=./events 环境变量 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fe506e0 commit 1caea2f

2 files changed

Lines changed: 76 additions & 15 deletions

File tree

deploy/know-canvas-feishubot.service

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,18 @@ Type=simple
2828
# 必须用 root: 复用 /root/.lark-cli/ 下的 profile 凭证
2929
User=root
3030
Group=root
31-
WorkingDirectory=/opt/know-canvas-repo
31+
# WorkingDirectory 必须是 events 目录的 parent — lark-cli --output-dir 强制相对路径
32+
# CacheDirectory 让 systemd 创建 /var/cache/know-canvas-feishubot/ 并挂为 cwd
33+
WorkingDirectory=/var/cache/know-canvas-feishubot
34+
CacheDirectory=know-canvas-feishubot
3235
Environment=NODE_ENV=production
3336
Environment=LARK_PROFILE=cli_a9434cff84381bd9
3437
Environment=LARK_CLI=/usr/local/bin/lark-cli
3538
Environment=SOURCE_PROXY=http://127.0.0.1:17090
3639
Environment=CANVAS_PUBLIC_URL=https://ha2.digitalvio.shop/canvas/
3740
Environment=CANVAS_DEFAULT_ROOM=demo-final
3841
Environment=DAEMON_AS_NAME=猫蛋
42+
Environment=FEISHU_EVENTS_DIR=./events
3943
Environment=PATH=/usr/local/bin:/usr/bin:/bin
4044

4145
ExecStart=/usr/bin/node /opt/know-canvas-repo/server/feishu-bot.mjs

server/feishu-bot.mjs

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import { spawn } from 'node:child_process'
3838
import readline from 'node:readline'
3939
import path from 'node:path'
4040
import os from 'node:os'
41+
import fs from 'node:fs'
42+
import fsp from 'node:fs/promises'
4143

4244
const SOURCE_PROXY = process.env.SOURCE_PROXY || 'http://127.0.0.1:17090'
4345
const CANVAS_PUBLIC_URL = process.env.CANVAS_PUBLIC_URL || 'https://ha2.digitalvio.shop/canvas/'
@@ -359,11 +361,12 @@ async function handleMessage(evt) {
359361
return handleCardAction(evt)
360362
}
361363

362-
// 提取 message 字段 (兼容扁平 + envelope)
363-
const msg = evt.message || evt
364+
// 提取 message 字段 (schema 2.0: evt.event.message; 兼容扁平/旧 envelope)
365+
const msg = evt.event?.message || evt.message || evt
366+
const sender = evt.event?.sender || evt.sender || {}
364367
const messageId = msg.message_id || ''
365368
const chatId = msg.chat_id || ''
366-
const senderId = msg.sender_id || msg.sender?.sender_id?.open_id || ''
369+
const senderId = sender.sender_id?.open_id || msg.sender_id || ''
367370
let content = String(msg.content || '').trim()
368371

369372
// 飞书 content 有时是 JSON {text: ".."}, 有时是裸字符串. 尝试 parse
@@ -673,40 +676,94 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
673676
})
674677
}
675678

679+
// === 事件接收: 用 lark-cli --output-dir 落盘 + fs.watch 监听 ===
680+
// 背景: lark-cli 默认 stdout NDJSON 模式实测不输出 event body (仅 stderr status counter),
681+
// --output-dir 把每条 event 写成独立 JSON 文件已验证可拿到完整 body.
682+
// cwd: systemd unit 设 WorkingDirectory=/var/cache/know-canvas-feishubot (CacheDirectory 管理)
683+
// 路径要求: lark-cli 强制 --output-dir 必须是 cwd 下的相对路径 (unsafe output path 校验)
684+
const EVENTS_DIR = process.env.FEISHU_EVENTS_DIR || './events'
685+
686+
async function processEventFile(filePath) {
687+
let raw
688+
try { raw = await fsp.readFile(filePath, 'utf-8') }
689+
catch (e) { logErr('读事件文件失败:', filePath, e.message); return }
690+
let evt
691+
try { evt = JSON.parse(raw) }
692+
catch (e) { logErr('事件 JSON 解析失败:', filePath, e.message); return }
693+
try {
694+
if (evt.header?.event_type === 'card.action.trigger') {
695+
await handleCardAction(evt)
696+
} else {
697+
await handleMessage(evt)
698+
}
699+
} catch (e) {
700+
logErr('handler 异常:', e.message, e.stack?.slice(0, 300))
701+
} finally {
702+
// 处理完删文件 (避免目录无限增长)
703+
fsp.unlink(filePath).catch(() => {})
704+
}
705+
}
706+
676707
function start() {
677708
log(`启动 bot — source-proxy: ${SOURCE_PROXY}, default room: ${DEFAULT_ROOM}, lark-bin: ${LARK_BIN}`)
709+
log(`事件目录: ${path.resolve(EVENTS_DIR)} (cwd=${process.cwd()})`)
678710

679-
// 显式 --event-types 订阅 message + card 事件 (catch-all 模式 stdout 不 forward 事件 body)
711+
// 准备事件目录 (清理上次残留, 创建新的)
712+
try {
713+
fs.mkdirSync(EVENTS_DIR, { recursive: true })
714+
for (const f of fs.readdirSync(EVENTS_DIR)) {
715+
if (f.endsWith('.json')) {
716+
try { fs.unlinkSync(path.join(EVENTS_DIR, f)) } catch {}
717+
}
718+
}
719+
} catch (e) {
720+
logErr('创建/清理事件目录失败:', EVENTS_DIR, e.message)
721+
}
722+
723+
// fs.watch: 监听文件创建 (Linux inotify; Windows ReadDirectoryChangesW)
724+
// 落盘的瞬间会有 'rename' 事件 (Linux 创建用 rename), 我们去重处理
725+
const seen = new Set()
726+
const watcher = fs.watch(EVENTS_DIR, async (eventType, filename) => {
727+
if (!filename || !filename.endsWith('.json')) return
728+
const full = path.join(EVENTS_DIR, filename)
729+
if (seen.has(full)) return
730+
seen.add(full)
731+
setTimeout(() => seen.delete(full), 30_000) // 30s 去重窗口
732+
// 文件可能还在写, 等 50ms 让 lark-cli 写完
733+
setTimeout(() => {
734+
fs.access(full, fs.constants.R_OK, (err) => {
735+
if (err) return // 文件可能已被处理删除
736+
processEventFile(full)
737+
})
738+
}, 50)
739+
})
740+
741+
// 显式 --event-types 订阅 message + card 事件
680742
// --force 绕过 single-instance lock (旧 daemon SIGKILL 不释放锁; 我们重启时若锁未失效会卡死)
743+
// --output-dir 让每条 event 落盘独立 JSON 文件 (绕开 stdout NDJSON 失效的坑)
744+
// --quiet 抑制 stderr status (减少 journal 噪音)
681745
const consumer = spawnLark(
682746
[
683747
'event', '+subscribe',
684748
'--as', 'bot',
685749
'--event-types', 'im.message.receive_v1,card.action.trigger',
750+
'--output-dir', EVENTS_DIR,
686751
'--force',
752+
'--quiet',
687753
],
688754
{ stdio: ['pipe', 'pipe', 'pipe'] },
689755
)
690756
_currentConsumer = consumer
691757
consumer.stdin.on('error', () => {})
692758

693-
const rl = readline.createInterface({ input: consumer.stdout })
694-
rl.on('line', async (line) => {
695-
if (!line.trim()) return
696-
let evt
697-
try { evt = JSON.parse(line) }
698-
catch (e) { logErr('NDJSON 解析失败:', line.slice(0, 200)); return }
699-
try { await handleMessage(evt) }
700-
catch (e) { logErr('handleMessage 异常:', e.message, e.stack?.slice(0, 200)) }
701-
})
702-
703759
consumer.stderr.on('data', (b) => {
704760
const s = b.toString('utf8').trim()
705761
if (s) log('[lark-cli]', s.slice(0, 200))
706762
})
707763

708764
consumer.on('error', (e) => { logErr('subscribe spawn 失败:', e.message); process.exit(1) })
709765
consumer.on('exit', (code, sig) => {
766+
try { watcher.close() } catch {}
710767
logErr(`subscribe 退出 code=${code} sig=${sig} — 5s 后重启`)
711768
setTimeout(start, 5000)
712769
})

0 commit comments

Comments
 (0)