- 新增命令: lark-delegate(跨项目委派)、lark-inbox(收件箱)、lark-me(自发送)、stop(任务收尾) - 新增工具: lark-consumer/consumer-ensure/inbox-check(飞书事件实时消费与会话隔离)、tmux-monitor(监控面板) - runcc.sh 增加 --list/--kill/--send 操作模式与多会话自增命名 - CLAUDE.md 增加强制要求(汇报格式、收尾通知、上下文压缩)
206 lines
7.7 KiB
Bash
Executable File
206 lines
7.7 KiB
Bash
Executable File
#!/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 <name> 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
|