fix(wechat): deliver failure notices and hot-swappable wechat-mp bundle
Notify users when HTML publish guards reject stub pages instead of silently failing, send real page links when non-stub files exist, and split wechat-mp into wechat-mp.bundle.mjs for fast production updates without full portal rebuilds. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -369,6 +369,7 @@ async function writeMetadata() {
|
|||||||
'',
|
'',
|
||||||
'Bundled alongside server.mjs (required for sandbox-fs MCP):',
|
'Bundled alongside server.mjs (required for sandbox-fs MCP):',
|
||||||
' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)',
|
' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)',
|
||||||
|
' wechat-mp.bundle.mjs (esbuild bundle; hot-swappable WeChat MP module)',
|
||||||
'',
|
'',
|
||||||
'Post-deploy validation (scripts/check-mindspace-public-links.mjs):',
|
'Post-deploy validation (scripts/check-mindspace-public-links.mjs):',
|
||||||
' Scans MindSpace/*/public/*.html for missing relative href/src/cover targets.',
|
' Scans MindSpace/*/public/*.html for missing relative href/src/cover targets.',
|
||||||
@@ -423,10 +424,16 @@ async function writeMetadata() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bundleWechatMp() {
|
||||||
|
console.log('==> 打包 WeChat MP runtime bundle');
|
||||||
|
await run('node', ['scripts/build-wechat-mp-runtime.mjs']);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await buildFrontend();
|
await buildFrontend();
|
||||||
await copyRuntimeAssets();
|
await copyRuntimeAssets();
|
||||||
await bundleServer();
|
await bundleServer();
|
||||||
|
await bundleWechatMp();
|
||||||
await bundleSandboxMcp();
|
await bundleSandboxMcp();
|
||||||
await bundleAgentRunWorker();
|
await bundleAgentRunWorker();
|
||||||
if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) {
|
if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
const runtimeRoot = path.join(root, '.runtime', 'portal');
|
||||||
|
const esbuildBin = path.join(root, 'node_modules', '.pnpm', 'node_modules', '.bin', 'esbuild');
|
||||||
|
const runtimeNodeTarget = process.env.PORTAL_RUNTIME_NODE_TARGET || 'node24';
|
||||||
|
const outputArg = process.argv.find((arg) => arg.startsWith('--outfile='));
|
||||||
|
const outfile = outputArg
|
||||||
|
? outputArg.slice('--outfile='.length)
|
||||||
|
: path.join(runtimeRoot, 'wechat-mp.bundle.mjs');
|
||||||
|
|
||||||
|
const externalPackages = [
|
||||||
|
'undici',
|
||||||
|
'mysql2',
|
||||||
|
'mysql2/promise',
|
||||||
|
'sharp',
|
||||||
|
'fsevents',
|
||||||
|
];
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, { cwd: root, stdio: 'inherit', env: process.env });
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 1}`));
|
||||||
|
});
|
||||||
|
child.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await fs.mkdir(path.dirname(outfile), { recursive: true });
|
||||||
|
const args = [
|
||||||
|
'wechat-mp.mjs',
|
||||||
|
'--bundle',
|
||||||
|
'--platform=node',
|
||||||
|
'--format=esm',
|
||||||
|
`--target=${runtimeNodeTarget}`,
|
||||||
|
`--outfile=${outfile}`,
|
||||||
|
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||||
|
];
|
||||||
|
for (const pkg of externalPackages) {
|
||||||
|
args.push(`--external:${pkg}`);
|
||||||
|
}
|
||||||
|
await run(esbuildBin, args);
|
||||||
|
console.log(`WeChat MP runtime bundle written: ${outfile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err instanceof Error ? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
HOST="${MEMIND_PROD_HOST:-john@58.38.22.103}"
|
||||||
|
REMOTE_ROOT="${MEMIND_PROD_ROOT:-/Users/john/Project/Memind}"
|
||||||
|
BUNDLE=".runtime/portal/wechat-mp.bundle.mjs"
|
||||||
|
|
||||||
|
echo "[deploy-wechat-mp] build bundle"
|
||||||
|
node scripts/build-wechat-mp-runtime.mjs
|
||||||
|
|
||||||
|
if [[ ! -f "${BUNDLE}" ]]; then
|
||||||
|
echo "missing bundle: ${BUNDLE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[deploy-wechat-mp] upload ${BUNDLE} -> ${HOST}:${REMOTE_ROOT}/wechat-mp.bundle.mjs"
|
||||||
|
scp "${BUNDLE}" "${HOST}:${REMOTE_ROOT}/wechat-mp.bundle.mjs"
|
||||||
|
|
||||||
|
echo "[deploy-wechat-mp] restart portal"
|
||||||
|
ssh "${HOST}" 'launchctl kickstart -k "gui/$(id -u)/cn.tkmind.memind-portal"'
|
||||||
|
|
||||||
|
echo "[deploy-wechat-mp] health check"
|
||||||
|
ssh "${HOST}" 'curl -s -o /dev/null -w "portal=%{http_code}\n" http://127.0.0.1:8081/api/status'
|
||||||
|
|
||||||
|
echo "[deploy-wechat-mp] done"
|
||||||
@@ -151,7 +151,7 @@ fi
|
|||||||
|
|
||||||
verify_runtime_artifact() {
|
verify_runtime_artifact() {
|
||||||
local missing=0
|
local missing=0
|
||||||
for required in server.mjs mindspace-sandbox-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
for required in server.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
||||||
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
||||||
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
||||||
missing=1
|
missing=1
|
||||||
|
|||||||
+4
-2
@@ -157,7 +157,8 @@ import {
|
|||||||
WECHAT_NOTIFY_SUCCESS_V2,
|
WECHAT_NOTIFY_SUCCESS_V2,
|
||||||
} from './wechat-pay.mjs';
|
} from './wechat-pay.mjs';
|
||||||
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
|
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
|
||||||
import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs';
|
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
|
||||||
|
import { loadWechatMpModule } from './wechat-mp-loader.mjs';
|
||||||
import { validateWechatShareSignatureUrl } from './wechat-share.mjs';
|
import { validateWechatShareSignatureUrl } from './wechat-share.mjs';
|
||||||
import { createScheduleService } from './schedule-service.mjs';
|
import { createScheduleService } from './schedule-service.mjs';
|
||||||
import { createFeedbackService } from './user-feedback.mjs';
|
import { createFeedbackService } from './user-feedback.mjs';
|
||||||
@@ -559,7 +560,8 @@ async function bootstrapUserAuth() {
|
|||||||
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
|
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
|
||||||
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000),
|
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000),
|
||||||
});
|
});
|
||||||
wechatMpService = createWechatMpService({
|
const wechatMp = await loadWechatMpModule(__dirname);
|
||||||
|
wechatMpService = wechatMp.createWechatMpService({
|
||||||
config: WECHAT_MP_CONFIG,
|
config: WECHAT_MP_CONFIG,
|
||||||
userAuth,
|
userAuth,
|
||||||
apiFetch: tkmindProxy.apiFetch,
|
apiFetch: tkmindProxy.apiFetch,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
const DEFAULT_ACK_TEXT = '已收到,正在处理,完成后发到这里。';
|
||||||
|
const DEFAULT_PROGRESS_TEXT = '还在处理,请稍等片刻。';
|
||||||
|
const DEFAULT_STATUS_TEXT =
|
||||||
|
'我在这边。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。';
|
||||||
|
const DEFAULT_UNSUPPORTED_TEXT = '当前先支持文字消息,你可以直接发文字给我。';
|
||||||
|
const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agent 对话:';
|
||||||
|
const DEFAULT_PROGRESS_DELAY_MS = 8000;
|
||||||
|
const DEFAULT_SESSION_IDLE_ROTATE_MS = 30 * 60 * 1000;
|
||||||
|
const DEFAULT_SESSION_MESSAGE_ROTATE_COUNT = 200;
|
||||||
|
const DEFAULT_WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
|
||||||
|
const DEFAULT_WECHAT_CUSTOMER_SERVICE_URL =
|
||||||
|
'https://api.weixin.qq.com/cgi-bin/message/custom/send';
|
||||||
|
const DEFAULT_WECHAT_JSAPI_TICKET_URL = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket';
|
||||||
|
|
||||||
|
function deriveWechatEndpointFromUrl(baseUrl, suffix) {
|
||||||
|
const normalized = String(baseUrl ?? '').trim();
|
||||||
|
if (!normalized) return '';
|
||||||
|
try {
|
||||||
|
const url = new URL(normalized);
|
||||||
|
return `${url.origin}${suffix}`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWechatMpConfig(env = process.env) {
|
||||||
|
const appId = env.H5_WECHAT_MP_APP_ID?.trim() ?? env.H5_WECHAT_APP_ID?.trim() ?? '';
|
||||||
|
const appSecret =
|
||||||
|
env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? '';
|
||||||
|
const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? '';
|
||||||
|
const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? '';
|
||||||
|
const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1';
|
||||||
|
const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login';
|
||||||
|
return {
|
||||||
|
enabled: enabledFlag && Boolean(appId && appSecret && token && publicBaseUrl),
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
token,
|
||||||
|
publicBaseUrl,
|
||||||
|
bindPath,
|
||||||
|
ackText: env.H5_WECHAT_MP_ACK_TEXT?.trim() || DEFAULT_ACK_TEXT,
|
||||||
|
ackRandomEnabled: env.H5_WECHAT_MP_ACK_RANDOM !== '0',
|
||||||
|
ackNicknameEnabled: env.H5_WECHAT_MP_ACK_NICKNAME !== '0',
|
||||||
|
progressText: env.H5_WECHAT_MP_PROGRESS_TEXT?.trim() || DEFAULT_PROGRESS_TEXT,
|
||||||
|
progressDelayMs: Math.max(
|
||||||
|
0,
|
||||||
|
Number(env.H5_WECHAT_MP_PROGRESS_DELAY_MS ?? DEFAULT_PROGRESS_DELAY_MS),
|
||||||
|
),
|
||||||
|
sessionIdleRotateMs: Math.max(
|
||||||
|
0,
|
||||||
|
Number(env.H5_WECHAT_MP_SESSION_IDLE_ROTATE_MS ?? DEFAULT_SESSION_IDLE_ROTATE_MS),
|
||||||
|
),
|
||||||
|
sessionMessageRotateCount: Math.max(
|
||||||
|
0,
|
||||||
|
Number(env.H5_WECHAT_MP_SESSION_MESSAGE_ROTATE_COUNT ?? DEFAULT_SESSION_MESSAGE_ROTATE_COUNT),
|
||||||
|
),
|
||||||
|
statusText: env.H5_WECHAT_MP_STATUS_TEXT?.trim() || DEFAULT_STATUS_TEXT,
|
||||||
|
unsupportedText: env.H5_WECHAT_MP_UNSUPPORTED_TEXT?.trim() || DEFAULT_UNSUPPORTED_TEXT,
|
||||||
|
unboundTextPrefix: env.H5_WECHAT_MP_UNBOUND_TEXT_PREFIX?.trim() || DEFAULT_UNBOUND_TEXT,
|
||||||
|
tokenUrl: env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_WECHAT_TOKEN_URL,
|
||||||
|
customerServiceUrl:
|
||||||
|
env.H5_WECHAT_MP_CUSTOMER_SERVICE_URL?.trim() || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
||||||
|
jsapiTicketUrl:
|
||||||
|
env.H5_WECHAT_MP_JSAPI_TICKET_URL?.trim() ||
|
||||||
|
deriveWechatEndpointFromUrl(env.H5_WECHAT_MP_TOKEN_URL?.trim(), '/cgi-bin/ticket/getticket') ||
|
||||||
|
DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
||||||
|
mediaPublicBaseUrl:
|
||||||
|
env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') || publicBaseUrl,
|
||||||
|
maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)),
|
||||||
|
acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0',
|
||||||
|
acceptImage: env.H5_WECHAT_MP_ACCEPT_IMAGE !== '0',
|
||||||
|
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
||||||
|
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
|
||||||
|
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
|
||||||
|
|
||||||
|
export { loadWechatMpConfig };
|
||||||
|
|
||||||
|
let cachedModule = null;
|
||||||
|
let cachedTarget = '';
|
||||||
|
let cachedMtime = 0;
|
||||||
|
|
||||||
|
function resolveWechatMpEntry(root = process.cwd()) {
|
||||||
|
const bundlePath = path.join(root, 'wechat-mp.bundle.mjs');
|
||||||
|
if (fs.existsSync(bundlePath)) return bundlePath;
|
||||||
|
return path.join(root, 'wechat-mp.mjs');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWechatMpModule(root = process.cwd()) {
|
||||||
|
const target = resolveWechatMpEntry(root);
|
||||||
|
const mtime = fs.statSync(target).mtimeMs;
|
||||||
|
if (cachedModule && cachedTarget === target && cachedMtime === mtime) {
|
||||||
|
return cachedModule;
|
||||||
|
}
|
||||||
|
cachedModule = await import(`${pathToFileURL(target).href}?v=${mtime}`);
|
||||||
|
cachedTarget = target;
|
||||||
|
cachedMtime = mtime;
|
||||||
|
return cachedModule;
|
||||||
|
}
|
||||||
+122
-109
@@ -6,7 +6,8 @@ import { developerToolsFromPolicy } from './capabilities.mjs';
|
|||||||
import { mergeMessageContent } from './message-stream.mjs';
|
import { mergeMessageContent } from './message-stream.mjs';
|
||||||
import { reconcileAgentSession } from './session-reconcile.mjs';
|
import { reconcileAgentSession } from './session-reconcile.mjs';
|
||||||
import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
|
import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
|
||||||
import { materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs';
|
import { isStubPublicHtmlContent, materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs';
|
||||||
|
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
|
||||||
import { buildCurrentTimeAgentPrefix } from './user-memory-profile.mjs';
|
import { buildCurrentTimeAgentPrefix } from './user-memory-profile.mjs';
|
||||||
import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
||||||
import { buildAutoChatSkillPrefix } from './chat-skills.mjs';
|
import { buildAutoChatSkillPrefix } from './chat-skills.mjs';
|
||||||
@@ -18,15 +19,7 @@ const DEFAULT_WECHAT_CUSTOMER_SERVICE_URL =
|
|||||||
'https://api.weixin.qq.com/cgi-bin/message/custom/send';
|
'https://api.weixin.qq.com/cgi-bin/message/custom/send';
|
||||||
const DEFAULT_WECHAT_JSAPI_TICKET_URL = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket';
|
const DEFAULT_WECHAT_JSAPI_TICKET_URL = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket';
|
||||||
const DEFAULT_ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn';
|
const DEFAULT_ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn';
|
||||||
const DEFAULT_ACK_TEXT = '已收到,正在处理,完成后发到这里。';
|
export { loadWechatMpConfig };
|
||||||
const DEFAULT_PROGRESS_TEXT = '还在处理,请稍等片刻。';
|
|
||||||
const DEFAULT_STATUS_TEXT =
|
|
||||||
'我在这边。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。';
|
|
||||||
const DEFAULT_UNSUPPORTED_TEXT = '当前先支持文字消息,你可以直接发文字给我。';
|
|
||||||
const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agent 对话:';
|
|
||||||
const DEFAULT_PROGRESS_DELAY_MS = 8000;
|
|
||||||
const DEFAULT_SESSION_IDLE_ROTATE_MS = 30 * 60 * 1000;
|
|
||||||
const DEFAULT_SESSION_MESSAGE_ROTATE_COUNT = 200;
|
|
||||||
const PUBLIC_HTML_LINK_PATTERN =
|
const PUBLIC_HTML_LINK_PATTERN =
|
||||||
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
|
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
|
||||||
const SESSION_WORKSPACE_TOOL_NAMES = new Set([
|
const SESSION_WORKSPACE_TOOL_NAMES = new Set([
|
||||||
@@ -806,13 +799,22 @@ function downgradePrematurePublishClaims(text) {
|
|||||||
.replace(/已发布成功/g, '生成未完成');
|
.replace(/已发布成功/g, '生成未完成');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHtmlPublishFailureText() {
|
export function buildHtmlPublishFailureText() {
|
||||||
return [
|
return [
|
||||||
'这次页面没有按 H5 里的页面技能真正生成成功,所以我先不发不一致的简版页。',
|
'这次页面没有按 H5 里的页面技能真正生成成功,所以我先不发不一致的简版页。',
|
||||||
'请直接重发一次你的页面需求,我会按和 H5 相同的 `static-page-publish` 技能链路重做。',
|
'请直接重发一次你的页面需求,我会按和 H5 相同的 `static-page-publish` 技能链路重做。',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isHtmlPublishFailureMessage(message) {
|
||||||
|
const normalized = String(message ?? '').trim();
|
||||||
|
return (
|
||||||
|
normalized.includes('页面技能') ||
|
||||||
|
normalized.includes('static-page-publish') ||
|
||||||
|
normalized.includes('简版页')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { maybeAttachPublishedHtmlLink };
|
export { maybeAttachPublishedHtmlLink };
|
||||||
|
|
||||||
function artifactFileExists(artifact) {
|
function artifactFileExists(artifact) {
|
||||||
@@ -903,6 +905,26 @@ function resolveHtmlPublishArtifacts({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectHtmlPublishArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) {
|
||||||
|
const candidates = verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts;
|
||||||
|
const sendable = nonStubHtmlArtifacts(candidates);
|
||||||
|
return sendable.length > 0 ? sendable : candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStubPublicHtmlArtifact(artifact) {
|
||||||
|
const localPath = String(artifact?.localPath ?? '').trim();
|
||||||
|
if (!localPath) return false;
|
||||||
|
try {
|
||||||
|
return isStubPublicHtmlContent(fs.readFileSync(localPath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonStubHtmlArtifacts(artifacts = []) {
|
||||||
|
return artifacts.filter((artifact) => artifactFileExists(artifact) && !isStubPublicHtmlArtifact(artifact));
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldRetryHtmlGenerationReply({
|
export function shouldRetryHtmlGenerationReply({
|
||||||
reply,
|
reply,
|
||||||
intent,
|
intent,
|
||||||
@@ -911,6 +933,8 @@ export function shouldRetryHtmlGenerationReply({
|
|||||||
}) {
|
}) {
|
||||||
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
||||||
|
|
||||||
|
if (nonStubHtmlArtifacts(confirmedArtifacts).length > 0) return false;
|
||||||
|
|
||||||
const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text);
|
const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text);
|
||||||
if (replyHasPublicLinks) {
|
if (replyHasPublicLinks) {
|
||||||
if (!hasValidLinkInReply) return true;
|
if (!hasValidLinkInReply) return true;
|
||||||
@@ -1015,12 +1039,22 @@ export function sanitizeWechatAgentOutboundText(text) {
|
|||||||
|
|
||||||
function formatWechatAgentFailureMessage(err) {
|
function formatWechatAgentFailureMessage(err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (isHtmlPublishFailureMessage(message)) return buildHtmlPublishFailureText();
|
||||||
if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) {
|
if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) {
|
||||||
return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。';
|
return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。';
|
||||||
}
|
}
|
||||||
return `这次转发到专属 Agent 失败了:${message.slice(0, 200)}`;
|
return `这次转发到专属 Agent 失败了:${message.slice(0, 200)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markWechatUserNotified(err) {
|
||||||
|
if (err && typeof err === 'object') err.wechatUserNotified = true;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasWechatUserNotified(err) {
|
||||||
|
return Boolean(err && typeof err === 'object' && err.wechatUserNotified);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNumber(value) {
|
function normalizeNumber(value) {
|
||||||
if (value === '' || value === null || value === undefined) return null;
|
if (value === '' || value === null || value === undefined) return null;
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
@@ -1303,58 +1337,6 @@ function successResponse(task = null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadWechatMpConfig(env = process.env) {
|
|
||||||
const appId = env.H5_WECHAT_MP_APP_ID?.trim() ?? env.H5_WECHAT_APP_ID?.trim() ?? '';
|
|
||||||
const appSecret =
|
|
||||||
env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? '';
|
|
||||||
const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? '';
|
|
||||||
const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? '';
|
|
||||||
const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1';
|
|
||||||
const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login';
|
|
||||||
return {
|
|
||||||
enabled: enabledFlag && Boolean(appId && appSecret && token && publicBaseUrl),
|
|
||||||
appId,
|
|
||||||
appSecret,
|
|
||||||
token,
|
|
||||||
publicBaseUrl,
|
|
||||||
bindPath,
|
|
||||||
ackText: env.H5_WECHAT_MP_ACK_TEXT?.trim() || DEFAULT_ACK_TEXT,
|
|
||||||
ackRandomEnabled: env.H5_WECHAT_MP_ACK_RANDOM !== '0',
|
|
||||||
ackNicknameEnabled: env.H5_WECHAT_MP_ACK_NICKNAME !== '0',
|
|
||||||
progressText: env.H5_WECHAT_MP_PROGRESS_TEXT?.trim() || DEFAULT_PROGRESS_TEXT,
|
|
||||||
progressDelayMs: Math.max(
|
|
||||||
0,
|
|
||||||
Number(env.H5_WECHAT_MP_PROGRESS_DELAY_MS ?? DEFAULT_PROGRESS_DELAY_MS),
|
|
||||||
),
|
|
||||||
sessionIdleRotateMs: Math.max(
|
|
||||||
0,
|
|
||||||
Number(env.H5_WECHAT_MP_SESSION_IDLE_ROTATE_MS ?? DEFAULT_SESSION_IDLE_ROTATE_MS),
|
|
||||||
),
|
|
||||||
sessionMessageRotateCount: Math.max(
|
|
||||||
0,
|
|
||||||
Number(env.H5_WECHAT_MP_SESSION_MESSAGE_ROTATE_COUNT ?? DEFAULT_SESSION_MESSAGE_ROTATE_COUNT),
|
|
||||||
),
|
|
||||||
statusText: env.H5_WECHAT_MP_STATUS_TEXT?.trim() || DEFAULT_STATUS_TEXT,
|
|
||||||
unsupportedText: env.H5_WECHAT_MP_UNSUPPORTED_TEXT?.trim() || DEFAULT_UNSUPPORTED_TEXT,
|
|
||||||
unboundTextPrefix: env.H5_WECHAT_MP_UNBOUND_TEXT_PREFIX?.trim() || DEFAULT_UNBOUND_TEXT,
|
|
||||||
tokenUrl: env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_WECHAT_TOKEN_URL,
|
|
||||||
customerServiceUrl:
|
|
||||||
env.H5_WECHAT_MP_CUSTOMER_SERVICE_URL?.trim() || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
|
||||||
jsapiTicketUrl:
|
|
||||||
env.H5_WECHAT_MP_JSAPI_TICKET_URL?.trim() ||
|
|
||||||
deriveWechatEndpointFromUrl(env.H5_WECHAT_MP_TOKEN_URL?.trim(), '/cgi-bin/ticket/getticket') ||
|
|
||||||
DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
|
||||||
mediaPublicBaseUrl:
|
|
||||||
env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') || publicBaseUrl,
|
|
||||||
maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)),
|
|
||||||
acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0',
|
|
||||||
acceptImage: env.H5_WECHAT_MP_ACCEPT_IMAGE !== '0',
|
|
||||||
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
|
||||||
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
|
|
||||||
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sha1Hex(parts) {
|
function sha1Hex(parts) {
|
||||||
return crypto
|
return crypto
|
||||||
.createHash('sha1')
|
.createHash('sha1')
|
||||||
@@ -1483,21 +1465,11 @@ export function createWechatMpService({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
config = {
|
config = {
|
||||||
|
...loadWechatMpConfig({}),
|
||||||
...config,
|
...config,
|
||||||
tokenUrl: config.tokenUrl || DEFAULT_WECHAT_TOKEN_URL,
|
tokenUrl: config.tokenUrl || DEFAULT_WECHAT_TOKEN_URL,
|
||||||
customerServiceUrl: config.customerServiceUrl || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
customerServiceUrl: config.customerServiceUrl || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
||||||
jsapiTicketUrl: config.jsapiTicketUrl || DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
jsapiTicketUrl: config.jsapiTicketUrl || DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
||||||
progressText: config.progressText ?? DEFAULT_PROGRESS_TEXT,
|
|
||||||
progressDelayMs: Math.max(0, Number(config.progressDelayMs ?? DEFAULT_PROGRESS_DELAY_MS)),
|
|
||||||
sessionIdleRotateMs: Math.max(
|
|
||||||
0,
|
|
||||||
Number(config.sessionIdleRotateMs ?? DEFAULT_SESSION_IDLE_ROTATE_MS),
|
|
||||||
),
|
|
||||||
sessionMessageRotateCount: Math.max(
|
|
||||||
0,
|
|
||||||
Number(config.sessionMessageRotateCount ?? DEFAULT_SESSION_MESSAGE_ROTATE_COUNT),
|
|
||||||
),
|
|
||||||
statusText: config.statusText || DEFAULT_STATUS_TEXT,
|
|
||||||
mediaPublicBaseUrl: config.mediaPublicBaseUrl || config.publicBaseUrl,
|
mediaPublicBaseUrl: config.mediaPublicBaseUrl || config.publicBaseUrl,
|
||||||
maxImageBytes: Math.max(1, Number(config.maxImageBytes ?? 10 * 1024 * 1024)),
|
maxImageBytes: Math.max(1, Number(config.maxImageBytes ?? 10 * 1024 * 1024)),
|
||||||
acceptVoice: config.acceptVoice !== false,
|
acceptVoice: config.acceptVoice !== false,
|
||||||
@@ -2046,32 +2018,50 @@ export function createWechatMpService({
|
|||||||
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
|
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
|
||||||
confirmedArtifacts,
|
confirmedArtifacts,
|
||||||
});
|
});
|
||||||
if (
|
const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text);
|
||||||
shouldRetryHtmlGenerationReply({
|
const suspiciousPublishClaim =
|
||||||
reply,
|
expectedArtifacts.length === 0 &&
|
||||||
intent,
|
recentArtifacts.length === 0 &&
|
||||||
confirmedArtifacts,
|
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest }));
|
||||||
hasValidLinkInReply,
|
const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({
|
||||||
}) ||
|
reply,
|
||||||
(expectedArtifacts.length === 0 &&
|
intent,
|
||||||
recentArtifacts.length === 0 &&
|
confirmedArtifacts,
|
||||||
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })))
|
hasValidLinkInReply,
|
||||||
) {
|
});
|
||||||
|
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
|
||||||
|
if (looksLikeHtmlGenerationIntent(intent?.agentText)) {
|
||||||
|
if (
|
||||||
|
suspiciousPublishClaim ||
|
||||||
|
bareCompletionReply ||
|
||||||
|
(htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0)
|
||||||
|
) {
|
||||||
|
throw new Error('stale_session_poisoned_completion');
|
||||||
|
}
|
||||||
|
if (htmlGenerationNeedsRetry) {
|
||||||
|
const text = buildHtmlPublishFailureText();
|
||||||
|
try {
|
||||||
|
await sendCustomerServiceText(inbound.fromUserName, text, user);
|
||||||
|
} catch (sendErr) {
|
||||||
|
logger.error?.('WeChat MP html publish failure notice failed:', sendErr);
|
||||||
|
}
|
||||||
|
throw markWechatUserNotified(new Error(text));
|
||||||
|
}
|
||||||
|
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
|
||||||
throw new Error('stale_session_poisoned_completion');
|
throw new Error('stale_session_poisoned_completion');
|
||||||
}
|
}
|
||||||
if (reply.tokenState) {
|
if (reply.tokenState) {
|
||||||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
|
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
|
||||||
}
|
}
|
||||||
|
const publishArtifacts = selectHtmlPublishArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
||||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||||
workingDir,
|
workingDir,
|
||||||
publicBaseUrl: config.publicBaseUrl,
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts,
|
artifacts: publishArtifacts,
|
||||||
});
|
});
|
||||||
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
|
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
|
||||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
||||||
verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map(
|
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
|
||||||
(artifact) => artifact.url,
|
|
||||||
),
|
|
||||||
linkExistsForRequest,
|
linkExistsForRequest,
|
||||||
});
|
});
|
||||||
return { sessionId };
|
return { sessionId };
|
||||||
@@ -2115,32 +2105,50 @@ export function createWechatMpService({
|
|||||||
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
|
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
|
||||||
confirmedArtifacts,
|
confirmedArtifacts,
|
||||||
});
|
});
|
||||||
if (
|
const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text);
|
||||||
shouldRetryHtmlGenerationReply({
|
const suspiciousPublishClaim =
|
||||||
reply,
|
expectedArtifacts.length === 0 &&
|
||||||
intent,
|
recentArtifacts.length === 0 &&
|
||||||
confirmedArtifacts,
|
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest }));
|
||||||
hasValidLinkInReply,
|
const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({
|
||||||
}) ||
|
reply,
|
||||||
(expectedArtifacts.length === 0 &&
|
intent,
|
||||||
recentArtifacts.length === 0 &&
|
confirmedArtifacts,
|
||||||
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })))
|
hasValidLinkInReply,
|
||||||
) {
|
});
|
||||||
|
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
|
||||||
|
if (looksLikeHtmlGenerationIntent(intent?.agentText)) {
|
||||||
|
if (
|
||||||
|
suspiciousPublishClaim ||
|
||||||
|
bareCompletionReply ||
|
||||||
|
(htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0)
|
||||||
|
) {
|
||||||
|
throw new Error(buildHtmlPublishFailureText());
|
||||||
|
}
|
||||||
|
if (htmlGenerationNeedsRetry) {
|
||||||
|
const text = buildHtmlPublishFailureText();
|
||||||
|
try {
|
||||||
|
await sendCustomerServiceText(inbound.fromUserName, text, user);
|
||||||
|
} catch (sendErr) {
|
||||||
|
logger.error?.('WeChat MP html publish failure notice failed:', sendErr);
|
||||||
|
}
|
||||||
|
throw markWechatUserNotified(new Error(text));
|
||||||
|
}
|
||||||
|
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
|
||||||
throw new Error(buildHtmlPublishFailureText());
|
throw new Error(buildHtmlPublishFailureText());
|
||||||
}
|
}
|
||||||
if (reply.tokenState) {
|
if (reply.tokenState) {
|
||||||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
|
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
|
||||||
}
|
}
|
||||||
|
const publishArtifacts = selectHtmlPublishArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
||||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||||
workingDir,
|
workingDir,
|
||||||
publicBaseUrl: config.publicBaseUrl,
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts,
|
artifacts: publishArtifacts,
|
||||||
});
|
});
|
||||||
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
|
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
|
||||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
||||||
verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map(
|
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
|
||||||
(artifact) => artifact.url,
|
|
||||||
),
|
|
||||||
linkExistsForRequest,
|
linkExistsForRequest,
|
||||||
});
|
});
|
||||||
return { sessionId };
|
return { sessionId };
|
||||||
@@ -2563,11 +2571,16 @@ export function createWechatMpService({
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
logger.error?.('WeChat MP background reply failed:', err);
|
logger.error?.('WeChat MP background reply failed:', err);
|
||||||
return sendCustomerServiceText(
|
if (wasWechatUserNotified(err)) return;
|
||||||
inbound.fromUserName,
|
try {
|
||||||
formatWechatAgentFailureMessage(err),
|
await sendCustomerServiceText(
|
||||||
boundUser,
|
inbound.fromUserName,
|
||||||
).catch(() => {});
|
formatWechatAgentFailureMessage(err),
|
||||||
|
boundUser,
|
||||||
|
);
|
||||||
|
} catch (sendErr) {
|
||||||
|
logger.error?.('WeChat MP failure notice failed:', sendErr);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+4
-4
@@ -2084,7 +2084,7 @@ test('wechat mp service recreates poisoned dedicated session after bare completi
|
|||||||
assert.match(payload.text.content, /页面生成未完成|hello\.html/);
|
assert.match(payload.text.content, /页面生成未完成|hello\.html/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wechat mp service recreates dedicated session when html was written before loading static-page-publish skill', async () => {
|
test('wechat mp service forwards html link when write_file created a real page without static-page-publish skill', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
const nonce = 'nonce';
|
const nonce = 'nonce';
|
||||||
@@ -2249,11 +2249,11 @@ test('wechat mp service recreates dedicated session when html was written before
|
|||||||
crypto.randomUUID = originalRandomUuid;
|
crypto.randomUUID = originalRandomUuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.equal(routeCleared, true);
|
assert.equal(routeCleared, false);
|
||||||
assert.equal(started, true);
|
assert.equal(started, false);
|
||||||
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
||||||
const payload = JSON.parse(sendCall[2]);
|
const payload = JSON.parse(sendCall[2]);
|
||||||
assert.match(payload.text.content, /Hello World/);
|
assert.match(payload.text.content, /hello-world\.html/);
|
||||||
assert.match(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/hello-world\.html/);
|
assert.match(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/hello-world\.html/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user