#!/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'; import { loadH5Environment } from './load-env.mjs'; loadH5Environment(import.meta.dirname); 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 页面', resetSessionMessage: '换新会话', timeoutMs: Number(process.env.WECHAT_SCENARIO_POEM_PAGE_TIMEOUT_MS ?? 900_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 tools = new Set(); let sandboxFsFailed = false; const matchedLines = []; const v149Root = process.env.GOOSE_V149_RUNTIME_ROOT || '/tmp/goose-v149-runtime'; const today = new Date().toISOString().slice(0, 10); const v149LogDir = path.join(v149Root, 'state', 'goose', 'logs', 'cli', today); if (fs.existsSync(v149LogDir)) { for (const name of fs.readdirSync(v149LogDir)) { if (!name.endsWith('.log')) continue; const filePath = path.join(v149LogDir, name); let inSession = false; for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { if (!line.trim()) continue; let obj; try { obj = JSON.parse(line); } catch { continue; } const sid = obj?.fields?.session_id; if (sid === sessionId) inSession = true; if (inSession && sid && sid !== sessionId) inSession = false; const toolName = obj?.fields?.tool_name; if (inSession && toolName) { tools.add(toolName); matchedLines.push(`tool_name: ${toolName}`); } if (inSession && /Failed to load extension sandbox-fs|Transport closed/i.test(line)) { sandboxFsFailed = true; } } } } const logGlob = `${process.env.HOME}/Library/Logs/goosed-native-*.log`; let logFile = ''; try { const loadLine = execSync( `grep -H "Session loaded.*session_id: ${sessionId}" ${logGlob} 2>/dev/null | tail -1 || true`, { encoding: 'utf8' }, ).trim(); if (loadLine.includes(':')) logFile = loadLine.split(':')[0]; } catch { logFile = ''; } if (logFile) { try { const 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 }, ); for (const line of raw.split('\n').filter(Boolean)) { const toolName = line.match(/tool_name: ([^,]+)/)?.[1]; if (toolName) { tools.add(toolName); matchedLines.push(line); } } const failRaw = execSync( `awk '/Session loaded.*session_id: ${sessionId}/{found=1} found && /Failed to load extension sandbox-fs/{print; exit}' "${logFile}" || true`, { encoding: 'utf8' }, ); if (failRaw.trim()) sandboxFsFailed = true; } catch { // ignore legacy log parse errors } } return { tools: [...tools], sandboxFsFailed, lines: matchedLines.slice(-20), logFile: logFile || v149LogDir, }; } 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) { if (scenario.resetSessionMessage) { console.log(` 预步骤: ${scenario.resetSessionMessage}`); const resetMsgId = `sim_${scenario.id}_reset_${Date.now()}`; const resetPost = await postWechatMessage({ baseUrl: ctx.baseUrl, token: ctx.token, appId: ctx.appId, openid: ctx.openid, ghId: ctx.ghId, content: scenario.resetSessionMessage, msgId: resetMsgId, }); if (resetPost.status !== 200) { console.error(`✘ 预步骤 webhook 返回 ${resetPost.status}`); return false; } const resetOutcome = await waitForMessageStatus( ctx.pool, { appId: ctx.appId, openid: ctx.openid, msgId: resetMsgId }, 120_000, ); console.log(` 预步骤状态: ${resetOutcome.status}, session: ${resetOutcome.agent_session_id ?? '-'}`); if (resetOutcome.status !== 'done' && resetOutcome.status !== 'failed') { console.error('✘ 预步骤超时'); return false; } } 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 requiresGoosedTools = (scenario.expect?.requireGoosedTools ?? []).length > 0; const goosed = grepGoosedSessionLogs(outcome.agent_session_id); if (requiresGoosedTools) { 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 (requiresGoosedTools && 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(写入 .env.local,见 docs/local-dev.md §公众号 Agent 调试)', ); } 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); });