发布:学生会策划部工作包(Skills + MCP)

This commit is contained in:
gitea_admin
2026-09-29 12:35:04 +08:00
commit 2df696c390
147 changed files with 50777 additions and 0 deletions
+637
View File
@@ -0,0 +1,637 @@
#!/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> → nonce 值(在 AUTH_REQUIRED 之前输出)
# AUTH_REQUIRED:<url> → 立即向用户展示授权链接,然后执行第二步
# 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 <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:<token> 授权成功
# 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:<url> 立即输出到 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> nonce 值(在 AUTH_REQUIRED 之前输出)
# AUTH_REQUIRED:<url> 需要授权:立即展示链接给用户,然后执行第二步
# 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