#!/bin/bash # ============================================================================ # SessionStart Hook — 飞书事件消费者生命周期管理 # # 通过环境变量适配不同项目(szis / dit),PID/日志文件按 LARK_PROFILE 后缀分离, # cleanup 只清理本项目进程,绝不误杀其他项目的 consumer。 # 由 settings.json 的 SessionStart hook 无参调用。 # # 环境变量(均在项目 settings.json 中配置): # LARK_PROFILE - lark-cli profile 名称,同时用于进程/日志文件命名隔离 # LARK_INBOX_NDJSON_PATH - inbox ndjson 文件相对路径(相对于项目根目录) # 可选: # SUBMIT_DELAY - tmux send-keys 延迟秒数(默认 0.5) # LARK_SENDER_ALLOW - sender 白名单 open_id(优先于 LARK_ME_USER_ID) # LARK_ME_USER_ID - sender 白名单 open_id(fallback) # ============================================================================ set -euo pipefail PROJECT_ROOT="${PWD}" INBOX_FILE="${PROJECT_ROOT}/${LARK_INBOX_NDJSON_PATH:-01-输入/lark-inbox.ndjson}" SUBMIT_DELAY="${SUBMIT_DELAY:-0.5}" MAX_DIAG_LINES=500 # ── 从自身位置推导 consumer 脚本路径(与 ensure 脚本同目录)── SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONSUMER_SCRIPT="${SCRIPT_DIR}/lark-consumer.sh" # ── 按 profile 隔离的 PID/日志文件 ── PROFILE_NAME="${LARK_PROFILE:-szis}" PID_FILE="/tmp/lark-consumer-${PROFILE_NAME}.pid" CONSUMER_LOG="/tmp/lark-consumer-${PROFILE_NAME}.log" DIAG_LOG="/tmp/lark-consumer-ensure-${PROFILE_NAME}.log" # ── 日志函数 ── log() { local ts lines ts=$(date '+%Y-%m-%d %H:%M:%S') echo "[${ts}] $*" >> "${DIAG_LOG}" lines=$(wc -l < "${DIAG_LOG}" 2>/dev/null || echo 0) if [ "${lines}" -gt "${MAX_DIAG_LINES}" ]; then tail -n "${MAX_DIAG_LINES}" "${DIAG_LOG}" > "${DIAG_LOG}.tmp" && mv "${DIAG_LOG}.tmp" "${DIAG_LOG}" fi } # ── 项目隔离清理:只杀本 profile 的消费者进程 ── cleanup_stale() { log "---- 开始清理历史 ${PROFILE_NAME} 消费者进程 ----" local killed=0 p ancestor sig pgid local -A PROTECT=() # 收集自身及所有祖先 PID(受保护,绝不杀) set +e ancestor=$$ while [ -n "$ancestor" ] && [ "$ancestor" != 0 ] && [ "$ancestor" != 1 ]; do PROTECT["$ancestor"]=1 ancestor=$(ps -o ppid= -p "$ancestor" 2>/dev/null | tr -d ' ') done log "受保护(自身+祖先) PID: ${!PROTECT[*]}" # 1) 按 PID_FILE 里的 leader 杀整个进程组(setsid 启动,leader 即进程组长) if [ -f "${PID_FILE}" ]; then local leader leader=$(cat "${PID_FILE}" 2>/dev/null) if [ -n "${leader}" ]; then pgid=$(ps -o pgid= -p "${leader}" 2>/dev/null | tr -d ' ') if [ -n "${pgid}" ]; then kill -- -"${pgid}" 2>/dev/null log "kill PID_FILE 进程组 -${pgid} (leader=${leader})" fi fi fi # 2) 按 INBOX_FILE 路径匹配 consumer.sh 进程,PGID 整组清除 for p in $(pgrep -f "lark-consumer\.sh --inbox-file ${INBOX_FILE}" 2>/dev/null || true); do [ -n "${PROTECT[$p]:-}" ] && continue pgid=$(ps -o pgid= -p "$p" 2>/dev/null | tr -d ' ') if [ -n "${pgid}" ]; then kill -- -"${pgid}" 2>/dev/null log "kill consumer 进程组 -${pgid} (pid=$p)" killed=$((killed + 1)) fi done # 3) 兜底:杀残留的 --profile consume for sig in TERM KILL; do [ "$sig" = "KILL" ] && sleep 0.5 for p in $(pgrep -f "lark-cli .*--profile ${PROFILE_NAME} .*event consume im\.message\.receive_v1" 2>/dev/null || true); do [ -n "${PROTECT[$p]:-}" ] && continue kill "-${sig}" "$p" 2>/dev/null log "$sig consume pid=$p" killed=$((killed + 1)) done done set -e rm -f "${PID_FILE}" 2>/dev/null || true log "清理完成,处理 $killed 个进程句柄" } # ── 检测当前 CC pane(沿祖先链找有效 tty,再匹配 tmux pane_tty)── detect_cc_pane() { ( set +e local pid=$$ tty pane while [ -n "$pid" ] && [ "$pid" != 0 ] && [ "$pid" != 1 ]; do tty=$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ') case "$tty" in ''|'?'|'??') : ;; # 无效 tty,继续向上 *) pane=$(tmux list-panes -a -F '#{pane_id} #{pane_tty}' 2>/dev/null \ | awk -v t="/dev/$tty" '$2==t{print $1; exit}') [ -n "$pane" ] && { echo "$pane"; exit 0; } ;; esac pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') done exit 1 ) } log "========== lark-consumer-ensure (${PROFILE_NAME}) 开始 ==========" log "PWD=${PROJECT_ROOT} INBOX=${INBOX_FILE} PID_FILE=${PID_FILE}" # ═══ 0. 检查 profile 是否已创建(未创建则安全跳过)═══ if ! lark-cli profile list 2>/dev/null | grep -q "\"name\": \"${PROFILE_NAME}\""; then log "profile ${PROFILE_NAME} 未创建,跳过 consumer 启动(待 profile add ${PROFILE_NAME} 后生效)" exit 0 fi # ═══ 1. 清理历史(仅本项目)═══ cleanup_stale # ═══ 2. 检查 bus daemon ═══ BUS_JSON=$(lark-cli --profile "${PROFILE_NAME}" event status --json 2>&1 || true) log "event status: ${BUS_JSON}" BUS_ALIVE=$(printf '%s' "${BUS_JSON}" | python3 -c " import sys,json try: d=json.load(sys.stdin) print(str(any(a.get('running',False) for a in d.get('apps',[]))).lower()) except Exception: print('false') " 2>/dev/null || echo "false") log "BUS_ALIVE=${BUS_ALIVE}" # ═══ 3. 确保 inbox 文件存在 ═══ touch "${INBOX_FILE}" log "inbox 已就绪" # ═══ 4. 检测 CC pane ═══ CC_PANE="" CC_PANE=$(detect_cc_pane) || CC_PANE="" log "CC_PANE=${CC_PANE:-(未检测到)}" # ═══ 5. 启动单例消费者(setsid 独立进程组,便于整树清理)═══ export JQ_EXPR='select(.chat_type=="p2p") | {time: .create_time, msg: .content, id: .message_id, sender: .sender_id}' export CONSUMER_LOG CONSUMER_SCRIPT INBOX_FILE CC_PANE SUBMIT_DELAY DIAG_LOG export PROFILE_NAME log "启动 ${PROFILE_NAME} 单例消费者..." setsid bash -c ' trap "kill 0 2>/dev/null" EXIT # sleep infinity 保持 lark-cli stdin 不 EOF(lark-cli 默认把 stdin EOF 当退出信号) sleep infinity | \ lark-cli --profile "$PROFILE_NAME" event consume im.message.receive_v1 --as bot --jq "$JQ_EXPR" \ 2>>"$CONSUMER_LOG" \ | bash "$CONSUMER_SCRIPT" \ --inbox-file "$INBOX_FILE" \ --cc-pane "$CC_PANE" \ --submit-delay "$SUBMIT_DELAY" \ --diag-log "$DIAG_LOG" \ --scope "$PROFILE_NAME" ' >>"${CONSUMER_LOG}" 2>&1 & LEADER=$! disown 2>/dev/null || true echo "${LEADER}" > "${PID_FILE}" log "leader 已启动 PID=${LEADER},写入 ${PID_FILE}" # ═══ 6. 等待就绪(最多 10s)═══ READY=false for i in $(seq 1 10); do sleep 1 if ! kill -0 "${LEADER}" 2>/dev/null; then log "leader ${LEADER} 在第 ${i}s 已退出" break fi if grep -q 'consumer ready' "${CONSUMER_LOG}" 2>/dev/null; then READY=true log "消费者就绪(第 ${i}s)" break fi done if [ "${READY}" = false ]; then log "警告: 未检测到就绪信号" if kill -0 "${LEADER}" 2>/dev/null; then log "leader 仍在运行但无 ready 信号" else log "leader 已退出,CONSUMER_LOG 末尾:" tail -20 "${CONSUMER_LOG}" 2>/dev/null >> "${DIAG_LOG}" || true fi fi log "========== lark-consumer-ensure (${PROFILE_NAME}) 结束 ==========" exit 0