Files

2678 lines
92 KiB
JavaScript
Raw Permalink Normal View History

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fetch as undiciFetch } from 'undici';
2026-06-27 22:28:41 +08:00
import { developerToolsFromPolicy } from './capabilities.mjs';
import { mergeMessageContent } from './message-stream.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { resolveSessionAccess } from './session-broker.mjs';
import { isStubPublicHtmlContent, materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs';
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
2026-07-02 21:11:36 +08:00
import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs';
import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs';
import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs';
import { buildAckText } from './wechat/ack/ack-provider.mjs';
import { guardScheduleConfirmationReply } from './wechat/handlers/schedule-guard.mjs';
import { handleWechatScheduleIntent } from './wechat/handlers/schedule.mjs';
import {
buildStatusText,
resolveSyncReply,
shouldHandleSyncReply,
} from './wechat/handlers/sync-replies.mjs';
import { classifyWechatIntent } from './wechat/intent/classifier.mjs';
import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs';
import { isPageDataIntent } from './chat-skills.mjs';
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
import {
buildPageGenerateAgentPrompt,
buildPagePublishFailureText,
} from './wechat/prompts/page-generate.mjs';
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs';
import { resolveBillingTokenState } from './billing-token-state.mjs';
import {
buildPageDataCollectFailureText,
buildPageDataDeliveryArtifactsFromBindResult,
ensurePageDataDeliveryReady,
maybeAutoBindPageDataHtmlPages,
resolvePageDataCollectOutcomeAsync,
rewritePageDataDeliveryLinks,
} from './mindspace-page-data-finish-guard.mjs';
export { buildWechatAgentPrompt };
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';
const DEFAULT_ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn';
export { loadWechatMpConfig };
const PUBLIC_HTML_LINK_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
2026-06-27 22:28:41 +08:00
const SESSION_WORKSPACE_TOOL_NAMES = new Set([
'write',
'edit',
'tree',
'read_file',
'write_file',
'edit_file',
'create_dir',
'list_dir',
]);
function escapeRegExp(value) {
return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function parseXmlField(xml, field) {
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${field}>`));
if (cdataMatch) return cdataMatch[1];
const plainMatch = xml.match(new RegExp(`<${field}>([\\s\\S]*?)<\\/${field}>`));
return plainMatch ? plainMatch[1].trim() : '';
}
function parseWechatMessage(xml) {
return {
toUserName: parseXmlField(xml, 'ToUserName'),
fromUserName: parseXmlField(xml, 'FromUserName'),
createTime: Number(parseXmlField(xml, 'CreateTime') || 0),
msgType: parseXmlField(xml, 'MsgType').toLowerCase(),
content: parseXmlField(xml, 'Content'),
msgId: parseXmlField(xml, 'MsgId'),
mediaId: parseXmlField(xml, 'MediaId'),
format: parseXmlField(xml, 'Format'),
recognition: parseXmlField(xml, 'Recognition'),
picUrl: parseXmlField(xml, 'PicUrl'),
locationX: parseXmlField(xml, 'Location_X'),
locationY: parseXmlField(xml, 'Location_Y'),
scale: parseXmlField(xml, 'Scale'),
label: parseXmlField(xml, 'Label'),
title: parseXmlField(xml, 'Title'),
description: parseXmlField(xml, 'Description'),
url: parseXmlField(xml, 'Url'),
thumbMediaId: parseXmlField(xml, 'ThumbMediaId'),
event: parseXmlField(xml, 'Event').toLowerCase(),
eventKey: parseXmlField(xml, 'EventKey'),
latitude: parseXmlField(xml, 'Latitude'),
longitude: parseXmlField(xml, 'Longitude'),
precision: parseXmlField(xml, 'Precision'),
encrypt: parseXmlField(xml, 'Encrypt'),
rawXml: xml,
};
}
function readJsonResponse(response) {
return response.text().then((text) => {
if (!response.ok) {
throw new Error(text || `upstream ${response.status}`);
}
return text ? JSON.parse(text) : null;
});
}
async function readJsonBody(response) {
const text = await response.text();
if (!text) return { payload: null, text: '' };
try {
return { payload: JSON.parse(text), text };
} catch {
return { payload: null, text };
}
}
function sanitizeAsrMessage(message) {
if (!message || typeof message !== 'string') return '识别失败,请重试';
const trimmed = message.trim();
if (!trimmed) return '识别失败,请重试';
if (/Failed to load audio|ffmpeg|Invalid data found when processing input|moov atom not found/i.test(trimmed)) {
return '音频格式无法识别,请重新录制';
}
if (/timeout|timed out/i.test(trimmed)) return '识别超时,请重试';
if (trimmed.length > 160) return `${trimmed.slice(0, 160)}…`;
return trimmed;
}
function guessVoiceMimeType(format = '', contentType = '') {
const normalizedContentType = String(contentType ?? '')
.split(';')[0]
.trim()
.toLowerCase();
if (normalizedContentType) return normalizedContentType;
const normalizedFormat = String(format ?? '').trim().toLowerCase();
if (normalizedFormat === 'amr') return 'audio/amr';
if (normalizedFormat === 'wav') return 'audio/wav';
if (normalizedFormat === 'mp3') return 'audio/mpeg';
if (normalizedFormat === 'm4a') return 'audio/mp4';
if (normalizedFormat === 'ogg') return 'audio/ogg';
return 'application/octet-stream';
}
function deriveWechatEndpointFromUrl(sourceUrl, pathname) {
if (!sourceUrl) return '';
try {
const url = new URL(sourceUrl);
url.pathname = pathname;
url.search = '';
return url.toString();
} catch {
return '';
}
}
function createUserMessage(text, metadata = {}) {
return {
id: crypto.randomUUID(),
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text }],
metadata: {
userVisible: true,
agentVisible: true,
...metadata,
},
};
}
function messageVisibleText(message) {
if (!message?.content) return '';
return message.content
.filter((item) => item.type === 'text' && typeof item.text === 'string')
.map((item) => item.text)
.join('');
}
function pushMessage(messages, incoming) {
const last = messages[messages.length - 1];
if (last?.id && incoming?.id && last.id === incoming.id) {
return [
...messages.slice(0, -1),
{
...last,
content: mergeMessageContent(last.content, incoming.content),
},
];
}
return [...messages, incoming];
}
async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metadata = {}) {
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
method: 'GET',
headers: { Accept: 'text/event-stream' },
});
if (!eventsResponse.ok || !eventsResponse.body) {
const text = await eventsResponse.text().catch(() => '');
throw new Error(text || '无法建立公众号消息事件流');
}
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
method: 'POST',
body: JSON.stringify({
request_id: requestId,
user_message: createUserMessage(prompt, metadata),
}),
});
if (!replyResponse.ok) {
const text = await replyResponse.text().catch(() => '');
throw new Error(text || 'Agent reply 请求失败');
}
replyResponse.body?.cancel().catch?.(() => {});
const reader = eventsResponse.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let messages = [];
2026-06-27 22:28:41 +08:00
let hasScopedAssistantUpdate = false;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
let data = '';
for (const line of frame.split('\n')) {
if (line.startsWith('data:')) data += line.slice(5).trim();
}
if (!data) continue;
let event;
try {
event = JSON.parse(data);
} catch {
continue;
}
const routingId = event.chat_request_id ?? event.request_id;
if (routingId && routingId !== requestId) continue;
if (event.type === 'Message' && event.message?.metadata?.userVisible) {
const hasActionRequired = event.message.content?.some((item) => item.type === 'actionRequired');
if (hasActionRequired) {
throw new Error('当前回复需要人工确认,公众号通道暂不支持');
}
2026-06-27 22:28:41 +08:00
if (event.message.role === 'assistant') hasScopedAssistantUpdate = true;
messages = pushMessage(messages, event.message);
} else if (event.type === 'UpdateConversation') {
2026-06-27 22:28:41 +08:00
// 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');
2026-06-27 22:28:41 +08:00
if (!hasScopedAssistantUpdate || !assistant) {
throw new Error('本轮未收到可发送的新回复,请稍后重试');
}
const reply = {
text: messageVisibleText(assistant),
tokenState: event.token_state ?? null,
2026-06-27 22:28:41 +08:00
messages,
};
assertWechatAgentReplyIsSendable(reply);
return reply;
}
}
}
throw new Error('公众号消息事件流提前结束');
}
const WECHAT_CUSTOMER_TEXT_MAX_BYTES = 2048;
function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) {
const normalized = String(text ?? '').trim();
if (!normalized) return ['我先收到了,但这次没有生成可发送的文本结果。'];
const chunks = [];
let offset = 0;
while (offset < normalized.length) {
let byteCount = 0;
let end = offset;
while (end < normalized.length) {
const charBytes = Buffer.byteLength(normalized[end], 'utf8');
if (byteCount + charBytes > maxBytes) break;
byteCount += charBytes;
end += 1;
}
if (end === offset) break;
chunks.push(normalized.slice(offset, end));
offset = end;
}
return chunks.length > 0 ? chunks : ['我先收到了,但这次没有生成可发送的文本结果。'];
}
export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES };
2026-06-27 22:28:41 +08:00
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 normalizedName = String(name ?? '').trim();
const writeLikeSandbox =
(normalizedName === 'write_file' ||
normalizedName === 'edit_file' ||
normalizedName.endsWith('__write_file') ||
normalizedName.endsWith('__edit_file')) &&
typeof args.path === 'string';
2026-06-27 22:28:41 +08:00
if (!writeLikeDeveloper && !writeLikeSandbox) continue;
const candidate = String(args.path ?? '').trim();
if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate);
}
}
return [...targets];
}
function looksLikeHtmlGenerationIntent(text) {
return isPageGenerateText(text);
2026-06-27 22:28:41 +08:00
}
function looksLikeDocxDownloadIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return /(?:(?:word|docx|\.docx|\.doc|文档).*(?:下载|链接|导出|给我)|(?:下载|导出|提供|给我).*(?:word|docx|\.docx|\.doc|文档))/iu.test(normalized);
}
function usedStaticPagePublishSkill(messages = []) {
return messages.some((message) =>
message?.content?.some((item) => {
if (item?.type !== 'toolRequest') return false;
const toolCall = item.toolCall?.value;
const name = String(toolCall?.name ?? '').trim();
const args = toolCall?.arguments ?? {};
const skillName = String(args.name ?? '').trim();
return name === 'load_skill' && skillName === 'static-page-publish';
}),
);
}
2026-06-27 22:28:41 +08:00
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 looksLikePublishSuccessClaim(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return /(?:页面|网页|html).*(?:已创建|已生成|已发布|创建完毕|生成完成|发布成功)|(?:成功发布|已经发布|已创建完毕).*(?:页面|网页|html)/iu.test(normalized);
}
2026-06-29 22:20:04 +08:00
async function hasAnyValidPublishedHtmlLink(text, linkExists, { confirmedArtifacts = [] } = {}) {
const value = String(text ?? '');
for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN)) {
try {
if (await linkExists(match[0])) return true;
} catch {
// treat lookup failures as missing links
}
2026-06-29 22:20:04 +08:00
const filename = String(match[2] ?? '').split('/').pop()?.trim();
if (
filename &&
confirmedArtifacts.some((artifact) => {
const artifactName = path.posix.basename(String(artifact.relativePath ?? '').replace(/\\/g, '/'));
return artifactName === filename && artifactFileExists(artifact);
})
) {
return true;
}
}
return false;
}
async function isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists = defaultPublicHtmlLinkExists } = {}) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
const text = String(reply?.text ?? '').trim();
if (!looksLikePublishSuccessClaim(text)) return false;
if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false;
return !(await hasAnyValidPublishedHtmlLink(text, linkExists));
}
function isMissingRequiredPublishSkill(reply, intent) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
const wroteHtml = extractHtmlWriteTargets(reply?.messages ?? []).length > 0;
if (!wroteHtml) return false;
return !usedStaticPagePublishSkill(reply?.messages ?? []);
}
2026-06-27 22:28:41 +08:00
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
const owner = path.basename(path.resolve(workingDir));
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
2026-07-02 21:11:36 +08:00
return buildPublicUrl(publicBaseUrl, owner, normalized);
2026-06-27 22:28:41 +08:00
}
function ensurePublicHtmlArtifact(htmlPath, workingDir) {
const workspaceRoot = path.resolve(workingDir);
const source = path.isAbsolute(String(htmlPath ?? ''))
? path.resolve(String(htmlPath))
: path.resolve(workspaceRoot, String(htmlPath ?? ''));
2026-06-27 22:28:41 +08:00
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,
};
}
function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) {
const artifacts = [];
2026-06-27 22:28:41 +08:00
const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []);
for (const target of htmlTargets) {
const artifact = ensurePublicHtmlArtifact(target, workingDir);
if (!artifact) continue;
artifacts.push({
...artifact,
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
});
}
return artifacts;
}
function extractRequestedHtmlTarget(text) {
const value = String(text ?? '');
if (!value) return '';
const explicitPublicMatch = value.match(/(?:^|[\s`'"])(public\/[a-z0-9._-]+\.html)\b/i);
if (explicitPublicMatch?.[1]) return explicitPublicMatch[1];
const bareMatch = value.match(/(?:^|[\s`'"])([a-z0-9._-]+\.html)\b/i);
if (bareMatch?.[1]) return `public/${bareMatch[1]}`;
return '';
}
function collectExpectedHtmlArtifacts(intent, { workingDir, publicBaseUrl }) {
const target = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? '');
if (!target) return [];
const artifact = ensurePublicHtmlArtifact(target, workingDir);
if (!artifact) return [];
return [{
...artifact,
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
}];
}
function sanitizeFilenameSlug(value) {
const normalized = String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'simple-page';
}
function hasAnyUrl(text) {
return /https?:\/\/\S+/i.test(String(text ?? ''));
}
function hasAnyPublicHtmlLink(text) {
return [...String(text ?? '').matchAll(PUBLIC_HTML_LINK_PATTERN)].length > 0;
}
2026-06-29 22:20:04 +08:00
function collectPublicHtmlLinkFilenames(text) {
const filenames = new Set();
for (const match of String(text ?? '').matchAll(PUBLIC_HTML_LINK_PATTERN)) {
const relativePath = String(match[2] ?? '').trim();
const filename = path.posix.basename(relativePath);
if (filename) filenames.add(filename);
}
2026-06-29 22:20:04 +08:00
return filenames;
}
function collectRecentPublishedHtmlArtifacts(
intent,
2026-06-29 22:20:04 +08:00
{ workingDir, publicBaseUrl, replyText = '', sinceMs = 0, limit = 20 } = {},
) {
const publicRoot = path.join(path.resolve(workingDir), 'public');
if (!fs.existsSync(publicRoot) || !fs.statSync(publicRoot).isDirectory()) return [];
const expectedTarget = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? '');
const expectedRelativePath = expectedTarget ? expectedTarget.replace(/^\/+/, '').replace(/\\/g, '/') : '';
2026-06-29 22:20:04 +08:00
const linkedFilenames = collectPublicHtmlLinkFilenames(replyText);
const artifacts = [];
const stack = [publicRoot];
while (stack.length > 0 && artifacts.length < limit) {
const current = stack.pop();
let entries = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(absolutePath);
continue;
}
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.html')) continue;
let stat;
try {
stat = fs.statSync(absolutePath);
} catch {
continue;
}
if (sinceMs > 0 && Number(stat.mtimeMs ?? 0) + 1 < sinceMs) continue;
const relativePath = path.relative(path.resolve(workingDir), absolutePath);
if (!relativePath || relativePath.startsWith('..')) continue;
2026-06-29 22:20:04 +08:00
const normalizedRelativePath = relativePath.replace(/\\/g, '/');
const filename = path.posix.basename(normalizedRelativePath);
if (linkedFilenames.size > 0 && filename && !linkedFilenames.has(filename)) continue;
artifacts.push({
localPath: absolutePath,
2026-06-29 22:20:04 +08:00
relativePath: normalizedRelativePath,
url: buildPublicHtmlUrl(workingDir, normalizedRelativePath, publicBaseUrl),
mtimeMs: Number(stat.mtimeMs ?? 0),
matchesExpected: expectedRelativePath
2026-06-29 22:20:04 +08:00
? normalizedRelativePath === expectedRelativePath
: false,
});
if (artifacts.length >= limit) break;
}
}
return artifacts
.sort((left, right) => {
if (left.matchesExpected !== right.matchesExpected) return left.matchesExpected ? -1 : 1;
return right.mtimeMs - left.mtimeMs;
})
.map(({ mtimeMs, matchesExpected, ...artifact }) => artifact);
}
function rewritePublishedHtmlLinks(text, artifacts = []) {
const value = String(text ?? '');
if (!value || artifacts.length === 0) return value;
let next = value;
for (const artifact of artifacts) {
const canonicalUrl = String(artifact?.url ?? '').trim();
const filename = String(artifact?.relativePath ?? '').replace(/\\/g, '/').split('/').pop();
if (!canonicalUrl || !filename) continue;
const wrongPublicLinkPattern = new RegExp(
`https?:\\/\\/[^\\s)]+\\/(?:MindSpace\\/[^\\s/]+\\/)?public\\/${escapeRegExp(filename)}\\b`,
'g',
);
const wrongTkmindHtmlLinkPattern = new RegExp(
`https?:\\/\\/(?:[^\\s./]+\\.)*tkmind\\.cn\\/[^\n\\s)]*${escapeRegExp(filename)}\\b`,
'gi',
);
next = next.replace(wrongPublicLinkPattern, canonicalUrl);
next = next.replace(wrongTkmindHtmlLinkPattern, canonicalUrl);
next = next.replace(PUBLIC_HTML_LINK_PATTERN, (match) => {
if (match === canonicalUrl) return match;
const matchedFilename = String(match).split('/').pop();
return matchedFilename === filename ? canonicalUrl : match;
});
}
return next;
}
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl, artifacts: providedArtifacts = null }) {
const artifacts = Array.isArray(providedArtifacts)
? providedArtifacts
: collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl });
const baseText = rewritePublishedHtmlLinks(String(reply?.text ?? '').trim(), artifacts).trim();
for (const artifact of artifacts) {
const url = artifact.url;
2026-06-27 22:28:41 +08:00
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, '');
}
function replaceLeadingInternalWechatUsername(text, user) {
const addressName = normalizeWechatName(user?.nickname) || normalizeWechatName(user?.displayName);
if (!addressName) return stripInternalWechatUsername(text);
return String(text ?? '').replace(
/(^|[\s,,、::])wx_[a-z0-9_]{4,64}\s*([,,、::])\s*/gi,
(_match, prefix = '', punctuation = ',') => `${prefix}${addressName}${punctuation}`,
);
}
function convertMarkdownLinks(text) {
return String(text ?? '').replace(/\[([^\]\n]{1,160})\]\((https?:\/\/[^\s)]+)\)/g, (_match, label, url) => {
const cleanLabel = String(label ?? '').trim();
const cleanUrl = String(url ?? '').trim();
return cleanLabel ? `${cleanLabel}\n${cleanUrl}` : cleanUrl;
});
}
function splitMarkdownTableRow(line) {
const trimmed = String(line ?? '').trim();
if (!trimmed.includes('|')) return null;
return trimmed
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim());
}
function isMarkdownTableSeparator(line) {
const cells = splitMarkdownTableRow(line);
return Boolean(cells?.length) && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
}
function convertMarkdownTables(text) {
const lines = String(text ?? '').split('\n');
const output = [];
for (let index = 0; index < lines.length; index += 1) {
const headers = splitMarkdownTableRow(lines[index]);
if (!headers || !isMarkdownTableSeparator(lines[index + 1])) {
output.push(lines[index]);
continue;
}
const readableRows = [];
index += 2;
while (index < lines.length) {
const cells = splitMarkdownTableRow(lines[index]);
if (!cells) break;
const parts = cells
.map((cell, cellIndex) => {
const header = headers[cellIndex] || `列${cellIndex + 1}`;
return cell ? `${header}:${cell}` : '';
})
.filter(Boolean);
if (parts.length) readableRows.push(`- ${parts.join(';')}`);
index += 1;
}
index -= 1;
output.push(...readableRows);
}
return output.join('\n');
}
function stripMarkdownEmphasis(text) {
return String(text ?? '')
.replace(/\*\*([^*\n]+)\*\*/g, '$1')
.replace(/__([^_\n]+)__/g, '$1')
.replace(/`([^`\n]+)`/g, '$1')
.replace(/\*/g, '')
.replace(/__/g, '');
}
function formatWechatOutboundText(text, user = null) {
return replaceLeadingInternalWechatUsername(
stripMarkdownEmphasis(convertMarkdownTables(convertMarkdownLinks(text)))
.replace(/^\s*#{1,6}\s+/gm, '')
.replace(/^\s*---+\s*$/gm, '')
.replace(/\n{3,}/g, '\n\n'),
user,
);
}
function defaultPublicHtmlLinkExists(urlText) {
let url;
try {
url = new URL(urlText);
} catch {
return true;
}
const parts = url.pathname.split('/').filter(Boolean).map((part) => {
try {
return decodeURIComponent(part);
} catch {
return part;
}
});
if (parts.length < 4 || parts[0] !== PUBLISH_ROOT_DIR || parts[2] !== 'public') return true;
const owner = parts[1];
const rest = parts.slice(3);
if (!owner || rest.some((part) => !part || part === '.' || part === '..')) return false;
const root = path.resolve(process.cwd(), PUBLISH_ROOT_DIR, owner, 'public');
const target = path.resolve(root, ...rest);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false;
return fs.existsSync(target) && fs.statSync(target).isFile();
}
2026-06-29 22:20:04 +08:00
export function createPublicHtmlLinkExists(workingDir) {
const workspaceRoot = path.resolve(String(workingDir ?? ''));
const workspaceKey = path.basename(workspaceRoot);
return (urlText) => {
let url;
try {
url = new URL(urlText);
} catch {
return true;
}
const parts = url.pathname.split('/').filter(Boolean).map((part) => {
try {
return decodeURIComponent(part);
} catch {
return part;
}
});
if (parts.length < 4 || parts[0] !== PUBLISH_ROOT_DIR || parts[2] !== 'public') return true;
const owner = parts[1];
const rest = parts.slice(3);
if (!owner || rest.some((part) => !part || part === '.' || part === '..')) return false;
if (owner === workspaceKey) {
const root = path.join(workspaceRoot, 'public');
const target = path.resolve(root, ...rest);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false;
return fs.existsSync(target) && fs.statSync(target).isFile();
}
return defaultPublicHtmlLinkExists(urlText);
};
}
function resolveLinkExistsForWorkingDir(workingDir, linkExists = defaultPublicHtmlLinkExists) {
if (linkExists !== defaultPublicHtmlLinkExists) return linkExists;
return createPublicHtmlLinkExists(workingDir);
}
export async function guardMissingPublicHtmlLinks(
text,
{ linkExists = defaultPublicHtmlLinkExists } = {},
) {
const value = String(text ?? '');
const replacements = [];
for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN)) {
const url = match[0];
let exists = true;
try {
exists = await linkExists(url);
} catch {
exists = false;
}
if (!exists) {
const filename = match[2].split('/').pop() ?? '页面';
replacements.push([
url,
`(页面生成未完成,已阻止发送失效链接:${filename}。请重新生成页面后再打开。)`,
]);
}
}
return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value);
}
function downgradePrematurePublishClaims(text) {
const value = String(text ?? '');
if (!value.includes('页面生成未完成')) return value;
return value
.replace(/(^|\n)([^\n]*页面都已经成功发布了[^\n]*)(?=\n|$)/g, '$1页面暂未生成完成,这次我先不发送失效链接。')
.replace(/(^|\n)([^\n]*主题页面已发布[^\n]*)(?=\n|$)/g, '$1🌴 夏日主题页面生成未完成')
.replace(/页面已创建完毕/g, '页面生成未完成')
.replace(/页面已创建/g, '页面生成未完成')
.replace(/发布成功/g, '生成未完成')
.replace(/已发布成功/g, '生成未完成');
}
export function buildHtmlPublishFailureText() {
return buildPagePublishFailureText();
}
export function isHtmlPublishFailureMessage(message) {
const normalized = String(message ?? '').trim();
return (
normalized.includes('页面技能') ||
normalized.includes('static-page-publish') ||
normalized.includes('简版页')
);
}
2026-06-27 22:28:41 +08:00
export { maybeAttachPublishedHtmlLink };
2026-06-29 22:20:04 +08:00
function artifactFileExists(artifact) {
const localPath = String(artifact?.localPath ?? '').trim();
if (!localPath) return false;
try {
return fs.existsSync(localPath) && fs.statSync(localPath).isFile();
} catch {
return false;
}
}
function uniqueArtifactsByUrl(artifacts = []) {
const byUrl = new Map();
for (const artifact of artifacts) {
const url = String(artifact?.url ?? '').trim();
if (url) byUrl.set(url, artifact);
}
return [...byUrl.values()];
}
function allExistingHtmlArtifacts({ publishedArtifacts = [], expectedArtifacts = [], recentArtifacts = [] } = {}) {
return uniqueArtifactsByUrl([
...publishedArtifacts,
...expectedArtifacts,
...recentArtifacts,
]).filter((artifact) => artifactFileExists(artifact));
}
function buildVerifiedHtmlArtifacts({
publishedArtifacts = [],
expectedArtifacts = [],
recentArtifacts = [],
replyText = '',
} = {}) {
const existing = allExistingHtmlArtifacts({ publishedArtifacts, expectedArtifacts, recentArtifacts });
const linkedFilenames = collectPublicHtmlLinkFilenames(replyText);
if (linkedFilenames.size === 0) return existing;
const matched = existing.filter((artifact) => {
const filename = path.posix.basename(String(artifact.relativePath ?? '').replace(/\\/g, '/'));
return filename && linkedFilenames.has(filename);
});
return matched;
}
function resolveHtmlPublishArtifacts({
reply,
intent,
workingDir,
publicBaseUrl,
requestStartedAt,
2026-07-16 21:06:28 +08:00
userId = '',
sessionId = '',
onPageGenerated = null,
2026-06-29 22:20:04 +08:00
}) {
materializeMissingPublicHtmlWrites({
messages: reply?.messages ?? [],
publishDir: workingDir,
});
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
workingDir,
publicBaseUrl,
});
const expectedArtifacts = collectExpectedHtmlArtifacts(intent, {
workingDir,
publicBaseUrl,
});
const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, {
workingDir,
publicBaseUrl,
replyText: reply?.text,
sinceMs: requestStartedAt,
});
const confirmedArtifacts = allExistingHtmlArtifacts({
publishedArtifacts,
expectedArtifacts,
recentArtifacts,
});
const verifiedArtifacts = buildVerifiedHtmlArtifacts({
publishedArtifacts,
expectedArtifacts,
recentArtifacts,
replyText: reply?.text,
});
2026-07-16 21:06:28 +08:00
if (typeof onPageGenerated === 'function' && confirmedArtifacts.length > 0) {
void onPageGenerated({ userId, sessionId, artifacts: confirmedArtifacts });
}
2026-06-29 22:20:04 +08:00
return {
publishedArtifacts,
expectedArtifacts,
recentArtifacts,
confirmedArtifacts,
verifiedArtifacts,
};
}
function selectHtmlPublishArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) {
return selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
}
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));
}
2026-06-29 22:20:04 +08:00
export function shouldRetryHtmlGenerationReply({
reply,
intent,
confirmedArtifacts = [],
hasValidLinkInReply = false,
}) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
if (nonStubHtmlArtifacts(confirmedArtifacts).length > 0) return false;
2026-06-29 22:20:04 +08:00
const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text);
if (replyHasPublicLinks) {
if (!hasValidLinkInReply) return true;
if (isMissingRequiredPublishSkill(reply, intent)) return true;
return false;
}
if (confirmedArtifacts.length > 0) return false;
if (isMissingRequiredPublishSkill(reply, intent) || isSuspiciousBareCompletionReply(reply, intent)) {
return true;
}
2026-07-03 10:43:13 +08:00
if (!usedStaticPagePublishSkill(reply?.messages ?? [])) return false;
2026-06-29 22:20:04 +08:00
return !hasAnyUrl(reply?.text);
}
function isTopicResetIntent(text) {
return isTopicResetText(text);
}
export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) {
return (
wechatIntent?.kind === 'session.reset' ||
isTopicResetIntent(resetCandidate) ||
isPageDataIntent(resetCandidate)
);
}
export function isRecoverableWechatAgentSessionError(message) {
const normalized = String(message ?? '').trim();
if (!normalized) return false;
if (/stale_session_poisoned_completion/i.test(normalized)) return true;
if (/403|404|not found|无权访问/i.test(normalized)) return true;
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
return false;
}
function collectWechatAgentReplyVisibleTexts(reply) {
const texts = [];
const seen = new Set();
const append = (text) => {
const normalized = String(text ?? '').trim();
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
texts.push(normalized);
};
append(reply?.text);
for (const message of reply?.messages ?? []) {
if (message?.role !== 'assistant') continue;
append(messageVisibleText(message));
}
return texts;
}
export function findRecoverableWechatAgentErrorInReply(reply) {
for (const text of collectWechatAgentReplyVisibleTexts(reply)) {
if (isRecoverableWechatAgentSessionError(text)) return text;
}
return null;
}
export function assertWechatAgentReplyIsSendable(reply) {
const recoverable = findRecoverableWechatAgentErrorInReply(reply);
if (recoverable) throw new Error(recoverable);
}
export function isWechatAgentApiErrorText(message) {
const normalized = String(message ?? '').trim();
if (!normalized) return false;
if (isRecoverableWechatAgentSessionError(normalized)) return true;
if (/Ran into this error:/i.test(normalized)) return true;
return /Request failed:\s*(Bad request|Internal Server Error)/i.test(normalized);
}
export function sanitizeWechatAgentOutboundText(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return normalized;
const stripped = normalized
.replace(/Ran into this error:[\s\S]*?(?=\n\n|$)/gi, '')
.replace(/Request failed:\s*Bad request \(400\):[\s\S]*?(?=\n\n|$)/gi, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (!stripped || isWechatAgentApiErrorText(stripped)) {
return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。';
}
return stripped;
}
function formatWechatAgentFailureMessage(err) {
const message = err instanceof Error ? err.message : String(err);
if (isHtmlPublishFailureMessage(message)) return buildHtmlPublishFailureText();
if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) {
return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。';
}
return `这次转发到专属 Agent 失败了:${message.slice(0, 200)}`;
}
function markWechatUserNotified(err) {
if (err && typeof err === 'object') err.wechatUserNotified = true;
return err;
}
async function enforcePageDataCollectDelivery({
reply,
intent,
workingDir,
userId,
pageDataFinishGuard,
publicBaseUrl,
requestStartedAt = 0,
notifyFailure,
}) {
let outcome = await resolvePageDataCollectOutcomeAsync({
reply,
intent,
publishDir: workingDir,
requestStartedAt,
pool: pageDataFinishGuard?.pool ?? null,
userId,
findPageByRelativePath: null,
apiBase: publicBaseUrl,
});
let autoBind = null;
if (outcome.action === 'skip') return outcome;
if (pageDataFinishGuard?.pool) {
const { createPageService } = await import('./mindspace-pages.mjs');
const pageService = createPageService(pageDataFinishGuard.pool, {
h5Root: pageDataFinishGuard.h5Root,
storageRoot: pageDataFinishGuard.storageRoot,
});
autoBind = await maybeAutoBindPageDataHtmlPages({
pool: pageDataFinishGuard.pool,
userId,
publishDir: workingDir,
h5Root: pageDataFinishGuard.h5Root,
storageRoot: pageDataFinishGuard.storageRoot,
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
});
outcome = await resolvePageDataCollectOutcomeAsync({
reply,
intent,
publishDir: workingDir,
requestStartedAt,
pool: pageDataFinishGuard.pool,
userId,
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
apiBase: publicBaseUrl,
});
}
if (outcome.action === 'retry') {
throw new Error('stale_session_poisoned_completion');
}
if (outcome.action === 'fail') {
const text = outcome.failureText ?? buildPageDataCollectFailureText();
if (typeof notifyFailure === 'function') {
await notifyFailure(text);
}
throw markWechatUserNotified(new Error(text));
}
const deliveryArtifacts = buildPageDataDeliveryArtifactsFromBindResult(autoBind, workingDir, {
publicBaseUrl,
});
if (deliveryArtifacts.length > 0 && pageDataFinishGuard?.pool) {
const { createPageService } = await import('./mindspace-pages.mjs');
const pageService = createPageService(pageDataFinishGuard.pool, {
h5Root: pageDataFinishGuard.h5Root,
storageRoot: pageDataFinishGuard.storageRoot,
});
const deliveryCheck = await ensurePageDataDeliveryReady({
publishDir: workingDir,
userId,
pool: pageDataFinishGuard.pool,
h5Root: pageDataFinishGuard.h5Root,
storageRoot: pageDataFinishGuard.storageRoot,
apiBase: publicBaseUrl,
artifacts: deliveryArtifacts,
});
if (!deliveryCheck.ok) {
const text = buildPageDataCollectFailureText();
if (typeof notifyFailure === 'function') {
await notifyFailure(text);
}
throw markWechatUserNotified(new Error(text));
}
}
if (deliveryArtifacts.length > 0 && reply && typeof reply.text === 'string') {
reply.text = rewritePageDataDeliveryLinks(reply.text, deliveryArtifacts);
}
return { ...outcome, deliveryArtifacts, autoBind };
}
function wasWechatUserNotified(err) {
return Boolean(err && typeof err === 'object' && err.wechatUserNotified);
}
function normalizeNumber(value) {
if (value === '' || value === null || value === undefined) return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function normalizeWechatInboundIntent(inbound) {
const msgType = String(inbound?.msgType ?? '').toLowerCase();
const base = {
appId: '',
openid: String(inbound?.fromUserName ?? '').trim(),
msgId: String(inbound?.msgId ?? '').trim(),
msgType,
displayText: '',
agentText: '',
raw: { ...inbound },
};
if (msgType === 'text') {
const content = String(inbound?.content ?? '').trim();
return {
...base,
displayText: content,
agentText: content,
};
}
if (msgType === 'voice') {
const recognition = String(inbound?.recognition ?? '').trim();
return {
...base,
displayText: recognition ? `语音:${recognition}` : '语音消息',
agentText: recognition,
media: {
mediaId: inbound?.mediaId || '',
format: inbound?.format || '',
},
};
}
if (msgType === 'image') {
return {
...base,
displayText: '收到图片,请描述你想让我怎么处理',
agentText: '',
media: {
mediaId: inbound?.mediaId || '',
picUrl: inbound?.picUrl || '',
},
};
}
if (msgType === 'location') {
const latitude = normalizeNumber(inbound?.locationX);
const longitude = normalizeNumber(inbound?.locationY);
const scale = normalizeNumber(inbound?.scale);
const label = String(inbound?.label ?? '').trim();
const fragments = [
label ? `用户发送了位置:${label}` : '用户发送了位置',
latitude !== null ? `纬度:${latitude}` : '',
longitude !== null ? `经度:${longitude}` : '',
scale !== null ? `缩放:${scale}` : '',
].filter(Boolean);
return {
...base,
displayText: fragments.join(';'),
agentText: fragments.join(';'),
location: {
latitude,
longitude,
scale,
label,
},
};
}
if (msgType === 'link') {
const title = String(inbound?.title ?? '').trim();
const description = String(inbound?.description ?? '').trim();
const url = String(inbound?.url ?? '').trim();
const fragments = [
'用户分享了链接',
title ? `标题:${title}` : '',
description ? `描述:${description}` : '',
url ? `URL:${url}` : '',
].filter(Boolean);
return {
...base,
displayText: fragments.join(';'),
agentText: fragments.join(';'),
link: { title, description, url },
};
}
if (msgType === 'video' || msgType === 'shortvideo') {
return {
...base,
displayText: msgType === 'shortvideo' ? '收到小视频' : '收到视频',
agentText: '',
media: {
mediaId: inbound?.mediaId || '',
thumbMediaId: inbound?.thumbMediaId || '',
},
};
}
if (msgType === 'event') {
return {
...base,
displayText: inbound?.event ? `事件:${inbound.event}` : '事件消息',
agentText: '',
location:
inbound?.event === 'location'
? {
latitude: normalizeNumber(inbound?.latitude),
longitude: normalizeNumber(inbound?.longitude),
precision: normalizeNumber(inbound?.precision),
}
: undefined,
};
}
return base;
}
function successResponse(task = null) {
return {
ok: true,
status: 200,
contentType: 'text/plain; charset=utf-8',
body: 'success',
...(task ? { task } : {}),
};
}
function sha1Hex(parts) {
return crypto
.createHash('sha1')
.update([...parts].sort().join(''))
.digest('hex');
}
export function verifyWechatMpSignature({ token, timestamp, nonce, signature }) {
return sha1Hex([token, timestamp, nonce]) === String(signature ?? '');
}
export function verifyWechatMpMsgSignature({ token, timestamp, nonce, echoStr, msgSignature }) {
return sha1Hex([token, timestamp, nonce, echoStr]) === String(msgSignature ?? '');
}
function decodeWechatMpAesKey(encodingAesKey) {
const key = Buffer.from(`${String(encodingAesKey ?? '').trim()}=`, 'base64');
if (key.length !== 32) {
throw new Error('invalid encoding aes key');
}
return key;
}
export function decryptWechatMpPayload({ encodingAesKey, appId, encrypted }) {
const key = decodeWechatMpAesKey(encodingAesKey);
const iv = key.subarray(0, 16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
decipher.setAutoPadding(false);
const cipherBuf = Buffer.from(String(encrypted ?? ''), 'base64');
let decoded = Buffer.concat([decipher.update(cipherBuf), decipher.final()]);
const pad = decoded.at(-1);
if (!Number.isInteger(pad) || pad < 1 || pad > 32) {
throw new Error('invalid padding');
}
decoded = decoded.subarray(0, decoded.length - pad);
const content = decoded.subarray(16);
const msgLen = content.readUInt32BE(0);
const msg = content.subarray(4, 4 + msgLen).toString('utf8');
const receivedAppId = content.subarray(4 + msgLen).toString('utf8');
if (receivedAppId !== String(appId ?? '')) {
throw new Error('appid mismatch');
}
return msg;
}
export function verifyWechatMpUrlChallenge(query = {}, config = {}) {
const timestamp = String(query.timestamp ?? '');
const nonce = String(query.nonce ?? '');
const echostr = String(query.echostr ?? '');
const encryptType = String(query.encrypt_type ?? '').toLowerCase();
if (encryptType === 'aes') {
const msgSignature = String(query.msg_signature ?? '');
if (!config?.token || !config?.encodingAesKey || !config?.appId) {
return { ok: false, status: 503, body: 'wechat mp aes mode not configured' };
}
if (
!verifyWechatMpMsgSignature({
token: config.token,
timestamp,
nonce,
echoStr: echostr,
msgSignature,
})
) {
return { ok: false, status: 403, body: 'invalid signature' };
}
try {
const plain = decryptWechatMpPayload({
encodingAesKey: config.encodingAesKey,
appId: config.appId,
encrypted: echostr,
});
return { ok: true, status: 200, body: plain };
} catch {
return { ok: false, status: 403, body: 'invalid echostr' };
}
}
if (
!verifyWechatMpSignature({
token: config?.token,
timestamp,
nonce,
signature: String(query.signature ?? ''),
})
) {
return { ok: false, status: 403, body: 'invalid signature' };
}
return { ok: true, status: 200, body: echostr };
}
export function buildWechatTextReply({
toUserName,
fromUserName,
content,
createTime = Math.floor(Date.now() / 1000),
}) {
return [
'<xml>',
`<ToUserName><![CDATA[${toUserName ?? ''}]]></ToUserName>`,
`<FromUserName><![CDATA[${fromUserName ?? ''}]]></FromUserName>`,
`<CreateTime>${Number(createTime) || Math.floor(Date.now() / 1000)}</CreateTime>`,
'<MsgType><![CDATA[text]]></MsgType>',
`<Content><![CDATA[${String(content ?? '')}]]></Content>`,
'</xml>',
].join('');
}
export function createWechatMpService({
config,
userAuth,
sessionAccess = null,
apiFetch,
startAgentSession = null,
sessionApiFetch = null,
scheduleService = null,
2026-07-07 12:10:21 +08:00
wechatScheduleLlmConfigService = null,
llmProviderService = null,
2026-07-16 21:06:28 +08:00
onPageGenerated = null,
applySessionLlmProvider = null,
refreshSessionSnapshot = null,
pageDataFinishGuard = null,
wechatFetch = undiciFetch,
linkExists = defaultPublicHtmlLinkExists,
logger = console,
}) {
const sessionStore = resolveSessionAccess({ userAuth, sessionAccess });
if (!config?.enabled) {
return {
enabled: false,
};
}
config = {
...loadWechatMpConfig({}),
...config,
tokenUrl: config.tokenUrl || DEFAULT_WECHAT_TOKEN_URL,
customerServiceUrl: config.customerServiceUrl || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
jsapiTicketUrl: config.jsapiTicketUrl || DEFAULT_WECHAT_JSAPI_TICKET_URL,
mediaPublicBaseUrl: config.mediaPublicBaseUrl || config.publicBaseUrl,
maxImageBytes: Math.max(1, Number(config.maxImageBytes ?? 10 * 1024 * 1024)),
acceptVoice: config.acceptVoice !== false,
acceptImage: config.acceptImage !== false,
acceptLocation: config.acceptLocation !== false,
acceptLink: config.acceptLink !== false,
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
};
let accessTokenCache = {
token: null,
expiresAt: 0,
};
const rememberedWechatContexts = new Map();
let jsapiTicketCache = {
ticket: null,
expiresAt: 0,
};
const fetchForSession = (sessionId, pathname, init) =>
sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch(pathname, init);
const resolveWechatBillingTokenState = (sessionId, tokenState) =>
resolveBillingTokenState(tokenState, {
sessionId,
fetchSession: async (sid) => {
const response = await fetchForSession(sid, `/sessions/${encodeURIComponent(sid)}`);
if (!response?.ok) return null;
return readJsonResponse(response);
},
});
const buildBindUrl = () => {
if (/^https?:\/\//i.test(config.bindPath)) return config.bindPath;
const path = config.bindPath.startsWith('/') ? config.bindPath : `/${config.bindPath}`;
return `${config.publicBaseUrl}${path}`;
};
const verifyRequest = (query = {}) =>
verifyWechatMpSignature({
token: config.token,
timestamp: String(query.timestamp ?? ''),
nonce: String(query.nonce ?? ''),
signature: String(query.signature ?? ''),
});
const verifyUrlChallenge = (query = {}) => verifyWechatMpUrlChallenge(query, config);
const getStableAccessToken = async () => {
if (accessTokenCache.token && accessTokenCache.expiresAt > Date.now() + 60_000) {
return accessTokenCache.token;
}
const payload = await readJsonResponse(
await wechatFetch(config.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credential',
appid: config.appId,
secret: config.appSecret,
force_refresh: false,
}),
}),
);
if (!payload?.access_token) {
throw new Error(payload?.errmsg || '获取微信 access_token 失败');
}
const expiresIn = Number(payload.expires_in ?? 7200);
accessTokenCache = {
token: payload.access_token,
expiresAt: Date.now() + expiresIn * 1000,
};
return accessTokenCache.token;
};
const getJsapiTicket = async () => {
if (jsapiTicketCache.ticket && jsapiTicketCache.expiresAt > Date.now() + 60_000) {
return jsapiTicketCache.ticket;
}
const accessToken = await getStableAccessToken();
const url = new URL(config.jsapiTicketUrl);
url.searchParams.set('access_token', accessToken);
url.searchParams.set('type', 'jsapi');
const payload = await readJsonResponse(
await wechatFetch(url.toString(), {
method: 'GET',
headers: { Accept: 'application/json' },
}),
);
if (Number(payload?.errcode ?? 0) !== 0 || !payload?.ticket) {
throw new Error(payload?.errmsg || `获取微信 jsapi_ticket 失败 (${payload?.errcode})`);
}
const expiresIn = Number(payload.expires_in ?? 7200);
jsapiTicketCache = {
ticket: payload.ticket,
expiresAt: Date.now() + expiresIn * 1000,
};
return jsapiTicketCache.ticket;
};
const createJsSdkSignature = async (pageUrl) => {
const normalizedUrl = String(pageUrl ?? '').split('#')[0];
if (!/^https?:\/\//i.test(normalizedUrl)) {
throw new Error('签名 URL 必须是完整 http(s) 地址');
}
const ticket = await getJsapiTicket();
const nonceStr = crypto.randomBytes(12).toString('hex');
const timestamp = Math.floor(Date.now() / 1000);
const signature = crypto
.createHash('sha1')
.update(
[
`jsapi_ticket=${ticket}`,
`noncestr=${nonceStr}`,
`timestamp=${timestamp}`,
`url=${normalizedUrl}`,
].join('&'),
)
.digest('hex');
return {
appId: config.appId,
timestamp,
nonceStr,
signature,
url: normalizedUrl,
2026-06-29 22:20:04 +08:00
jsApiList: [
'startRecord',
'stopRecord',
'onVoiceRecordEnd',
'translateVoice',
'updateAppMessageShareData',
'updateTimelineShareData',
'onMenuShareAppMessage',
'onMenuShareTimeline',
],
};
};
2026-06-29 22:20:04 +08:00
const sendCustomerServiceText = async (
openid,
content,
user = null,
{ verifiedHtmlUrls = [], linkExistsForRequest = linkExists } = {},
) => {
const formatted = formatWechatOutboundText(sanitizeWechatAgentOutboundText(content), user);
const verifiedUrlSet = new Set(
verifiedHtmlUrls
.map((url) => String(url ?? '').trim())
.filter(Boolean),
);
const guarded = downgradePrematurePublishClaims(
await guardMissingPublicHtmlLinks(formatted, {
linkExists: async (url) => {
2026-06-29 22:20:04 +08:00
const normalized = String(url ?? '').trim();
if (verifiedUrlSet.has(normalized) && !linkExistsForRequest(normalized)) {
verifiedUrlSet.delete(normalized);
}
return linkExistsForRequest(url);
},
}),
);
const chunks = splitWechatText(guarded);
const accessToken = await getStableAccessToken();
for (const chunk of chunks) {
const payload = await readJsonResponse(
await wechatFetch(
`${config.customerServiceUrl}?access_token=${encodeURIComponent(accessToken)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
touser: openid,
msgtype: 'text',
text: {
content: chunk,
},
}),
},
),
);
if (Number(payload?.errcode ?? 0) !== 0) {
const errcode = Number(payload?.errcode ?? 0);
const errmsg = String(payload?.errmsg ?? '').trim() || 'unknown_error';
throw new Error(`微信客服消息发送失败 errcode=${errcode} errmsg=${errmsg}`);
}
}
};
const sendTextToUser = async (userId, content) => {
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
if (!openid) {
throw new Error('用户尚未绑定服务号,无法推送提醒');
}
await sendCustomerServiceText(openid, content);
};
const ensureSessionProvider = async (sessionId) => {
if (!applySessionLlmProvider || !sessionId) return;
const applied = await applySessionLlmProvider(sessionId);
if (applied && applied.ok === false) {
throw new Error(applied.message || '专属 Agent Provider 未配置');
}
};
const transcribeWechatVoiceMedia = async (mediaId, format) => {
if (!mediaId) return '';
const accessToken = await getStableAccessToken();
const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch });
if (!downloaded.buffer?.length) return '';
const extension = String(format ?? '').trim().toLowerCase() || 'amr';
const form = new FormData();
form.append(
'file',
new Blob([downloaded.buffer], {
type: guessVoiceMimeType(format, downloaded.contentType),
}),
`wechat-voice.${extension}`,
);
const response = await wechatFetch(`${config.asrTarget.replace(/\/$/, '')}/asr/oneshot`, {
method: 'POST',
body: form,
});
const { payload, text } = await readJsonBody(response);
if (!response.ok) {
throw new Error(sanitizeAsrMessage(payload?.message ?? text ?? `ASR HTTP ${response.status}`));
}
if (Number(payload?.code ?? 200) !== 200) {
throw new Error(sanitizeAsrMessage(payload?.message ?? text ?? '识别失败,请重试'));
}
return String(payload?.data?.text ?? '').trim();
};
const ensureWechatAgentSession = async ({
userId,
openid,
forceNew = false,
userContext = null,
}) => {
if (forceNew) {
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
2026-06-27 22:28:41 +08:00
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) {
const now = Date.now();
const routeUpdatedAt = Number(existingRoute.updatedAt ?? 0);
const routeIsIdle =
config.sessionIdleRotateMs > 0 &&
routeUpdatedAt > 0 &&
now - routeUpdatedAt > config.sessionIdleRotateMs;
let routeMessageCount = 0;
if (config.sessionMessageRotateCount > 0) {
const counts = [];
if (typeof userAuth.countWechatAgentSessionMessages === 'function') {
counts.push(
userAuth
.countWechatAgentSessionMessages({
appId: config.appId,
openid,
agentSessionId: existingRoute.agentSessionId,
})
.catch((err) => {
logger.warn?.('WeChat MP route message count lookup failed:', err);
return 0;
}),
);
}
if (typeof userAuth.getWechatAgentSessionSnapshotMessageCount === 'function') {
counts.push(
userAuth
.getWechatAgentSessionSnapshotMessageCount(existingRoute.agentSessionId)
.catch((err) => {
logger.warn?.('WeChat MP snapshot message count lookup failed:', err);
return 0;
}),
);
}
if (counts.length > 0) {
const resolved = await Promise.all(counts);
routeMessageCount = Math.max(0, ...resolved.map((value) => Number(value) || 0));
}
}
const routeIsTooLong =
config.sessionMessageRotateCount > 0 &&
Number(routeMessageCount ?? 0) >= config.sessionMessageRotateCount;
if (routeIsIdle || routeIsTooLong) {
await userAuth.clearWechatAgentRoute(config.appId, openid);
rememberedWechatContexts.delete(existingRoute.agentSessionId);
} else {
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,
2026-06-27 22:28:41 +08:00
sessionPolicy,
).catch(() => false);
if (routeHasTools) {
if (typeof userAuth.touchWechatAgentRoute === 'function') {
await userAuth.touchWechatAgentRoute(config.appId, openid, now).catch((err) => {
logger.warn?.('WeChat MP route touch failed:', err);
});
}
return { sessionId: existingRoute.agentSessionId, isNewSession: false };
}
await userAuth.clearWechatAgentRoute(config.appId, openid);
rememberedWechatContexts.delete(existingRoute.agentSessionId);
2026-06-27 22:28:41 +08:00
}
} 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 started = startAgentSession
? await startAgentSession({ userId, workingDir, sessionPolicy })
: await readJsonResponse(
await apiFetch('/agent/start', {
method: 'POST',
body: JSON.stringify({
working_dir: workingDir,
enable_context_memory: sessionPolicy.enableContextMemory,
...(sessionPolicy.extensionOverrides
? { extension_overrides: sessionPolicy.extensionOverrides }
: {}),
}),
}),
);
const sessionId = started?.id;
if (!sessionId) {
throw new Error('公众号专属 Agent 会话创建失败');
}
2026-06-27 18:33:53 +08:00
// `/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.
if (!startAgentSession) {
await sessionStore.registerSession({
userId,
sessionId,
target: 0,
origin: 'wechat',
});
}
await reconcileAgentSession(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
{
workingDir,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
userContext: publishLayout
? {
userId,
displayName: addressName || publishLayout.displayName,
username: addressName || null,
slug: null,
}
: null,
tolerateInvalidWorkingDir: true,
},
);
await userAuth.upsertWechatAgentRoute({
userId,
appId: config.appId,
openid,
agentSessionId: sessionId,
});
return { sessionId, isNewSession: true };
};
const refreshWechatSessionSnapshot = async (sessionId, userId) => {
if (!sessionId || !userId || typeof refreshSessionSnapshot !== 'function') return;
try {
await refreshSessionSnapshot(sessionId, userId);
} catch (err) {
logger.warn?.('WeChat MP snapshot refresh failed:', err);
}
};
const scheduleWechatSessionSnapshotRefresh = (sessionId, userId) => {
void refreshWechatSessionSnapshot(sessionId, userId);
};
const rememberWechatUserContext = async (sessionId, user, { forceBootstrap = false } = {}) => {
const addressName = resolveWechatAddressName(user);
if (!addressName) return;
const cacheKey = `${sessionId}:${addressName}`;
const alreadyRemembered = rememberedWechatContexts.get(sessionId) === cacheKey;
if (alreadyRemembered && !forceBootstrap) return;
try {
await readJsonResponse(
await fetchForSession(sessionId, '/agent/harness_remember', {
method: 'POST',
body: JSON.stringify({
sessionId,
title: '微信服务号用户',
content: [
`当前服务号用户名称:${addressName}`,
'这是通过微信 openid 在 H5 绑定库中查询到的用户名称。',
'回复时可以自然使用该名称称呼用户,不要使用 openid 或 wx_ 开头的内部用户名。',
].join('\n'),
}),
}),
);
await readJsonResponse(
await fetchForSession(sessionId, '/agent/harness_bootstrap', {
method: 'POST',
body: JSON.stringify({ sessionId, force: Boolean(forceBootstrap) }),
}),
);
rememberedWechatContexts.set(sessionId, cacheKey);
} catch (err) {
logger.warn?.('WeChat MP user context remember failed:', err);
}
};
const buildIntentMetadata = (intent) => ({
source: 'wechat_mp',
msgType: intent.msgType,
originalMsgId: intent.msgId || null,
displayText: intent.displayText || '',
mediaPublicUrl: intent.media?.publicUrl || null,
recognition: intent.msgType === 'voice' ? intent.agentText || null : null,
location: intent.location || null,
link: intent.link || null,
});
const persistIntentDetail = async ({ intent, userId = null, rawXmlHash = '' }) => {
if (typeof userAuth.insertWechatMpMessageDetail !== 'function') return;
await userAuth.insertWechatMpMessageDetail({
appId: config.appId,
openid: intent.openid,
msgId: intent.msgId || null,
userId,
msgType: intent.msgType,
displayText: intent.displayText || '',
agentText: intent.agentText || '',
mediaId: intent.media?.mediaId || null,
mediaUrl: intent.media?.picUrl || null,
mediaPublicUrl: intent.media?.publicUrl || null,
mediaFormat: intent.media?.format || null,
locationLat: intent.location?.latitude ?? null,
locationLng: intent.location?.longitude ?? null,
locationLabel: intent.location?.label || null,
linkUrl: intent.link?.url || null,
linkTitle: intent.link?.title || null,
rawXmlHash,
rawJson: intent.raw,
});
};
const resolveGrantedSkills = async (userId) => {
try {
const row = await userAuth.getUserById(userId);
if (!row) return [];
const caps = await userAuth.resolveUserCapabilities(row);
return Array.isArray(caps?.grantedSkills) ? caps.grantedSkills : [];
} catch (err) {
logger.warn?.('WeChat MP granted skills lookup failed:', err);
return [];
}
};
const runIntentMessage = async ({ inbound, intent, user }) => {
const wechatIntent = classifyWechatIntent(intent);
const resetCandidate =
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
// Page Data delivery owns persistent files, datasets and two publication
// policies. Reusing a conversational route here can make a new request
// inspect/retry unrelated historical pages from that session.
const isPageDataRequest = isPageDataIntent(resetCandidate);
const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate);
let route = await ensureWechatAgentSession({
userId: user.userId,
openid: inbound.fromUserName,
userContext: user,
forceNew,
});
let sessionId = route.sessionId;
2026-06-27 22:28:41 +08:00
const publishLayout = await userAuth.getUserPublishLayout(user.userId);
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
let finished = false;
let progressTimer = null;
if (config.progressDelayMs > 0 && config.progressText) {
progressTimer = setTimeout(() => {
if (finished) return;
sendCustomerServiceText(inbound.fromUserName, config.progressText, user).catch((err) => {
logger.warn?.('WeChat MP progress reply failed:', err);
});
}, config.progressDelayMs);
progressTimer.unref?.();
}
const requestId = crypto.randomUUID();
const guardScheduleReply = (replyText) =>
guardScheduleConfirmationReply({
replyText,
scheduleService,
userId: user.userId,
sourceMessageId: intent.msgId,
logger,
});
try {
const requestStartedAt = Date.now();
const agentPrompt =
wechatIntent.kind === 'page.generate'
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
: buildWechatAgentPrompt(intent);
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
requestId,
agentPrompt,
buildIntentMetadata(intent),
);
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
2026-06-29 22:20:04 +08:00
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
const {
publishedArtifacts,
verifiedArtifacts,
expectedArtifacts,
recentArtifacts,
confirmedArtifacts,
} = resolveHtmlPublishArtifacts({
reply,
intent,
workingDir,
publicBaseUrl: config.publicBaseUrl,
2026-06-29 22:20:04 +08:00
requestStartedAt,
2026-07-16 21:06:28 +08:00
userId: user.userId,
sessionId,
onPageGenerated,
});
2026-06-29 22:20:04 +08:00
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
confirmedArtifacts,
});
const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text);
const suspiciousPublishClaim =
expectedArtifacts.length === 0 &&
recentArtifacts.length === 0 &&
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest }));
const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({
reply,
intent,
confirmedArtifacts,
hasValidLinkInReply,
});
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
if (wechatIntent.kind === 'page.generate') {
const pageOutcome = resolvePageGenerateOutcome({
reply,
confirmedArtifacts,
verifiedArtifacts,
suspiciousPublishClaim,
bareCompletionReply,
htmlGenerationNeedsRetry,
replyHasPublicLinks,
});
if (pageOutcome.action === 'session_retry') {
throw new Error('stale_session_poisoned_completion');
}
if (pageOutcome.action === 'fail') {
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP page generate failure notice failed:', sendErr);
}
throw markWechatUserNotified(new Error(text));
}
publishArtifacts = pageOutcome.artifacts ?? publishArtifacts;
} else 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) {
2026-06-27 22:28:41 +08:00
throw new Error('stale_session_poisoned_completion');
}
const pageDataOutcome = await enforcePageDataCollectDelivery({
reply,
intent,
workingDir,
userId: user.userId,
pageDataFinishGuard,
publicBaseUrl: config.publicBaseUrl,
requestStartedAt,
notifyFailure: async (text) => {
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP page data failure notice failed:', sendErr);
}
},
});
if (pageDataOutcome?.deliveryArtifacts?.length) {
publishArtifacts = uniqueArtifactsByUrl([
...pageDataOutcome.deliveryArtifacts,
...publishArtifacts,
]);
}
if (reply.tokenState) {
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, requestId);
}
2026-06-27 22:28:41 +08:00
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir,
2026-06-27 22:28:41 +08:00
publicBaseUrl: config.publicBaseUrl,
artifacts: publishArtifacts,
});
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
2026-06-29 22:20:04 +08:00
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
2026-06-29 22:20:04 +08:00
linkExistsForRequest,
2026-06-27 22:28:41 +08:00
});
return { sessionId };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// A Page Data request must fail closed. Retrying a poisoned completion in
// another session while the finish guard is also active can turn one
// request into repairs against historical pages. Drop only this user's
// route so the next explicit request starts cleanly.
if (isPageDataRequest) {
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
logger.warn?.('WeChat MP page data route clear failed:', clearErr);
});
if (!err?.wechatUserNotified) {
const text = buildPageDataCollectFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP page data failure notice failed:', sendErr);
}
}
throw markWechatUserNotified(err instanceof Error ? err : new Error(message));
}
const mayBeStaleSession = sessionId && isRecoverableWechatAgentSessionError(message);
if (mayBeStaleSession) {
route = await ensureWechatAgentSession({
userId: user.userId,
openid: inbound.fromUserName,
forceNew: true,
userContext: user,
});
sessionId = route.sessionId;
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
const retryId = crypto.randomUUID();
const retryStartedAt = Date.now();
const retryPrompt =
wechatIntent.kind === 'page.generate'
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
: buildWechatAgentPrompt(intent);
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
retryId,
retryPrompt,
buildIntentMetadata(intent),
);
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
2026-06-29 22:20:04 +08:00
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
const {
publishedArtifacts,
verifiedArtifacts,
expectedArtifacts,
recentArtifacts,
confirmedArtifacts,
} = resolveHtmlPublishArtifacts({
reply,
intent,
workingDir,
publicBaseUrl: config.publicBaseUrl,
2026-06-29 22:20:04 +08:00
requestStartedAt: retryStartedAt,
2026-07-16 21:06:28 +08:00
userId: user.userId,
sessionId,
onPageGenerated,
});
2026-06-29 22:20:04 +08:00
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
confirmedArtifacts,
});
const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text);
const suspiciousPublishClaim =
expectedArtifacts.length === 0 &&
recentArtifacts.length === 0 &&
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest }));
const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({
reply,
intent,
confirmedArtifacts,
hasValidLinkInReply,
});
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
if (wechatIntent.kind === 'page.generate') {
const pageOutcome = resolvePageGenerateOutcome({
reply,
confirmedArtifacts,
verifiedArtifacts,
suspiciousPublishClaim,
bareCompletionReply,
htmlGenerationNeedsRetry,
replyHasPublicLinks,
});
if (pageOutcome.action === 'session_retry' || pageOutcome.action === 'fail') {
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP page generate retry failure notice failed:', sendErr);
}
throw markWechatUserNotified(new Error(text));
}
publishArtifacts = pageOutcome.artifacts ?? publishArtifacts;
} else 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) {
2026-06-29 22:20:04 +08:00
throw new Error(buildHtmlPublishFailureText());
2026-06-27 22:28:41 +08:00
}
const pageDataOutcome = await enforcePageDataCollectDelivery({
reply,
intent,
workingDir,
userId: user.userId,
pageDataFinishGuard,
publicBaseUrl: config.publicBaseUrl,
requestStartedAt: retryStartedAt,
notifyFailure: async (text) => {
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP page data retry failure notice failed:', sendErr);
}
},
});
if (pageDataOutcome?.deliveryArtifacts?.length) {
publishArtifacts = uniqueArtifactsByUrl([
...pageDataOutcome.deliveryArtifacts,
...publishArtifacts,
]);
}
if (reply.tokenState) {
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, retryId);
}
2026-06-27 22:28:41 +08:00
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir,
2026-06-27 22:28:41 +08:00
publicBaseUrl: config.publicBaseUrl,
artifacts: publishArtifacts,
});
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
2026-06-29 22:20:04 +08:00
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
2026-06-29 22:20:04 +08:00
linkExistsForRequest,
2026-06-27 22:28:41 +08:00
});
return { sessionId };
}
throw err;
} finally {
finished = true;
if (progressTimer) clearTimeout(progressTimer);
}
};
const handleInboundMessage = async (bodyText, query = {}) => {
if (!verifyRequest(query)) {
return { ok: false, status: 403, body: 'invalid signature' };
}
const inbound = parseWechatMessage(String(bodyText ?? ''));
const rawXmlHash = crypto.createHash('sha1').update(inbound.rawXml || '').digest('hex');
const intent = normalizeWechatInboundIntent(inbound);
intent.appId = config.appId;
if (!inbound.fromUserName || !inbound.toUserName || !inbound.msgType) {
return { ok: false, status: 400, body: 'invalid xml' };
}
if (String(query.encrypt_type ?? '').toLowerCase() === 'aes' || inbound.encrypt) {
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '当前公众号回调需使用明文模式接入,请联系管理员切换后再试。',
}),
};
}
if (inbound.msgType === 'event' && inbound.event === 'location') {
await persistIntentDetail({ intent, rawXmlHash });
return successResponse();
}
if (inbound.msgType === 'event' && inbound.event === 'subscribe') {
await persistIntentDetail({ intent, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: `${config.unboundTextPrefix}\n${buildBindUrl()}`,
}),
};
}
if (inbound.msgType === 'event') {
await persistIntentDetail({ intent, rawXmlHash });
return successResponse();
}
const supportedByConfig =
(intent.msgType === 'voice' && config.acceptVoice) ||
(intent.msgType === 'image' && config.acceptImage) ||
(intent.msgType === 'location' && config.acceptLocation) ||
(intent.msgType === 'link' && config.acceptLink) ||
intent.msgType === 'text' ||
intent.msgType === 'video' ||
intent.msgType === 'shortvideo';
if (!supportedByConfig) {
await persistIntentDetail({ intent, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: config.unsupportedText,
}),
};
}
const boundUser = await userAuth.findWechatUserByOpenid(config.appId, inbound.fromUserName);
if (!boundUser) {
await persistIntentDetail({ intent, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: `${config.unboundTextPrefix}\n${buildBindUrl()}`,
}),
};
}
if (boundUser.status === 'disabled') {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '当前账号不可用,请联系管理员处理。',
}),
};
}
if (intent.msgType === 'voice' && !intent.agentText.trim() && intent.media?.mediaId) {
try {
const fallbackText = await transcribeWechatVoiceMedia(intent.media.mediaId, intent.media.format);
if (fallbackText) {
intent.agentText = fallbackText;
intent.displayText = `语音:${fallbackText}`;
}
} catch (err) {
logger.warn?.('WeChat MP voice ASR fallback failed:', err);
}
}
if (intent.msgType === 'text' && !intent.agentText.trim()) {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: config.unsupportedText,
}),
};
}
if (intent.msgType === 'voice' && !intent.agentText.trim()) {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '未识别到语音文字,请再说一次或输入文字。',
}),
};
}
if (intent.msgType === 'image') {
try {
const accessToken = await getStableAccessToken();
const persisted = await persistWechatImage(
{
userId: boundUser.userId,
appId: config.appId,
openid: inbound.fromUserName,
msgId: inbound.msgId,
mediaId: inbound.mediaId,
picUrl: inbound.picUrl,
publicBaseUrl: config.mediaPublicBaseUrl,
maxImageBytes: config.maxImageBytes,
},
{
wechatFetch,
accessToken,
},
);
intent.media = {
...intent.media,
mediaId: inbound.mediaId || intent.media?.mediaId || '',
picUrl: inbound.picUrl || intent.media?.picUrl || '',
publicUrl: persisted.publicUrl,
format: persisted.contentType,
source: persisted.source,
};
intent.agentText = `[图片1]: ${persisted.publicUrl}`;
} catch (error) {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '图片处理失败,请稍后重试或直接发送文字说明。',
}),
};
}
}
if (
intent.msgType === 'location' &&
(intent.location?.latitude == null || intent.location?.longitude == null)
) {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '这次位置字段不完整,请重新发送位置或直接输入地点。',
}),
};
}
if (intent.msgType === 'link' && !intent.link?.url) {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '这次没有拿到完整链接,请重新发送一次。',
}),
};
}
if (intent.msgType === 'video' || intent.msgType === 'shortvideo') {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '已收到视频,当前先支持文本、语音、图片、定位和链接。',
}),
};
}
if (intent.msgType === 'text' || intent.msgType === 'voice') {
const wechatIntent = classifyWechatIntent(intent);
if (shouldHandleSyncReply(intent, wechatIntent)) {
const syncReply = resolveSyncReply(wechatIntent, boundUser, {
statusFallbackText: config.statusText,
});
if (syncReply) {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: syncReply,
}),
};
}
}
}
if (inbound.msgId && typeof userAuth.recordWechatMpMessage === 'function') {
const recorded = await userAuth.recordWechatMpMessage({
appId: config.appId,
openid: inbound.fromUserName,
msgId: inbound.msgId,
});
if (!recorded?.inserted) {
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: buildStatusText(boundUser, config.statusText),
}),
};
}
}
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
const scheduleReply =
intent.msgType === 'text' || intent.msgType === 'voice'
2026-07-07 12:10:21 +08:00
? await handleWechatScheduleIntent({
intent,
user: boundUser,
scheduleService,
wechatScheduleLlmConfigService,
llmProviderService,
logger,
})
: null;
if (scheduleReply) {
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
await userAuth.finishWechatMpMessage({
appId: config.appId,
openid: inbound.fromUserName,
msgId: inbound.msgId,
status: 'done',
agentSessionId: null,
});
}
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: scheduleReply,
}),
};
}
const task = runIntentMessage({
inbound,
intent,
user: boundUser,
})
.then(async ({ sessionId } = {}) => {
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
await userAuth.finishWechatMpMessage({
appId: config.appId,
openid: inbound.fromUserName,
msgId: inbound.msgId,
status: 'done',
agentSessionId: sessionId,
});
}
})
.catch(async (err) => {
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
await userAuth.finishWechatMpMessage({
appId: config.appId,
openid: inbound.fromUserName,
msgId: inbound.msgId,
status: 'failed',
}).catch(() => {});
}
logger.error?.('WeChat MP background reply failed:', err);
if (wasWechatUserNotified(err)) return;
try {
await sendCustomerServiceText(
inbound.fromUserName,
formatWechatAgentFailureMessage(err),
boundUser,
);
} catch (sendErr) {
logger.error?.('WeChat MP failure notice failed:', sendErr);
}
});
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: buildAckText({
intent,
nickname: resolveWechatAddressName(boundUser),
config,
fallbackText: config.ackText,
}),
}),
task,
};
};
const getRouteStatusForUser = async (userId) => {
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
if (!openid) {
return {
enabled: true,
bound: false,
appId: config.appId,
openid: null,
agentSessionId: null,
routeStatus: null,
updatedAt: null,
};
}
const route = await userAuth.getWechatAgentRoute(config.appId, openid);
return {
enabled: true,
bound: true,
appId: config.appId,
openid,
agentSessionId: route?.agentSessionId ?? null,
routeStatus: route?.status ?? null,
updatedAt: route?.updatedAt ?? null,
};
};
const recreateRouteForUser = async (userId) => {
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
if (!openid) {
return {
ok: false,
message: '当前账号尚未绑定该服务号',
};
}
const { sessionId } = await ensureWechatAgentSession({
userId,
openid,
forceNew: true,
});
const route = await userAuth.getWechatAgentRoute(config.appId, openid);
return {
ok: true,
route: {
enabled: true,
bound: true,
appId: config.appId,
openid,
agentSessionId: sessionId,
routeStatus: route?.status ?? 'active',
updatedAt: route?.updatedAt ?? null,
},
};
};
return {
enabled: true,
verifyRequest,
verifyUrlChallenge,
handleInboundMessage,
getRouteStatusForUser,
recreateRouteForUser,
createJsSdkSignature,
sendTextToUser,
};
}