#!/bin/bash # # openclaw-auto-approve.sh # 自动扫描并批准 openclaw 配对请求(多通道 + 多账户全批准) # # 手动批准请求 # ============ # 当自动批准不工作(CLI 路径卡住 / 通道断连 / daemon 一直没起)时, # 可以走下面两条兜底路径。 # # 1) 停 daemon,单条用 openclaw CLI 批准(最常见) # supervisorctl stop openclaw-auto-approve # openclaw pairing list --channel --account # 看请求码 # openclaw pairing approve --account # 批准一条 # supervisorctl start openclaw-auto-approve # # 2) 调试用 DRY_RUN 看脚本打算做什么而不真批 # DRY_RUN=1 启动一次,看 /var/log/openclaw/openclaw-auto-approve.log # 确认后再用 #1 真批 # # 设计要点: # - 通过 `openclaw channels list --json` 自动发现已安装且有账户的通道 # - 每个通道、每个账户都扫描一次 `pairing list --json`,逐条批准 # - 状态文件记录已批准过的 (channel, account, code),重启不重复批 # - PID 文件防止多实例同时跑 # - 优雅退出(SIGTERM/SIGINT/SIGHUP) # - DRY_RUN=1 模式只打印不真批 # - CHANNEL 环境变量可显式锁定到单通道(默认自动发现) # # 环境变量: # CHANNEL 显式锁定到单通道(默认空 = 自动发现所有已装通道) # INTERVAL 扫描间隔秒数,默认 10 # LOG_DIR 日志目录,默认 /var/log/openclaw # STATE_DIR 状态目录,默认 /var/lib/openclaw # DRY_RUN 1 = 只打印要批准什么不真批准 # PREFIX openclaw 之外的命令前缀(一般空着) # OPENCLAW_BIN openclaw 可执行路径,默认 /usr/bin/openclaw set -uo pipefail CHANNEL="${CHANNEL:-}" INTERVAL="${INTERVAL:-10}" LOG_DIR="${LOG_DIR:-/var/log/openclaw}" STATE_DIR="${STATE_DIR:-/var/lib/openclaw}" LOG_FILE="$LOG_DIR/openclaw-auto-approve.log" STATE_FILE="$STATE_DIR/auto-approve.approved" PID_FILE="$STATE_DIR/auto-approve.pid" DRY_RUN="${DRY_RUN:-0}" PREFIX="${PREFIX:-}" OPENCLAW_BIN="${OPENCLAW_BIN:-/usr/bin/openclaw}" mkdir -p "$LOG_DIR" "$STATE_DIR" # ---- 工具函数 ---- log() { local level="$1"; shift printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$*" | tee -a "$LOG_FILE" >&2 } run_oc() { if [ -n "$PREFIX" ]; then eval "$PREFIX $OPENCLAW_BIN $*" else "$OPENCLAW_BIN" "$@" fi } # ---- JSON 解析 ---- # 用 jq 最稳;没有就退化 python3 parse_json_codes() { local payload="$1" if command -v jq >/dev/null 2>&1; then printf '%s' "$payload" | jq -r '.requests[]?.code // empty' 2>/dev/null elif command -v python3 >/dev/null 2>&1; then printf '%s' "$payload" | python3 -c ' import json, sys try: data = json.load(sys.stdin) for r in data.get("requests", []): if r.get("code"): print(r["code"]) except Exception: pass ' 2>/dev/null else log "ERROR" "需要 jq 或 python3 来解析 JSON,二者都不可用" return 1 fi } # 从 channels list --json 里抓 (channel, account) 对 # 输出格式:每行 " " parse_json_channel_accounts() { local payload="$1" if command -v jq >/dev/null 2>&1; then printf '%s' "$payload" | jq -r ' .chat // {} | to_entries[]? as $ch | select($ch.value.installed == true) | select(($ch.value.accounts // []) | length > 0) | $ch.value.accounts[] as $acc | [$ch.key, $acc] | join(" ") ' 2>/dev/null elif command -v python3 >/dev/null 2>&1; then printf '%s' "$payload" | python3 -c ' import json, sys try: data = json.load(sys.stdin) chat = data.get("chat", {}) for ch_id, info in chat.items(): if not info.get("installed"): continue accounts = info.get("accounts") or [] for acc in accounts: print(f"{ch_id} {acc}") except Exception: pass ' 2>/dev/null else return 1 fi } # ---- 单实例锁 ---- acquire_lock() { if [ -f "$PID_FILE" ]; then local old_pid old_pid=$(cat "$PID_FILE" 2>/dev/null || echo "") if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then log "ERROR" "已有实例在运行 (PID: $old_pid),退出" exit 1 fi log "WARN" "残留 PID 文件 (PID: $old_pid 不存在),清理" rm -f "$PID_FILE" fi echo $$ > "$PID_FILE" } release_lock() { rm -f "$PID_FILE" } shutdown() { log "INFO" "收到终止信号,关闭..." release_lock exit 0 } trap shutdown SIGTERM SIGINT SIGHUP # ---- 状态去重 ---- # 状态文件每行格式:|| # 兼容旧格式:裸 code 行(无 |)视为 feishu|default| state_key() { local ch="$1" acc="$2" code="$3" printf '%s|%s|%s' "$ch" "$acc" "$code" } is_approved() { local key="$1" # 优先匹配新格式 if grep -Fxq "$key" "$STATE_FILE" 2>/dev/null; then return 0 fi # 兼容旧格式:若 key 是 feishu|default|,匹配裸 code if [[ "$key" == "feishu|default|"* ]]; then local bare_code="${key#feishu|default|}" if grep -Fxq "$bare_code" "$STATE_FILE" 2>/dev/null; then return 0 fi fi return 1 } mark_approved() { local key="$1" printf '%s\n' "$key" >> "$STATE_FILE" } # ---- 主循环 ---- main() { acquire_lock # 启动时一次状态文件迁移:把旧裸 code 升级为新格式 touch "$STATE_FILE" if [ -s "$STATE_FILE" ] && grep -q -v '|' "$STATE_FILE" 2>/dev/null; then log "INFO" "检测到旧格式状态文件,迁移为 channel|account|code" local migrated migrated=$(awk -F'|' ' NF==1 && $1!="" {print "feishu|default|"$1; next} {print} ' "$STATE_FILE") printf '%s\n' "$migrated" > "$STATE_FILE" fi if [ -n "$CHANNEL" ]; then log "INFO" "启动自动批准服务 (mode=锁定通道 channel=$CHANNEL interval=${INTERVAL}s dry_run=$DRY_RUN pid=$$)" else log "INFO" "启动自动批准服务 (mode=自动发现所有通道 interval=${INTERVAL}s dry_run=$DRY_RUN pid=$$)" fi while true; do local tick_channels=() local tick_accounts=() # 决定本轮要扫描的 (channel, account) 列表 if [ -n "$CHANNEL" ]; then # 显式锁定:单通道单账户 tick_channels=("$CHANNEL") tick_accounts=("default") else # 自动发现:拉 channels list --json local channels_json if ! channels_json=$(run_oc channels list --json 2>&1); then log "ERROR" "channels list 失败: $channels_json" sleep "$INTERVAL" continue fi local pairs pairs=$(parse_json_channel_accounts "$channels_json") if [ -z "$pairs" ]; then log "INFO" "当前没有已安装且有账户的通道" sleep "$INTERVAL" continue fi while IFS=' ' read -r ch acc; do [ -z "$ch" ] && continue tick_channels+=("$ch") tick_accounts+=("$acc") done <<< "$pairs" fi if [ ${#tick_channels[@]} -eq 0 ]; then log "INFO" "无可扫描通道" sleep "$INTERVAL" continue fi local total_found=0 local total_approved=0 local total_skipped=0 local total_failed=0 # 遍历 (channel, account) for idx in "${!tick_channels[@]}"; do local ch="${tick_channels[$idx]}" local acc="${tick_accounts[$idx]}" # 拉该通道该账户的待批列表 local list_json if ! list_json=$(run_oc pairing list --channel "$ch" --account "$acc" --json 2>&1); then # 通道不支持 pairing / 账户不存在 / 通道未启用等情况 log "DEBUG" "跳过 $ch/$acc: $list_json" continue fi local codes=() while IFS= read -r c; do [ -n "$c" ] && codes+=("$c") done < <(parse_json_codes "$list_json") if [ ${#codes[@]} -eq 0 ]; then log "DEBUG" "$ch/$acc 无待批准" continue fi log "INFO" "[$ch/$acc] 发现 ${#codes[@]} 个待批准请求" total_found=$((total_found + ${#codes[@]})) for code in "${codes[@]}"; do local key key=$(state_key "$ch" "$acc" "$code") if is_approved "$key"; then log "INFO" "[$ch/$acc] 跳过已批准过的 $code" total_skipped=$((total_skipped + 1)) continue fi log "INFO" "[$ch/$acc] 准备批准: $code" if [ "$DRY_RUN" = "1" ]; then log "INFO" "[$ch/$acc] [DRY-RUN] 不会真调用 approve" continue fi local result result=$(run_oc pairing approve "$ch" "$code" --account "$acc" --notify 2>&1) local approve_rc=$? echo "$result" >> "$LOG_FILE" if [ $approve_rc -eq 0 ] || echo "$result" | grep -qi "approved"; then mark_approved "$key" total_approved=$((total_approved + 1)) log "INFO" "✅ [$ch/$acc] 已批准并通知: $code" else total_failed=$((total_failed + 1)) log "ERROR" "❌ [$ch/$acc] 批准失败 (rc=$approve_rc): $code" fi done done if [ $total_found -eq 0 ]; then log "INFO" "本轮扫描 ${#tick_channels[@]} 个通道,无待批准请求" else log "INFO" "本轮汇总: 通道=${#tick_channels[@]} 发现=$total_found 新批准=$total_approved 跳过=$total_skipped 失败=$total_failed" fi sleep "$INTERVAL" done } main