#!/bin/bash # # Setup script for 腾讯问卷 MCP Skill (内部 OpenClaw 版本) 一体化配置与授权脚本 # # 功能: # 1. 检查 mcporter 是否已配置 tencent-survey(含 Authorization 可用) # 2. 未配置或 Token 失效时,展示授权链接 # 3. 前台轮询等待用户完成授权,Token 获取后自动写入 mcporter 并继续 # 4. 对超时、过期、错误等场景给出友好提示 # # 用法(供 AI Agent 调用): # 第一步:检查状态(立即返回,不阻塞) # bash ./setup.sh wj_check_and_start_auth # 输出: # READY → 服务已就绪,直接执行用户任务,无需第二步 # NONCE: → nonce 值(在 AUTH_REQUIRED 之前输出) # AUTH_REQUIRED: → 立即向用户展示授权链接,然后执行第二步 # ERROR:* → 告知用户对应错误 # # 第二步:等待授权完成(仅 AUTH_REQUIRED 时执行,阻塞最多约 300s) # bash ./setup.sh wj_wait_auth # 输出: # TOKEN_READY:ok → 授权成功,Token 已写入配置,继续执行用户任务 # AUTH_TIMEOUT → 告知用户:授权超时,请重新发起请求 # ERROR:* → 告知用户对应错误 # # 直接执行(排查问题): # bash ./setup.sh # # ── 全局配置 ────────────────────────────────────────────────────────────────── _WJ_API_BASE="${WJ_API_BASE_URL:-https://wj.qq.com}" _WJ_AUTH_PAGE="${WJ_AUTH_PAGE_URL:-https://wj.qq.com/oauth/authorize}" # 从 WJ_AUTH_PAGE_URL 中提取查询参数(如 _tde_id=2952),附加到 API 请求 _WJ_EXTRA_QUERY="" if [[ "$_WJ_AUTH_PAGE" == *"?"* ]]; then _WJ_EXTRA_QUERY="${_WJ_AUTH_PAGE#*\?}" fi # 构造 API URL,如有额外参数则附加 _WJ_MCP_URL="${_WJ_API_BASE}/api/v2/mcp" _WJ_TOKEN_POLL_URL="${_WJ_API_BASE}/api/v2/account/tokens/device-auth/poll" if [[ -n "$_WJ_EXTRA_QUERY" ]]; then _WJ_MCP_URL="${_WJ_MCP_URL}?${_WJ_EXTRA_QUERY}" _WJ_TOKEN_POLL_URL="${_WJ_TOKEN_POLL_URL}?${_WJ_EXTRA_QUERY}" fi _WJ_SERVICE_NAME="tencent-survey" # 轮询参数:每 2s 一次,最多 150 次(约 300s) _WJ_POLL_INTERVAL=2 _WJ_POLL_MAX=150 # 临时文件:使用基于 UID 的私有目录,避免 symlink 攻击和多用户冲突 _WJ_TMP_DIR="${TMPDIR:-/tmp}/.wj_auth_$(id -u)" mkdir -p "$_WJ_TMP_DIR" 2>/dev/null chmod 700 "$_WJ_TMP_DIR" _WJ_CODE_FILE="${_WJ_TMP_DIR}/code" _WJ_TOKEN_FILE="${_WJ_TMP_DIR}/token" _WJ_URL_FILE="${_WJ_TMP_DIR}/url" _WJ_NONCE_FILE="${_WJ_TMP_DIR}/nonce" # ── 安全写入函数(拒绝写入符号链接)──────────────────────────────────────── _wj_safe_write() { local file="$1" content="$2" if [[ -L "$file" ]]; then echo "ERROR:security - 检测到符号链接,拒绝写入: $file" >&2 return 1 fi echo "$content" > "$file" chmod 600 "$file" } # ── 清理函数 ────────────────────────────────────────────────────────────────── _wj_cleanup() { [[ -d "$_WJ_TMP_DIR" ]] && rm -rf "$_WJ_TMP_DIR" # 重建私有目录,确保后续写入可用 mkdir -p "$_WJ_TMP_DIR" 2>/dev/null chmod 700 "$_WJ_TMP_DIR" } # ── 检查 mcporter 是否已安装 ────────────────────────────────────────────────── _wj_check_mcporter() { if ! command -v mcporter &> /dev/null; then echo "⚠️ 未找到 mcporter,正在安装..." if command -v npm &>/dev/null; then npm install -g mcporter 2>&1 | tail -3 echo "✅ mcporter 安装完成" else echo "ERROR:no_npm" return 1 fi fi return 0 } # ── 检查必要系统依赖 ────────────────────────────────────────────────────────── _wj_check_dependencies() { local missing=() command -v curl &>/dev/null || missing+=("curl") command -v jq &>/dev/null || missing+=("jq") if [[ ${#missing[@]} -gt 0 ]]; then echo "ERROR:missing_dependencies - 缺少必要依赖: ${missing[*]}" return 1 fi return 0 } # ── JSON 字段提取(使用 jq)────────────────────────────────────────────────── # 提取 JSON 顶层字段 # 用法:_wj_json_get '{"code":"Ok","data":{...}}' "code" → Ok _wj_json_get() { local json="$1" key="$2" echo "$json" | jq -r --arg k "$key" '.[$k] // empty' } # 提取 JSON "data" 子对象中的字段 # 用法:_wj_json_get_data '{"code":"Ok","data":{"token":"xxx"}}' "token" → xxx _wj_json_get_data() { local json="$1" key="$2" echo "$json" | jq -r --arg k "$key" '.data[$k] // empty' } # 从 mcporter config get 读取当前 Authorization Token # 输出:token 字符串(空则表示服务未注册或 Token 未配置) _wj_get_token() { local output output=$(mcporter config get "$_WJ_SERVICE_NAME" 2>/dev/null) || return 1 # 从输出中提取 Authorization 头的值 local token token=$(echo "$output" | grep -i '^\s*Authorization:' | sed 's/.*Authorization:[[:space:]]*//' | tr -d '[:space:]') echo "$token" } # ── 将 Token 写入 mcporter 配置 ─────────────────────────────────────────────── # 用法:_wj_save_token _wj_save_token() { # 添加 MCP 配置 echo "🔧 配置 mcporter..." local token="$1" [[ -z "$token" ]] && return 1 # 使用传入的 token 写入 mcporter 配置(tencent-survey) mcporter config add "$_WJ_SERVICE_NAME" "$_WJ_MCP_URL" \ --header "Authorization=Bearer $token" \ --transport http \ --scope home echo "" echo "✅ 配置完成!" echo "" echo "🧪 验证配置..." if mcporter list 2>&1 | grep -q "$_WJ_SERVICE_NAME"; then echo "✅ tencent-survey 配置验证成功!" echo "" mcporter list | grep -A 1 "$_WJ_SERVICE_NAME" || true else echo "⚠️ tencent-survey 配置验证失败,请检查网络或 Token 是否有效" fi echo "" echo "─────────────────────────────────────" echo "🎉 设置完成!" echo "" echo "📖 使用方法:" echo " mcporter call ${_WJ_SERVICE_NAME}.get_survey --args '{\"survey_id\": 12345}'" echo " mcporter call ${_WJ_SERVICE_NAME}.create_survey --args '{\"text\": \"问卷标题\\n\\n1. 题目\"}'" echo "" echo "🏠 腾讯问卷主页:${_WJ_API_BASE}" echo "" echo "📖 更多信息请查看 SKILL.md" echo "" return 0 } # ── 检查 tencent-survey 服务状态 ──────────────────────────────────────────────── # 返回值: # 0 = 服务正常可用(有 Token) # 1 = 服务未注册(mcporter config get 失败) # 2 = Token 为空或未配置 _wj_check_service() { if ! mcporter list 2>/dev/null | grep -q "$_WJ_SERVICE_NAME"; then return 1 fi local token token=$(_wj_get_token) local rc=$? # mcporter config get 返回非 0 表示服务未注册 if [[ $rc -ne 0 ]]; then return 1 fi # Token 为空表示服务已注册但未配置 Authorization if [[ -z "$token" ]]; then return 2 fi return 0 } # ── 生成授权链接(调用后端 /init API 获取 code)───────────────────────────── # 输出:auth_url 字符串 # code 由后端 crypto/rand 生成,保证密码学安全性 _wj_generate_auth_url() { # 构造 init API URL local init_url="${_WJ_API_BASE}/api/v2/account/tokens/device-auth/init" if [[ -n "$_WJ_EXTRA_QUERY" ]]; then init_url="${init_url}?${_WJ_EXTRA_QUERY}" fi # 调用后端 init 端点 local response response=$(curl -s -f -X POST "$init_url" 2>/dev/null) || { echo "ERROR:init_request_failed - 无法连接到服务器 ${init_url}" return 1 } # 解析响应中的 code local resp_code resp_code=$(_wj_json_get "$response" "code") local resp_upper resp_upper=$(echo "$resp_code" | tr '[:lower:]' '[:upper:]') if [[ "$resp_upper" != "OK" ]]; then echo "ERROR:init_failed - 服务端返回错误: $resp_code" return 1 fi local code code=$(_wj_json_get_data "$response" "code") local nonce nonce=$(_wj_json_get_data "$response" "nonce") # 防御性校验 if [[ ! "$code" =~ ^[0-9a-f]{16,64}$ ]]; then echo "ERROR:invalid_code - 服务端返回的 code 格式非法: $code" return 1 fi if [[ -z "$nonce" ]]; then echo "ERROR:invalid_nonce - 服务端未返回 nonce" return 1 fi _wj_safe_write "$_WJ_CODE_FILE" "$code" || return 1 _wj_safe_write "$_WJ_NONCE_FILE" "$nonce" || return 1 # 如果 AUTH_PAGE 已包含 ?,则用 & 拼接;否则用 ? local sep="?" [[ "$_WJ_AUTH_PAGE" == *"?"* ]] && sep="&" echo "${_WJ_AUTH_PAGE}${sep}code=${code}&nonce=${nonce}" } # ── 前台轮询 Token(通用函数)───────────────────────────────────────────────── # 用法:_wj_poll_token [log_prefix] # log_prefix: 日志前缀(默认 "⏳"),用于区分调用场景的输出 # # 前置条件:$_WJ_CODE_FILE 已存在且包含有效的 code # # 输出(最后一行为结构化结果): # TOKEN_READY: 授权成功 # AUTH_TIMEOUT 超时 # ERROR:empty_token Token 为空异常 # ERROR:no_code 未找到授权码 # ERROR:empty_code 授权码为空 _wj_poll_token() { local prefix="${1:-⏳}" # 检查 code 文件 if [[ ! -f "$_WJ_CODE_FILE" ]]; then echo "ERROR:no_code - 未找到授权码,请先执行 wj_check_and_start_auth" return 1 fi local code code=$(cat "$_WJ_CODE_FILE") if [[ -z "$code" ]]; then echo "ERROR:empty_code - 授权码为空" return 1 fi local poll_sep="?" [[ "$_WJ_TOKEN_POLL_URL" == *"?"* ]] && poll_sep="&" local url="${_WJ_TOKEN_POLL_URL}${poll_sep}code=${code}" local i for ((i=1; i<=_WJ_POLL_MAX; i++)); do sleep "$_WJ_POLL_INTERVAL" local response response=$(curl -s -f -L "$url" 2>/dev/null) || { echo " ${prefix} [$i/$_WJ_POLL_MAX] curl 请求失败" continue } local resp_code resp_code=$(_wj_json_get "$response" "code") # 兼容 "Ok" 和 "OK" local resp_upper resp_upper=$(echo "$resp_code" | tr '[:lower:]' '[:upper:]') if [[ "$resp_upper" != "OK" && -n "$resp_upper" ]]; then echo " ${prefix} [$i/$_WJ_POLL_MAX] resp_code=$resp_code (非Ok)" continue fi local status status=$(_wj_json_get_data "$response" "status") local token token=$(_wj_json_get_data "$response" "token") case "$status" in "completed") echo " ${prefix} [$i/$_WJ_POLL_MAX] status=completed ✅" if [[ -n "$token" ]]; then echo "TOKEN_READY:$token" return 0 fi echo " ${prefix} [$i/$_WJ_POLL_MAX] ⚠️ status=completed 但 token 为空" echo "ERROR:empty_token" return 1 ;; "pending") echo " ${prefix} [$i/$_WJ_POLL_MAX] status=pending" continue ;; *) echo " ${prefix} [$i/$_WJ_POLL_MAX] status=$status (未知状态)" continue ;; esac done echo "AUTH_TIMEOUT" return 2 } # ── 执行授权流程(第一阶段):生成链接(立即返回,不阻塞)───────────────────── # 输出: # AUTH_REQUIRED: 立即输出到 stdout,同时写入 $_WJ_URL_FILE _wj_do_auth_start() { _wj_cleanup # 生成授权链接(同时写入 code 和 nonce 文件) local auth_url auth_url=$(_wj_generate_auth_url) local rc=$? # 检查生成是否成功 if [[ $rc -ne 0 ]]; then echo "$auth_url" # 透传 ERROR:xxx 消息 return 1 fi # 将 URL 写入文件,供后续阶段读取 _wj_safe_write "$_WJ_URL_FILE" "$auth_url" || return 1 # 读取 nonce 并显示 local nonce="" [[ -f "$_WJ_NONCE_FILE" ]] && nonce=$(cat "$_WJ_NONCE_FILE") if [[ -n "$nonce" ]]; then echo "NONCE:$nonce" fi # ★ 立即输出授权链接(调用方可立即展示给用户) echo "AUTH_REQUIRED:$auth_url" return 0 } # ── 主入口函数 A:检查状态 / 生成授权链接(立即返回,不阻塞)──────────────── # # AI Agent 第一步调用此函数,命令执行完毕后立即拿到输出: # READY 服务已就绪,直接执行用户任务,无需第二步 # NONCE: nonce 值(在 AUTH_REQUIRED 之前输出) # AUTH_REQUIRED: 需要授权:立即展示链接给用户,然后执行第二步 # ERROR:* 错误信息 # wj_check_and_start_auth() { _wj_check_mcporter || { echo "ERROR:mcporter_not_found - 请先安装 Node.js 和 npm 后重试" return 1 } _wj_check_dependencies || return 1 # ★ 如果设置了 TENCENT_SURVEY_TOKEN 环境变量,直接写入配置 if [[ -n "$TENCENT_SURVEY_TOKEN" ]]; then if [[ ! "$TENCENT_SURVEY_TOKEN" =~ ^wjpt_ ]]; then echo "ERROR:invalid_token_prefix - TENCENT_SURVEY_TOKEN 必须以 wjpt_ 开头" return 1 fi if _wj_save_token "$TENCENT_SURVEY_TOKEN" >/dev/null 2>&1; then echo "READY" return 0 else echo "ERROR:save_token_failed - Token 写入配置失败" return 1 fi fi _wj_check_service local status=$? case $status in 0) echo "READY" return 0 ;; 1|2) _wj_do_auth_start || return 1 return 0 ;; esac } # ── 主入口函数 B:等待授权完成(阻塞,最多约 300s)──────────────────────────── # # AI Agent 在展示授权链接后调用此函数,等待用户完成授权: # TOKEN_READY:ok 授权成功,Token 已写入配置,直接执行用户任务 # AUTH_TIMEOUT 超时,告知用户重新发起请求 # ERROR:* 错误信息 # wj_wait_auth() { # 前台轮询 API 等待授权完成 local result result=$(_wj_poll_token "⏳") local rc=$? # 提取最后一行作为结构化结果 local last_line last_line=$(echo "$result" | tail -1) # 输出过程日志(去掉最后一行结果) echo "$result" | sed '$d' case "$last_line" in TOKEN_READY:*) local token="${last_line#TOKEN_READY:}" if _wj_save_token "$token"; then _wj_cleanup echo "TOKEN_READY:ok" return 0 else _wj_cleanup echo "ERROR:save_token_failed" return 1 fi ;; AUTH_TIMEOUT) _wj_cleanup echo "AUTH_TIMEOUT" return 2 ;; ERROR:empty_token*) _wj_cleanup echo "ERROR:empty_token - 授权异常,Token 为空" return 1 ;; ERROR:*) _wj_cleanup echo "$last_line" return 1 ;; esac } # ── 直接执行时的交互式安装流程 ─────────────────────────────────────────────── _wj_interactive_setup() { echo "" echo "╔══════════════════════════════════════════════╗" echo "║ 腾讯问卷 MCP Skill 配置向导 ║" echo "╚══════════════════════════════════════════════╝" echo "" # 检查 mcporter echo "🔍 检查 mcporter..." if ! _wj_check_mcporter; then echo "❌ mcporter 安装失败,请先安装 Node.js (https://nodejs.org) 后重试" exit 1 fi echo "✅ mcporter 已就绪" echo "" # 检查系统依赖 echo "🔍 检查系统依赖..." if ! _wj_check_dependencies; then echo "❌ 缺少必要依赖,请先安装后重试" exit 1 fi echo "✅ 系统依赖已就绪" echo "" # 检查服务状态 echo "🔍 检查 tencent-survey 服务配置..." _wj_check_service local status=$? case $status in 0) echo "✅ tencent-survey 服务已配置且运行正常!" echo "" echo "🎉 无需重新配置,您可以直接使用腾讯问卷功能。" echo "" echo "📖 使用示例:" echo " mcporter call tencent-survey.get_survey --args '{\"survey_id\": 12345}'" return 0 ;; 1|2) echo "⚠️ Token 未配置,需要授权..." ;; esac echo "" echo "🔐 需要完成腾讯问卷授权" echo "" # 清理旧状态 _wj_cleanup # 生成授权链接(同时写入 code 和 nonce 文件) local auth_url auth_url=$(_wj_generate_auth_url) if [[ $? -ne 0 ]]; then echo "❌ 生成授权链接失败:$auth_url" exit 1 fi # 读取 nonce local nonce="" [[ -f "$_WJ_NONCE_FILE" ]] && nonce=$(cat "$_WJ_NONCE_FILE") echo "┌─────────────────────────────────────────────────────────┐" echo "│ 请在浏览器中打开以下链接完成授权: │" echo "│ │" printf "│ %s\n" "$auth_url" echo "│ │" if [[ -n "$nonce" ]]; then printf "│ 🔑 nonce: %s\n" "$nonce" echo "│ │" fi echo "│ ⚠️ 请使用 QQ 或微信 扫码 / 登录授权 │" echo "└─────────────────────────────────────────────────────────┘" echo "" echo "⏳ 正在等待您完成授权,无需任何额外操作..." echo " (最多等待 $((_WJ_POLL_MAX * _WJ_POLL_INTERVAL)) 秒)" echo "" # ★ 前台轮询等待授权完成 local result result=$(_wj_poll_token "🔄") local rc=$? # 提取最后一行作为结构化结果 local last_line last_line=$(echo "$result" | tail -1) # 输出过程日志(去掉最后一行结果) echo "$result" | sed '$d' case "$last_line" in TOKEN_READY:*) local token="${last_line#TOKEN_READY:}" echo "" echo "✅ 授权成功!正在保存配置..." if _wj_save_token "$token"; then _wj_cleanup echo "✅ Token 已写入 mcporter 配置" echo "" echo "🎉 配置完成!现在可以直接使用腾讯问卷功能了。" echo "" echo "📖 使用示例:" echo " mcporter call ${_WJ_SERVICE_NAME}.get_survey --args '{\"survey_id\": 12345}'" echo "" echo "🏠 腾讯问卷主页:${_WJ_API_BASE}" else # Token 写入临时文件,避免在终端明文打印 local token_file="${_WJ_TMP_DIR}/token_backup" _wj_safe_write "$token_file" "$token" echo "⚠️ Token 写入配置失败" echo " Token 已保存到临时文件: $token_file" echo " 请手动运行:" echo " mcporter config add ${_WJ_SERVICE_NAME} ${_WJ_MCP_URL} --header \"Authorization=Bearer \$(cat $token_file)\" --transport http --scope home" fi ;; AUTH_TIMEOUT) echo "" echo "⏳ 授权超时(未在时限内完成授权)" echo " 请重新运行:bash ./setup.sh" exit 1 ;; ERROR:*) echo "" echo "❌ 授权失败:$last_line" echo " 如问题持续,请联系腾讯问卷支持" exit 1 ;; esac return 0 } # ── 脚本入口 ────────────────────────────────────────────────────────────────── # 直接执行时: # bash ./setup.sh wj_check_and_start_auth → 第一步:检查状态 / 生成授权链接 # bash ./setup.sh wj_wait_auth → 第二步:等待授权完成 if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then if [[ -n "$1" ]]; then # 参数分发:将第一个参数作为函数名执行 case "$1" in wj_check_and_start_auth|wj_wait_auth) "$1" exit $? ;; setup) echo "🚀 腾讯问卷 MCP Skill 人工配置向导" echo "" _wj_interactive_setup ;; *) echo "ERROR:unknown_command - 未知命令: $1" echo "可用命令: wj_check_and_start_auth, wj_wait_auth, setup" exit 1 ;; esac else echo "用法:" echo " bash ./setup.sh wj_check_and_start_auth # 第一步:检查状态 / 生成授权链接" echo " bash ./setup.sh wj_wait_auth # 第二步:等待授权完成" fi fi