#!/usr/bin/env node /** * 模拟微信服务号 webhook 场景测试(打本地 Portal /webhooks/wechat-mp/messages)。 * * Usage: * node scripts/run-wechat-scenario-test.mjs --list * node scripts/run-wechat-scenario-test.mjs --scenario survey-page-data * node scripts/run-wechat-scenario-test.mjs --all --openid ooil-0VFj68QK1tkHl39uL610et8 */ import crypto from 'node:crypto'; import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import mysql from 'mysql2/promise'; function loadDotEnv(envPath) { if (!fs.existsSync(envPath)) return; for (const line of fs.readFileSync(envPath, 'utf8').split(/\n/)) { if (!line || line.startsWith('#') || !line.includes('=')) continue; const i = line.indexOf('='); const key = line.slice(0, i); const val = line.slice(i + 1); if (!(key in process.env)) process.env[key] = val; } } loadDotEnv(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '.env')); const SCENARIOS = [ { id: 'survey-page-data', name: '问卷 + 口令后台(Page Data)', message: '帮我做一个公司员工建议收集问卷(匿名提交),再做一个口令后台查看数据,后台密码 admin888。', timeoutMs: 900_000, expect: { forbidReply: [ '没有完成 Page Data API 绑定', '公众号消息处理超时', 'computercontroller', ], requireGoosedTools: ['sandbox-fs__private_data', 'sandbox-fs__write_file'], replyKeywords: ['问卷', '后台'], }, }, { id: 'poem-page', name: '写诗 → 做成页面', message: '帮我写一首关于八月午后的短诗,然后做成精美 HTML 页面', timeoutMs: 600_000, expect: { forbidReply: ['没有按服务号页面技能', '没能可靠确认'], requireGoosedTools: ['sandbox-fs__write_file'], replyKeywords: ['八月', '页面'], requireLink: true, }, }, { id: 'chat-general', name: '普通聊天(不应触发 Page Data 闸门)', message: '你好,今天天气怎么样?', timeoutMs: 420_000, expect: { forbidReply: ['Page Data API 绑定'], minReplyChars: 10, }, }, ]; function sha1(parts) { return crypto.createHash('sha1').update([...parts].sort().join('')).digest('hex'); } function buildInboundXml({ fromUser, toUser, content, msgId }) { return [ '', ``, ``, '1710000000', '', ``, `${msgId}`, '', ].join(''); } async function postWechatMessage({ baseUrl, token, appId, openid, ghId, content, msgId, }) { const timestamp = String(Math.floor(Date.now() / 1000)); const nonce = crypto.randomBytes(8).toString('hex'); const signature = sha1([token, timestamp, nonce]); const xml = buildInboundXml({ fromUser: openid, toUser: ghId || appId, content, msgId, }); const url = `${baseUrl}/webhooks/wechat-mp/messages?signature=${signature}×tamp=${timestamp}&nonce=${nonce}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'text/xml; charset=utf-8' }, body: xml, }); const body = await response.text(); return { status: response.status, body: body.slice(0, 200) }; } async function fetchAssistantReply(pool, sessionId, sinceCreatedAt = 0) { if (!sessionId) return ''; const [rows] = await pool.query( `SELECT text FROM h5_conversation_messages WHERE agent_session_id = ? AND role = 'assistant' AND created_at >= ? ORDER BY sequence_no DESC LIMIT 10`, [sessionId, sinceCreatedAt], ); const texts = rows.map((row) => String(row.text ?? '').trim()).filter(Boolean); return texts.sort((a, b) => b.length - a.length)[0] ?? ''; } async function waitForMessageStatus(pool, { appId, openid, msgId }, timeoutMs) { const started = Date.now(); while (Date.now() - started < timeoutMs) { const [rows] = await pool.query( `SELECT status, agent_session_id, updated_at FROM h5_wechat_mp_messages WHERE app_id = ? AND openid = ? AND msg_id = ? LIMIT 1`, [appId, openid, msgId], ); const row = rows[0]; if (row && (row.status === 'done' || row.status === 'failed')) { const [details] = await pool.query( `SELECT display_text, agent_text FROM h5_wechat_mp_message_details WHERE app_id = ? AND openid = ? AND msg_id = ? ORDER BY created_at DESC LIMIT 1`, [appId, openid, msgId], ); const assistantReply = await fetchAssistantReply(pool, row.agent_session_id, row.created_at); return { ...row, displayText: details[0]?.display_text ?? '', agentText: details[0]?.agent_text ?? '', assistantReply, elapsedMs: Date.now() - started, }; } await new Promise((r) => setTimeout(r, 5000)); } return { status: 'timeout', agent_session_id: null, displayText: '', agentText: '', assistantReply: '', elapsedMs: timeoutMs, }; } function grepGoosedSessionLogs(sessionId) { if (!sessionId) return { tools: [], sandboxFsFailed: false, lines: [], logFile: null }; const logGlob = `${process.env.HOME}/Library/Logs/goosed-native-*.log`; let loadLine = ''; let logFile = ''; try { loadLine = execSync( `grep -H "Session loaded.*session_id: ${sessionId}" ${logGlob} 2>/dev/null | tail -1 || true`, { encoding: 'utf8' }, ).trim(); } catch { loadLine = ''; } if (loadLine.includes(':')) { logFile = loadLine.split(':')[0]; } let raw = ''; if (logFile) { try { raw = execSync( `awk '/Session loaded.*session_id: ${sessionId}/{found=1; next} found && /Session loaded/{exit} found' "${logFile}" | grep "tool_name:" || true`, { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }, ); } catch { raw = ''; } } const lines = raw.split('\n').filter(Boolean); const tools = [...new Set(lines.map((l) => l.match(/tool_name: ([^,]+)/)?.[1]).filter(Boolean))]; let sandboxFsFailed = false; if (logFile) { try { const failRaw = execSync( `awk '/Session loaded.*session_id: ${sessionId}/{found=1} found && /Failed to load extension sandbox-fs/{print; exit}' "${logFile}" || true`, { encoding: 'utf8' }, ); sandboxFsFailed = failRaw.trim().length > 0; } catch { sandboxFsFailed = false; } } return { tools, sandboxFsFailed, lines: lines.slice(-20), logFile: logFile || null }; } function parseArgs(argv) { let scenarioId = 'survey-page-data'; let listOnly = false; let runAll = false; let port = Number(process.env.H5_PORT ?? 8081); let openid = process.env.WECHAT_SCENARIO_OPENID ?? 'ooil-0VFj68QK1tkHl39uL610et8'; for (let i = 2; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--scenario' && argv[i + 1]) scenarioId = argv[++i]; else if (arg === '--list') listOnly = true; else if (arg === '--all') runAll = true; else if (arg === '--port' && argv[i + 1]) port = Number(argv[++i]); else if (arg === '--openid' && argv[i + 1]) openid = argv[++i]; else if (arg === '-h' || arg === '--help') { console.log(`Usage: node scripts/run-wechat-scenario-test.mjs [--scenario id] [--all] [--openid openid] [--list]`); process.exit(0); } else throw new Error(`未知参数: ${arg}`); } return { scenarioId, listOnly, runAll, port, openid }; } async function runScenario(scenario, ctx) { const msgId = `sim_${scenario.id}_${Date.now()}`; console.log(`\n==> 场景: ${scenario.name} (${scenario.id})`); console.log(` 消息: ${scenario.message.slice(0, 60)}…`); console.log(` msgId: ${msgId}`); const post = await postWechatMessage({ baseUrl: ctx.baseUrl, token: ctx.token, appId: ctx.appId, openid: ctx.openid, ghId: ctx.ghId, content: scenario.message, msgId, }); if (post.status !== 200) { console.error(`✘ webhook 返回 ${post.status}: ${post.body}`); return false; } console.log(`✔ webhook 已接收 (${post.status})`); const outcome = await waitForMessageStatus( ctx.pool, { appId: ctx.appId, openid: ctx.openid, msgId }, scenario.timeoutMs, ); console.log(` 状态: ${outcome.status}, session: ${outcome.agent_session_id ?? '-'}, ${Math.round(outcome.elapsedMs / 1000)}s`); const reply = String(outcome.assistantReply || outcome.displayText || outcome.agentText || ''); if (reply) console.log(` 回复摘要: ${reply.slice(0, 120).replace(/\s+/g, ' ')}…`); if (outcome.displayText && !outcome.assistantReply) { console.log(` (用户消息: ${String(outcome.displayText).slice(0, 60)}…)`); } const goosed = grepGoosedSessionLogs(outcome.agent_session_id); if (goosed.sandboxFsFailed) { console.error('✘ goosed: sandbox-fs 扩展加载失败'); } else if (goosed.tools.some((t) => t.startsWith('sandbox-fs__'))) { console.log(`✔ goosed 工具: ${goosed.tools.filter((t) => t.startsWith('sandbox-fs__')).join(', ')}`); } else { console.error(`✘ goosed 未使用 sandbox-fs 工具 (实际: ${goosed.tools.slice(0, 8).join(', ') || '无'})`); } let ok = outcome.status === 'done'; if (outcome.status === 'failed' || outcome.status === 'timeout') ok = false; for (const pattern of scenario.expect?.forbidReply ?? []) { if (reply.includes(pattern)) { console.error(`✘ 回复含禁止文案: ${pattern}`); ok = false; } } for (const kw of scenario.expect?.replyKeywords ?? []) { if (!reply.includes(kw)) { console.error(`✘ 回复缺少关键词: ${kw}`); ok = false; } } if (scenario.expect?.requireLink && !/https?:\/\/[^\s]+\/MindSpace\//.test(reply)) { console.error('✘ 回复未含 MindSpace 公网链接'); ok = false; } if (scenario.expect?.minReplyChars && reply.length < scenario.expect.minReplyChars) { console.error(`✘ 回复过短: ${reply.length}`); ok = false; } for (const toolPrefix of scenario.expect?.requireGoosedTools ?? []) { if (!goosed.tools.some((t) => t.startsWith(toolPrefix))) { console.error(`✘ goosed 缺少工具前缀: ${toolPrefix}`); ok = false; } } if (goosed.sandboxFsFailed) ok = false; console.log(ok ? '✔ 场景通过' : '✘ 场景失败'); return ok; } async function main() { const { scenarioId, listOnly, runAll, port, openid } = parseArgs(process.argv); if (listOnly) { for (const s of SCENARIOS) console.log(`${s.id}\t${s.name}`); return; } const token = process.env.H5_WECHAT_MP_TOKEN; const appId = process.env.H5_WECHAT_MP_APP_ID; if (!token || !appId) throw new Error('缺少 H5_WECHAT_MP_TOKEN / H5_WECHAT_MP_APP_ID'); const baseUrl = `http://127.0.0.1:${port}`; const status = await fetch(`${baseUrl}/api/status`); if (!status.ok) throw new Error(`Portal 未就绪: ${status.status}`); const pool = await mysql.createConnection({ uri: process.env.DATABASE_URL, connectTimeout: 10000 }); const ctx = { baseUrl, token, appId, openid, ghId: appId, pool }; const selected = runAll ? SCENARIOS : SCENARIOS.filter((s) => s.id === scenarioId); if (!selected.length) throw new Error(`未知场景: ${scenarioId}`); console.log(`Portal: ${baseUrl}, openid: ${openid.slice(0, 10)}…`); let passed = 0; for (const scenario of selected) { if (await runScenario(scenario, ctx)) passed += 1; } await pool.end(); console.log(`\n=== 汇总: ${passed}/${selected.length} 通过 ===`); process.exit(passed === selected.length ? 0 : 1); } main().catch((err) => { console.error(err); process.exit(1); });