feat: sync portal runtime fixes for release

This commit is contained in:
john
2026-06-27 22:28:41 +08:00
parent 8264e71f9e
commit 4a9bc710f1
14 changed files with 795 additions and 130 deletions
+25 -88
View File
@@ -46,84 +46,6 @@ export function createDbPool() {
});
}
export function splitSqlStatements(sql) {
const statements = [];
let current = '';
let quote = null;
let lineComment = false;
let blockComment = false;
for (let i = 0; i < sql.length; i += 1) {
const char = sql[i];
const next = sql[i + 1];
if (lineComment) {
if (char === '\n') {
lineComment = false;
current += '\n';
}
continue;
}
if (blockComment) {
if (char === '*' && next === '/') {
blockComment = false;
i += 1;
}
continue;
}
if (quote) {
current += char;
if (char === '\\' && (quote === '\'' || quote === '"') && next) {
current += next;
i += 1;
continue;
}
if (char === quote) {
if (next === quote && quote !== '`') {
current += next;
i += 1;
} else {
quote = null;
}
}
continue;
}
if (char === '-' && next === '-') {
lineComment = true;
i += 1;
continue;
}
if (char === '/' && next === '*') {
blockComment = true;
i += 1;
continue;
}
if (char === '\'' || char === '"' || char === '`') {
quote = char;
current += char;
continue;
}
if (char === ';') {
const trimmed = current.trim();
if (trimmed) statements.push(trimmed);
current = '';
continue;
}
current += char;
}
const trimmed = current.trim();
if (trimmed) statements.push(trimmed);
return statements;
}
async function columnExists(pool, table, column) {
const [rows] = await pool.query(
`SELECT 1 FROM information_schema.COLUMNS
@@ -203,13 +125,6 @@ export async function migrateSchema(pool) {
}
}
if (!(await columnExists(pool, 'h5_session_snapshots', 'display_title'))) {
await pool.query(
`ALTER TABLE h5_session_snapshots
ADD COLUMN display_title VARCHAR(512) NOT NULL DEFAULT '' AFTER name`,
);
}
await pool.query(`UPDATE h5_users SET slug = username WHERE slug IS NULL OR slug = ''`);
if (!(await indexExists(pool, 'h5_users', 'uq_h5_users_slug'))) {
await pool.query(`ALTER TABLE h5_users ADD UNIQUE KEY uq_h5_users_slug (slug)`);
@@ -318,6 +233,7 @@ export async function migrateSchema(pool) {
`);
const publishColumns = [
['user_confirmed_at', 'BIGINT NULL AFTER expires_at'],
['plaza_view_count', 'BIGINT NOT NULL DEFAULT 0'],
['plaza_like_count', 'BIGINT NOT NULL DEFAULT 0'],
];
@@ -327,6 +243,13 @@ export async function migrateSchema(pool) {
}
}
if (!(await indexExists(pool, 'h5_publish_records', 'idx_h5_publish_auto_private'))) {
await pool.query(
`ALTER TABLE h5_publish_records
ADD KEY idx_h5_publish_auto_private (access_mode, status, user_confirmed_at, expires_at)`,
);
}
const plazaUserColumns = [
['plaza_post_count', 'INT UNSIGNED NOT NULL DEFAULT 0'],
['plaza_follower_count', 'INT UNSIGNED NOT NULL DEFAULT 0'],
@@ -367,6 +290,12 @@ export async function migrateSchema(pool) {
);
}
if (!(await columnExists(pool, 'h5_session_snapshots', 'display_title'))) {
await pool.query(
`ALTER TABLE h5_session_snapshots ADD COLUMN display_title VARCHAR(512) NOT NULL DEFAULT '' AFTER name`,
);
}
const oauthStateColumns = [
['intent', "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"],
['bind_user_id', 'CHAR(36) NULL AFTER intent'],
@@ -602,6 +531,7 @@ export async function migrateSchema(pool) {
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
name VARCHAR(512) NOT NULL DEFAULT '',
display_title VARCHAR(512) NOT NULL DEFAULT '',
working_dir VARCHAR(1024) NOT NULL DEFAULT '',
created_at_str VARCHAR(64) NOT NULL DEFAULT '',
updated_at_str VARCHAR(64) NOT NULL DEFAULT '',
@@ -673,9 +603,16 @@ export async function migrateSchema(pool) {
export async function initSchema(pool) {
const schemaPath = path.join(__dirname, 'schema.sql');
const sql = fs.readFileSync(schemaPath, 'utf8');
for (const statement of splitSqlStatements(sql)) {
await pool.query(statement);
const sql = fs
.readFileSync(schemaPath, 'utf8')
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n');
for (const statement of sql.split(';')) {
const trimmed = statement.trim();
if (trimmed) {
await pool.query(trimmed);
}
}
await migrateSchema(pool);
await ensureDefaultSpaces(pool, {
-22
View File
@@ -1,22 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { splitSqlStatements } from './db.mjs';
test('splitSqlStatements ignores semicolons in comments and strings', () => {
const statements = splitSqlStatements(`
-- comment with a semicolon; should not split
CREATE TABLE one (
label VARCHAR(64) DEFAULT 'semi;colon'
);
/* block; comment */
CREATE TABLE two (
id BIGINT PRIMARY KEY,
name VARCHAR(64) DEFAULT "two;name"
);
`);
assert.equal(statements.length, 2);
assert.match(statements[0], /^CREATE TABLE one/);
assert.match(statements[1], /^CREATE TABLE two/);
});
+1 -1
View File
@@ -265,7 +265,7 @@ test('completeUpload publishes public images to the public temp image library',
const asset = await service.completeUpload('user-1', upload.id);
assert.equal(asset.status, 'ready');
assert.match(asset.publicUrl, /^https:\/\/g2\.tkmind\.cn\/MindSpace\/user-1\/public\/\.tmp-images\/id-2\.png$/);
assert.match(asset.publicUrl, /^https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/\.tmp-images\/id-2\.png$/);
assert.equal(state.versions[0].storage_key, 'users/user-1/public/.tmp-images/id-2.png');
await assert.doesNotReject(() =>
fs.stat(path.join(storageRoot, 'users/user-1/public/.tmp-images/id-2.png')),
+3
View File
@@ -40,6 +40,9 @@ function resolveSandboxed(p) {
if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) {
throw Object.assign(new Error(`路径越界:${p} 不在当前工作区内`), { code: 'EACCES' });
}
if (resolved === PRIVATE_DATA_DIR || resolved.startsWith(PRIVATE_DATA_DIR + path.sep)) {
throw Object.assign(new Error('禁止直接访问私有数据目录,请使用 private_data_* 工具'), { code: 'EACCES' });
}
return resolved;
}
+2 -2
View File
@@ -233,7 +233,7 @@ CREATE TABLE IF NOT EXISTS h5_publish_records (
updated_at BIGINT NOT NULL,
KEY idx_h5_publish_route (url_slug, status, published_at),
KEY idx_h5_publish_page (user_id, page_id, published_at),
KEY idx_h5_publish_auto_private (expires_at, user_confirmed_at),
KEY idx_h5_publish_auto_private (access_mode, status, user_confirmed_at, expires_at),
UNIQUE KEY uq_h5_publish_token_hash (token_hash),
CONSTRAINT fk_h5_publish_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_publish_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE CASCADE,
@@ -1014,7 +1014,7 @@ CREATE TABLE IF NOT EXISTS h5_blocked_words (
-- Shared experience store (etat C): all goosed Worker instances read/write the
-- same learned experience so capability is not siloed per instance. Built on
-- MySQL first with a keyword + recency retrieval. `embedding` is reserved for a
-- MySQL first with a keyword + recency retrieval; `embedding` is reserved for a
-- later move to PostgreSQL + pgvector without changing the calling contract.
CREATE TABLE IF NOT EXISTS h5_experience (
id CHAR(36) PRIMARY KEY,
+15 -2
View File
@@ -3,12 +3,24 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
DEFAULT_MONITOR_SCRIPT="$ROOT/scripts/monitor-goosed-fds.mjs"
if [[ -x "/Users/john/Project/tkmind_go/scripts/monitor-goosed-fds.mjs" ]]; then
DEFAULT_MONITOR_SCRIPT="/Users/john/Project/tkmind_go/scripts/monitor-goosed-fds.mjs"
fi
MONITOR_SCRIPT="${GOOSED_MONITOR_SCRIPT:-$DEFAULT_MONITOR_SCRIPT}"
MONITOR_ROOT="$(cd "$(dirname "$MONITOR_SCRIPT")/.." && pwd)"
PLIST="$HOME/Library/LaunchAgents/cn.tkmind.goosed-monitor.plist"
LOG="$HOME/Library/Logs/goosed-monitor.log"
GUI="gui/$(id -u)"
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
if [[ ! -x "$MONITOR_SCRIPT" ]]; then
echo "monitor script not found or not executable: $MONITOR_SCRIPT" >&2
echo "Set GOOSED_MONITOR_SCRIPT to a stable goosed ops script path." >&2
exit 1
fi
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -19,10 +31,10 @@ cat > "$PLIST" <<EOF
<key>ProgramArguments</key>
<array>
<string>$NODE_BIN</string>
<string>$ROOT/scripts/monitor-goosed-fds.mjs</string>
<string>$MONITOR_SCRIPT</string>
</array>
<key>WorkingDirectory</key>
<string>$ROOT</string>
<string>$MONITOR_ROOT</string>
<key>StartInterval</key>
<integer>60</integer>
<key>RunAtLoad</key>
@@ -55,4 +67,5 @@ launchctl enable "$GUI/cn.tkmind.goosed-monitor"
launchctl kickstart -k "$GUI/cn.tkmind.goosed-monitor"
echo "installed $PLIST"
echo "script: $MONITOR_SCRIPT"
echo "log: $LOG"
+113
View File
@@ -80,7 +80,39 @@ need_cmd scp
need_cmd tar
need_cmd shasum
check_release_scope() {
local bypass="${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}"
if [[ "${bypass}" == "1" ]]; then
say "跳过发版范围检查(ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1"
return 0
fi
git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
local path
local blocked=()
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
case "${path}" in
docker/*|scripts/start-goosed-local.sh|scripts/verify-goosed-docker.sh|docs/goosed-*.md|server.mjs.bak*|*.bak)
blocked+=("${path}")
;;
docs/local-dev.md|README.md)
blocked+=("${path}")
;;
esac
done < <(git -C "${ROOT}" status --short --untracked-files=all | sed -E 's/^.. //')
if [[ "${#blocked[@]}" -gt 0 ]]; then
echo "检测到默认不应并入 103 Portal 发版的本地改动:" >&2
printf ' - %s\n' "${blocked[@]}" >&2
echo "请先清理这些改动,或确认本次需求明确包含这些模块后再重试。" >&2
echo "如确需绕过,可临时设置 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1。" >&2
exit 1
fi
}
say "本地预检查"
check_release_scope
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-ssh-ok" >/dev/null
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/.env'" >/dev/null
@@ -121,6 +153,87 @@ verify_runtime_artifact() {
verify_runtime_artifact
verify_remote_goosed_dependency() {
say "检查 103 goosed 依赖"
ssh -o BatchMode=yes "${HOST}" 'bash -s' <<'REMOTE'
set -euo pipefail
missing=0
docker_bin="/opt/homebrew/bin/docker"
if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/null | grep -q '^goosed-prod-1$'; then
for index in 1 2 3 4; do
container="goosed-prod-${index}"
host_port=$((18005 + index))
if ! "${docker_bin}" ps --format '{{.Names}} {{.Ports}} {{.Status}}' | grep -q "^${container} .*0.0.0.0:${host_port}->18006/tcp.*healthy"; then
echo "goosed dependency check failed: ${container} is not healthy on host port ${host_port}" >&2
missing=1
fi
if ! "${docker_bin}" exec "${container}" sh -lc 'test -x /usr/local/bin/node && test -f /opt/portal/mindspace-sandbox-mcp.mjs' >/dev/null 2>&1; then
echo "goosed dependency check failed: ${container} missing /usr/local/bin/node or /opt/portal/mindspace-sandbox-mcp.mjs" >&2
missing=1
fi
done
if [[ -f /Users/john/Project/Memind/.env ]]; then
portal_mcp_node="$(grep -E '^GOOSED_MCP_NODE_PATH=' /Users/john/Project/Memind/.env | tail -1 | cut -d= -f2- || true)"
portal_mcp_server="$(grep -E '^GOOSED_MCP_SERVER_PATH=' /Users/john/Project/Memind/.env | tail -1 | cut -d= -f2- || true)"
if [[ -z "${portal_mcp_node}" || -z "${portal_mcp_server}" ]]; then
echo "goosed dependency check failed: Portal .env must set GOOSED_MCP_NODE_PATH and GOOSED_MCP_SERVER_PATH for Docker goosed" >&2
missing=1
else
for index in 1 2 3 4; do
container="goosed-prod-${index}"
if ! "${docker_bin}" exec "${container}" sh -lc "test -x '${portal_mcp_node}' && test -f '${portal_mcp_server}'" >/dev/null 2>&1; then
echo "goosed dependency check failed: ${container} cannot resolve Portal .env MCP paths '${portal_mcp_node}' and '${portal_mcp_server}'" >&2
missing=1
fi
done
fi
fi
else
for port in 18006 18007; do
line="$(lsof -nP -iTCP:${port} -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1, $2, $9}')"
if [[ -z "${line}" ]]; then
echo "goosed dependency check failed: port ${port} has no listener" >&2
missing=1
continue
fi
command_name="${line%% *}"
case "${command_name}" in
goosed) ;;
*)
echo "goosed dependency check failed: port ${port} is listened by ${line}, not goosed" >&2
missing=1
;;
esac
done
if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18006'; then
echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18006 is not loaded" >&2
missing=1
fi
if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18007'; then
echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18007 is not loaded" >&2
missing=1
fi
if [[ ! -x /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh ]]; then
echo "goosed dependency check failed: missing /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh" >&2
missing=1
fi
if [[ ! -x /Users/john/Project/tkmind_go/target/release/goosed && ! -x /Users/john/Project/tkmind_go/target/debug/goosed ]]; then
echo "goosed dependency check failed: missing goosed binary under /Users/john/Project/tkmind_go/target" >&2
missing=1
fi
fi
exit "${missing}"
REMOTE
}
if [[ "${DRY_RUN}" -ne 1 ]]; then
verify_remote_goosed_dependency
fi
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
say "发布确认"
echo "目标主机: ${HOST}"
+1
View File
@@ -15,6 +15,7 @@ fi
export NODE_ENV=production
export H5_PORT="${H5_PORT:-8081}"
export H5_PUBLIC_BASE_URL="https://m.tkmind.cn"
export TKMIND_API_TARGETS="${TKMIND_API_TARGETS_OVERRIDE:-${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}}"
export TKMIND_API_TARGET="${TKMIND_API_TARGET_OVERRIDE:-https://127.0.0.1:18006}"
export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1_OVERRIDE:-https://127.0.0.1:18007}"
+1 -2
View File
@@ -2508,8 +2508,6 @@ api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => {
thumbnailReady = false;
}
}
const sessionId = String(req.body?.session_id ?? req.body?.sessionId ?? '').trim();
const messageId = String(req.body?.message_id ?? req.body?.messageId ?? '').trim();
const existingPage = await mindSpacePages.findPageBySourceMessage(
req.currentUser.id,
sessionId,
@@ -2546,6 +2544,7 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
console.log('[quick-share] bundle resolved, hasHtml:', Boolean(bundle.resolvedHtml));
if (!bundle.resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
+1 -1
View File
@@ -1982,7 +1982,7 @@ export function MindSpaceView({
{!agentJobsLoading && agentJobsList.length === 0 && (
<div className="mindspace-empty">
<h2> AI </h2>
<p> OA </p>
<p> OA AI </p>
</div>
)}
{!agentJobsLoading && agentJobsList.length > 0 && (
+5 -1
View File
@@ -1662,6 +1662,11 @@ export function createUserAuth(pool, options = {}) {
try {
const layout = await publishLayoutFor(user, { migrateLegacy: false });
sandboxMcp = {
// When goosed runs in a container its filesystem is split from the portal's,
// so the host paths the portal would otherwise send (node binary, MCP script)
// are not resolvable. These env overrides let the portal send container-canonical
// paths instead. Unset (e.g. local dev, co-located native goosed) => fall back to
// the portal's own paths, so behavior is unchanged.
serverPath: resolveSandboxMcpServerPath(process.env.GOOSED_MCP_SERVER_PATH),
sandboxRoot: layout.publishDir,
userId: user.id,
@@ -2709,7 +2714,6 @@ export function createUserAuth(pool, options = {}) {
policyCatalog: POLICY_CATALOG,
skillCatalog,
publicUser,
getUserById,
};
}
+1 -1
View File
@@ -57,7 +57,7 @@ test('publish dir and public url use stable user id', () => {
const skillPath = path.join(layout.publishDir, '.agents', 'skills', 'static-page-publish', 'SKILL.md');
assert.ok(fs.existsSync(skillPath));
const skillText = fs.readFileSync(skillPath, 'utf8');
assert.match(skillText, new RegExp(`g2\\.tkmind\\.cn/${PUBLISH_ROOT_DIR}/${USER_ID}/${PUBLIC_ZONE_DIR}/`));
assert.match(skillText, new RegExp(`m\\.tkmind\\.cn/${PUBLISH_ROOT_DIR}/${USER_ID}/${PUBLIC_ZONE_DIR}/`));
assert.match(skillText, /public\/report\.html/);
assert.match(skillText, /\[.*\]\(.*\)/);
assert.match(skillText, /mindspace-cover/);
+187 -10
View File
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fetch as undiciFetch } from 'undici';
import { developerToolsFromPolicy } from './capabilities.mjs';
import { mergeMessageContent } from './message-stream.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
@@ -23,6 +24,16 @@ const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agen
const DEFAULT_PROGRESS_DELAY_MS = 8000;
const PUBLIC_HTML_LINK_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
const SESSION_WORKSPACE_TOOL_NAMES = new Set([
'write',
'edit',
'tree',
'read_file',
'write_file',
'edit_file',
'create_dir',
'list_dir',
]);
function parseXmlField(xml, field) {
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${field}>`));
@@ -182,6 +193,7 @@ async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metad
const decoder = new TextDecoder();
let buffer = '';
let messages = [];
let hasScopedAssistantUpdate = false;
while (true) {
const { value, done } = await reader.read();
@@ -209,16 +221,26 @@ async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metad
if (hasActionRequired) {
throw new Error('当前回复需要人工确认,公众号通道暂不支持');
}
if (event.message.role === 'assistant') hasScopedAssistantUpdate = true;
messages = pushMessage(messages, event.message);
} else if (event.type === 'UpdateConversation') {
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
// Ignore unscoped snapshots until this request has yielded an assistant update.
// Otherwise a stale session snapshot can overwrite the current reply with a
// previous page/link from the same WeChat-dedicated session.
if (hasScopedAssistantUpdate) {
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
}
} else if (event.type === 'Error') {
throw new Error(event.error || '任务执行失败');
} else if (event.type === 'Finish') {
const assistant = [...messages].reverse().find((item) => item.role === 'assistant');
if (!hasScopedAssistantUpdate || !assistant) {
throw new Error('本轮未收到可发送的新回复,请稍后重试');
}
return {
text: messageVisibleText(assistant),
tokenState: event.token_state ?? null,
messages,
};
}
}
@@ -253,6 +275,117 @@ function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) {
export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES };
function flattenSessionTools(extensions = []) {
return new Set(
extensions.flatMap((extension) =>
Array.isArray(extension?.available_tools) ? extension.available_tools : [],
),
);
}
function needsWorkspaceTools(sessionPolicy) {
return developerToolsFromPolicy(sessionPolicy).some((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool));
}
async function readSessionExtensions(fetchForSession, sessionId) {
const payload = await readJsonResponse(await fetchForSession(sessionId, `/sessions/${sessionId}/extensions`));
return payload?.extensions ?? [];
}
async function sessionHasRequiredTools(fetchForSession, sessionId, sessionPolicy) {
if (!needsWorkspaceTools(sessionPolicy)) return true;
const desired = developerToolsFromPolicy(sessionPolicy).filter((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool));
if (desired.length === 0) return true;
const available = flattenSessionTools(await readSessionExtensions(fetchForSession, sessionId));
return desired.some((tool) => available.has(tool));
}
function extractHtmlWriteTargets(messages = []) {
const targets = new Set();
for (const message of messages) {
for (const item of message?.content ?? []) {
if (item?.type !== 'toolRequest') continue;
const toolCall = item.toolCall?.value;
const name = toolCall?.name;
const args = toolCall?.arguments ?? {};
const action = String(args.action ?? '').toLowerCase();
const writeLikeDeveloper = name === 'developer' && action === 'write';
const writeLikeSandbox = (name === 'write_file' || name === 'edit_file') && typeof args.path === 'string';
if (!writeLikeDeveloper && !writeLikeSandbox) continue;
const candidate = String(args.path ?? '').trim();
if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate);
}
}
return [...targets];
}
function looksLikeHtmlGenerationIntent(text) {
const normalized = String(text ?? '').trim().toLowerCase();
if (!normalized) return false;
return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized);
}
function isBareCompletionText(text) {
const normalized = String(text ?? '').trim();
return /^(?:已完成|完成了|完成)$/u.test(normalized);
}
function hasAnyToolRequest(messages = []) {
return messages.some((message) =>
message?.content?.some((item) => item?.type === 'toolRequest'),
);
}
function isSuspiciousBareCompletionReply(reply, intent) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
if (!isBareCompletionText(reply?.text)) return false;
if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false;
return !hasAnyToolRequest(reply?.messages ?? []);
}
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
const owner = path.basename(path.resolve(workingDir));
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
return `${String(publicBaseUrl ?? '').replace(/\/+$/, '')}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(owner)}/${normalized}`;
}
function ensurePublicHtmlArtifact(htmlPath, workingDir) {
const workspaceRoot = path.resolve(workingDir);
const source = path.resolve(htmlPath);
if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null;
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null;
const publicRoot = path.join(workspaceRoot, 'public');
let publishedPath = source;
if (source !== publicRoot && !source.startsWith(`${publicRoot}${path.sep}`)) {
fs.mkdirSync(publicRoot, { recursive: true });
publishedPath = path.join(publicRoot, path.basename(source));
if (publishedPath !== source) fs.copyFileSync(source, publishedPath);
}
const relativePath = path.relative(workspaceRoot, publishedPath);
if (!relativePath || relativePath.startsWith('..')) return null;
return {
localPath: publishedPath,
relativePath,
};
}
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) {
const baseText = String(reply?.text ?? '').trim();
const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []);
for (const target of htmlTargets) {
const artifact = ensurePublicHtmlArtifact(target, workingDir);
if (!artifact) continue;
const url = buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl);
if (baseText.includes(url)) return baseText;
return baseText
? `${baseText}\n\n查看页面:\n${url}`
: `查看页面:\n${url}`;
}
return baseText;
}
function stripInternalWechatUsername(text) {
return String(text ?? '').replace(/^\s*wx_[a-z0-9_]{4,64}\s*[,,、:]\s*/i, '');
}
@@ -387,6 +520,8 @@ export async function guardMissingPublicHtmlLinks(
return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value);
}
export { maybeAttachPublishedHtmlLink };
function isQuestionStatusProbe(text) {
return /^[?]+$/.test(String(text ?? '').trim());
}
@@ -1040,19 +1175,47 @@ export function createWechatMpService({
if (forceNew) {
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
const workingDir = await userAuth.resolveWorkingDir(userId);
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
const publishLayout = await userAuth.getUserPublishLayout(userId);
const addressName = resolveWechatAddressName(userContext);
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
if (existingRoute?.agentSessionId) {
return existingRoute.agentSessionId;
await reconcileAgentSession(
(pathname, init) => fetchForSession(existingRoute.agentSessionId, pathname, init),
existingRoute.agentSessionId,
{
workingDir,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
userContext: publishLayout
? {
userId,
displayName: addressName || publishLayout.displayName,
username: addressName || null,
slug: null,
}
: null,
tolerateInvalidWorkingDir: true,
},
);
const routeHasTools = await sessionHasRequiredTools(
fetchForSession,
existingRoute.agentSessionId,
sessionPolicy,
).catch(() => false);
if (routeHasTools) {
return existingRoute.agentSessionId;
}
await userAuth.clearWechatAgentRoute(config.appId, openid);
} else if (existingRoute?.status === 'disabled') {
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
const gate = await userAuth.canUseChat(userId);
if (!gate.ok) {
throw new Error(gate.message || '当前用户无法使用聊天能力');
}
const workingDir = await userAuth.resolveWorkingDir(userId);
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
const publishLayout = await userAuth.getUserPublishLayout(userId);
const started = await readJsonResponse(
await apiFetch('/agent/start', {
method: 'POST',
@@ -1073,7 +1236,6 @@ export function createWechatMpService({
// `/agent/start` already persists the owning user and goosed node via the
// portal proxy. Re-registering here without the node can overwrite the
// correct mapping back to node 0 in multi-goosed production.
const addressName = resolveWechatAddressName(userContext);
await reconcileAgentSession(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
@@ -1175,6 +1337,7 @@ export function createWechatMpService({
userContext: user,
forceNew,
});
const publishLayout = await userAuth.getUserPublishLayout(user.userId);
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user);
let finished = false;
@@ -1220,15 +1383,22 @@ export function createWechatMpService({
buildWechatAgentPrompt(intent),
buildIntentMetadata(intent),
);
if (isSuspiciousBareCompletionReply(reply, intent)) {
throw new Error('stale_session_poisoned_completion');
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
}
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user);
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
publicBaseUrl: config.publicBaseUrl,
});
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
return { sessionId };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const mayBeStaleSession =
/403|404|not found|无权访问|session/i.test(message) && sessionId;
/403|404|not found|无权访问|session|stale_session_poisoned_completion/i.test(message) && sessionId;
if (mayBeStaleSession) {
sessionId = await ensureWechatAgentSession({
userId: user.userId,
@@ -1246,10 +1416,17 @@ export function createWechatMpService({
buildWechatAgentPrompt(intent),
buildIntentMetadata(intent),
);
if (isSuspiciousBareCompletionReply(reply, intent)) {
throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试');
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
}
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user);
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
publicBaseUrl: config.publicBaseUrl,
});
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
return { sessionId };
}
throw err;
+440
View File
@@ -7,6 +7,7 @@ import {
createWechatMpService,
guardMissingPublicHtmlLinks,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
splitWechatText,
verifyWechatMpSignature,
verifyWechatMpUrlChallenge,
@@ -136,6 +137,48 @@ test('guardMissingPublicHtmlLinks blocks missing MindSpace public html links', a
assert.match(guarded, /missing\.html/);
});
test('maybeAttachPublishedHtmlLink copies generated root html into public and returns link', async (t) => {
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-public-');
const htmlPath = `${workspaceRoot}/hello.html`;
fs.writeFileSync(htmlPath, '<!doctype html><title>Hello</title>');
const text = await maybeAttachPublishedHtmlLink(
{
text: '已完成',
messages: [
{
role: 'assistant',
content: [
{
type: 'toolRequest',
toolCall: {
value: {
name: 'developer',
arguments: {
action: 'write',
path: htmlPath,
content: '<!doctype html><title>Hello</title>',
},
},
},
},
],
},
],
},
{
workingDir: workspaceRoot,
publicBaseUrl: 'https://m.tkmind.cn',
},
);
assert.equal(fs.existsSync(`${workspaceRoot}/public/hello.html`), true);
assert.match(
text,
/https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/hello\.html/,
);
});
test('wechat mp service splits long agent replies into multiple customer messages', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -367,6 +410,70 @@ test('wechat mp service strips markdown emphasis around outbound links', async (
assert.match(payload.text.content, /https:\/\/example\.com\/running-route\.html/);
});
test('wechat mp service does not send stale page links from unscoped conversation snapshots', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const wechatCalls = [];
const service = createBoundWechatService({
sessionApiFetch: async (sessionId, pathname) => {
assert.equal(sessionId, 'session-1');
if (pathname === '/sessions/session-1/events') {
return new Response(
[
'data: {"type":"UpdateConversation","conversation":[{"id":"user-old","role":"user","metadata":{"userVisible":true},"content":[{"type":"text","text":"帮我做餐厅推荐"}]},{"id":"assistant-old","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"页面已生成!餐厅推荐\\nhttps://g2.tkmind.cn/MindSpace/old/public/restaurant.html"}]}]}\n\n',
'data: {"type":"Finish","request_id":"req-stale","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-1/reply') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected api path: ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-stale';
try {
const result = await service.handleInboundMessage(inboundXml({ content: '帮我做上马计划' }), {
timestamp,
nonce,
signature: signatureFor(token, timestamp, nonce),
});
assert.equal(result.status, 200);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
const sendCalls = wechatCalls.filter(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
assert.equal(sendCalls.length, 1);
const payload = JSON.parse(sendCalls[0][2]);
assert.doesNotMatch(payload.text.content, /restaurant\.html/);
assert.match(payload.text.content, /本轮未收到可发送的新回复/);
});
test('loadWechatMpConfig requires full config and enable flag', () => {
const config = loadWechatMpConfig({
H5_WECHAT_MP_ENABLED: '1',
@@ -873,6 +980,339 @@ test('wechat mp service injects schedule skill instructions for reminder-like re
}
});
test('wechat mp service reconciles existing dedicated session before reply', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const calls = [];
let extensionsReadCount = 0;
const workingDir = '/Users/john/Project/Memind/MindSpace/user-1';
const extensionConfig = {
type: 'stdio',
name: 'sandbox-fs',
cmd: '/usr/local/bin/node',
args: ['/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs', workingDir],
envs: { SANDBOX_ROOT: workingDir },
available_tools: ['list_dir'],
};
const service = createWechatMpService({
config: {
enabled: true,
appId: 'wx123',
appSecret: 'secret',
token,
publicBaseUrl: 'https://example.com',
bindPath: '/auth/wechat/authorize?intent=login',
ackText: 'ack',
unsupportedText: 'unsupported',
unboundTextPrefix: '请先绑定',
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: 'user-1', status: 'active', nickname: '毕升' };
},
async getWechatAgentRoute() {
return { agentSessionId: 'session-1' };
},
async clearWechatAgentRoute() {},
async canUseChat() {
return { ok: true };
},
async resolveWorkingDir() {
return workingDir;
},
async getAgentSessionPolicy() {
return {
enableContextMemory: false,
extensionOverrides: [extensionConfig],
unrestricted: false,
gooseMode: 'auto',
};
},
async getUserPublishLayout() {
return { displayName: 'John', username: 'john', slug: 'john', constraints: 'base' };
},
async registerAgentSession() {},
async upsertWechatAgentRoute() {},
async billSessionUsage() {},
},
apiFetch: async (pathname, init = {}) => {
calls.push(['fallback', pathname, init.method ?? 'GET']);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
},
sessionApiFetch: async (sessionId, pathname, init = {}) => {
assert.equal(sessionId, 'session-1');
calls.push([pathname, init.method ?? 'GET']);
if (pathname === '/sessions/session-1') {
return new Response(JSON.stringify({ working_dir: '/root/tkmind_go/ui/h5/MindSpace/user-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === '/agent/update_working_dir') {
const body = JSON.parse(init.body);
assert.equal(body.session_id, 'session-1');
assert.equal(body.working_dir, workingDir);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/update_session') {
const body = JSON.parse(init.body);
assert.equal(body.session_id, 'session-1');
assert.equal(body.goose_mode, 'auto');
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/sessions/session-1/extensions') {
extensionsReadCount += 1;
const extensions =
extensionsReadCount > 1
? [{ name: 'sandbox-fs', available_tools: ['list_dir'] }]
: [];
return new Response(JSON.stringify({ extensions }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === '/agent/add_extension') {
const body = JSON.parse(init.body);
assert.equal(body.session_id, 'session-1');
assert.equal(body.config.name, 'sandbox-fs');
assert.equal(body.config.args[1], workingDir);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/restart') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/sessions/session-1/events') {
return new Response(
[
'data: {"type":"Message","request_id":"req-1","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"收到"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-1","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-1/reply') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected api path: ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-1';
try {
const result = await service.handleInboundMessage(inboundXml({ content: '继续' }), {
timestamp,
nonce,
signature: signatureFor(token, timestamp, nonce),
});
assert.equal(result.status, 200);
await result.task;
assert.equal(calls.some(([pathname]) => pathname === '/agent/update_working_dir'), true);
assert.equal(calls.some(([pathname]) => pathname === '/agent/add_extension'), true);
assert.equal(calls.some(([pathname]) => pathname === '/sessions/session-1/reply'), true);
} finally {
crypto.randomUUID = originalRandomUuid;
}
});
test('wechat mp service recreates poisoned dedicated session after bare completion on html generation', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const wechatCalls = [];
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-poisoned-session-');
const htmlPath = `${workspaceRoot}/hello.html`;
let routeCleared = false;
let started = false;
const service = createWechatMpService({
config: {
enabled: true,
appId: 'wx123',
appSecret: 'secret',
token,
publicBaseUrl: 'https://m.tkmind.cn',
bindPath: '/auth/wechat/authorize?intent=login',
ackText: 'ack',
unsupportedText: 'unsupported',
unboundTextPrefix: '请先绑定',
progressDelayMs: 0,
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: 'user-1', status: 'active', nickname: '毕升' };
},
async getWechatAgentRoute() {
return routeCleared ? null : { agentSessionId: 'session-1' };
},
async clearWechatAgentRoute() {
routeCleared = true;
},
async canUseChat() {
return { ok: true };
},
async resolveWorkingDir() {
return workspaceRoot;
},
async getAgentSessionPolicy() {
return {
enableContextMemory: false,
extensionOverrides: [
{
type: 'platform',
name: 'developer',
available_tools: ['write'],
},
],
unrestricted: false,
};
},
async getUserPublishLayout() {
return { publishDir: workspaceRoot, displayName: 'John', username: 'john', slug: 'john', constraints: null };
},
async registerAgentSession() {},
async upsertWechatAgentRoute({ appId, openid, userId, agentSessionId }) {
assert.equal(appId, 'wx123');
assert.equal(openid, 'openid-1');
assert.equal(userId, 'user-1');
assert.equal(agentSessionId, 'session-2');
},
async billSessionUsage() {},
async recordWechatMpMessage() {
return { inserted: true };
},
async finishWechatMpMessage() {},
async insertWechatMpMessageDetail() {},
},
apiFetch: async (pathname, init = {}) => {
if (pathname === '/agent/start') {
started = true;
const body = JSON.parse(init.body);
assert.equal(body.working_dir, workspaceRoot);
return new Response(JSON.stringify({ id: 'session-2' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected api path: ${pathname}`);
},
sessionApiFetch: async (sessionId, pathname, init = {}) => {
if (pathname === `/sessions/${sessionId}/extensions`) {
return new Response(JSON.stringify({ extensions: [{ name: 'developer', available_tools: ['write'] }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === '/agent/update_working_dir' || pathname === '/agent/update_session' || pathname === '/agent/restart') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/add_extension') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === `/sessions/${sessionId}`) {
return new Response(JSON.stringify({ working_dir: workspaceRoot }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === `/sessions/${sessionId}/reply`) {
if (sessionId === 'session-2') {
fs.writeFileSync(htmlPath, '<!doctype html><title>Hello</title>');
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === `/sessions/${sessionId}/events`) {
if (sessionId === 'session-1') {
return new Response(
[
'data: {"type":"Message","request_id":"req-poisoned","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已完成"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-poisoned","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
return new Response(
[
`data: {"type":"Message","request_id":"req-poisoned-retry","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"developer","arguments":{"action":"write","path":"${htmlPath.replace(/\\/g, '\\\\')}","content":"<!doctype html><title>Hello</title>"}}}}]}}\n\n`,
'data: {"type":"Message","request_id":"req-poisoned-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已完成"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-poisoned-retry","token_state":{"inputTokens":2,"outputTokens":2}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = (() => {
const ids = ['req-poisoned', 'req-poisoned-retry'];
return () => ids.shift() ?? 'req-poisoned-retry';
})();
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '帮我生成一个简单的 hello 页面吧' }),
{
timestamp,
nonce,
signature: signatureFor(token, timestamp, nonce),
},
);
assert.equal(result.status, 200);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.equal(routeCleared, true);
assert.equal(started, true);
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
const payload = JSON.parse(sendCall[2]);
assert.match(payload.text.content, /已完成/);
assert.match(payload.text.content, /页面生成未完成|hello\.html/);
});
test('wechat mp service blocks schedule confirmation when no schedule item was written', async () => {
const token = 'token';
const timestamp = '1710000000';