@@ -38,6 +38,8 @@ import { spawn } from 'node:child_process'
3838import readline from 'node:readline'
3939import path from 'node:path'
4040import os from 'node:os'
41+ import fs from 'node:fs'
42+ import fsp from 'node:fs/promises'
4143
4244const SOURCE_PROXY = process . env . SOURCE_PROXY || 'http://127.0.0.1:17090'
4345const 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+
676707function 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