Files
memind/wechat-mp.mjs
T

3314 lines
116 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fetch as undiciFetch } from 'undici';
import { developerToolsFromPolicy } from './capabilities.mjs';
import { mergeMessageContent } from './message-stream.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { resolveSessionAccess } from './session-broker.mjs';
import {
extractPublicHtmlWriteArtifacts,
isStubPublicHtmlContent,
materializeMissingPublicHtmlWrites,
} from './mindspace-public-finish-sync.mjs';
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs';
import {
downloadTemporaryMedia,
persistWechatAttachment,
persistWechatImage,
uploadWechatGeneratedImage,
} 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 {
buildWechatImageRunMetadata,
resolveWechatImageGenerationPolicy,
WECHAT_PAGE_THUMBNAIL_MODE,
} from './wechat/image-generation-policy.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 {
collectWechatGeneratedImages,
repairUnambiguousFreshWechatPageThumbnail,
summarizeFreshWechatThumbnailVerification,
verifyFreshWechatPageThumbnails,
} from './wechat/verify/generated-thumbnail.mjs';
import { resolveBillingTokenState } from './billing-token-state.mjs';
import { ensureWorkspaceHtmlThumbnail } from './mindspace-workspace-thumbnails.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';
const WECHAT_RECENT_MEDIA_TTL_MS = 15 * 60 * 1000;
const WECHAT_RECENT_IMAGE_MAX_COUNT = 10;
const DEFAULT_WECHAT_AGENT_REPLY_TIMEOUT_MS = 15 * 60 * 1000;
export { loadWechatMpConfig };
const PUBLIC_HTML_LINK_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
const SESSION_WORKSPACE_TOOL_NAMES = new Set([
'write',
'edit',
'tree',
'read_file',
'write_file',
'edit_file',
'create_dir',
'list_dir',
]);
function 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'),
fileName: parseXmlField(xml, 'FileName') || parseXmlField(xml, 'Filename'),
fileSize: parseXmlField(xml, 'FileSize'),
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];
}
export async function executeSessionReply(
apiFetch,
sessionId,
requestId,
prompt,
metadata = {},
{ submitReply = null, prepareUserMessage = null, timeoutMs = 0 } = {},
) {
const normalizedTimeoutMs = Math.max(0, Number(timeoutMs) || 0);
const abortController = new AbortController();
let timedOut = false;
let reader = null;
const timeout = normalizedTimeoutMs > 0
? setTimeout(() => {
timedOut = true;
abortController.abort();
void reader?.cancel?.().catch?.(() => {});
}, normalizedTimeoutMs)
: null;
try {
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
method: 'GET',
headers: { Accept: 'text/event-stream' },
signal: abortController.signal,
});
if (!eventsResponse.ok || !eventsResponse.body) {
const text = await eventsResponse.text().catch(() => '');
throw new Error(text || '无法建立公众号消息事件流');
}
let userMessage = createUserMessage(prompt, metadata);
if (prepareUserMessage) {
userMessage = (await prepareUserMessage(userMessage)) ?? userMessage;
}
if (submitReply) {
await submitReply({ sessionId, requestId, userMessage, signal: abortController.signal });
} else {
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
method: 'POST',
body: JSON.stringify({
request_id: requestId,
user_message: userMessage,
}),
signal: abortController.signal,
});
if (!replyResponse.ok) {
const text = await replyResponse.text().catch(() => '');
throw new Error(text || 'Agent reply 请求失败');
}
replyResponse.body?.cancel().catch?.(() => {});
}
reader = eventsResponse.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let messages = [];
let requestMessages = [];
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('当前回复需要人工确认,公众号通道暂不支持');
}
if (event.message.role === 'assistant') hasScopedAssistantUpdate = true;
messages = pushMessage(messages, event.message);
requestMessages = pushMessage(requestMessages, event.message);
} else if (event.type === 'UpdateConversation') {
// Ignore unscoped snapshots until this request has yielded an assistant update.
// Otherwise a stale session snapshot can overwrite the current reply with a
// previous page/link from the same WeChat-dedicated session.
if (hasScopedAssistantUpdate) {
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
}
} else if (event.type === 'Error') {
throw new Error(event.error || '任务执行失败');
} else if (event.type === 'Finish') {
const assistant = [...messages].reverse().find((item) => item.role === 'assistant');
if (!hasScopedAssistantUpdate || !assistant) {
throw new Error('本轮未收到可发送的新回复,请稍后重试');
}
const reply = {
text: messageVisibleText(assistant),
tokenState: event.token_state ?? null,
messages,
// Keep request-scoped stream messages separate from a later full
// UpdateConversation snapshot. Artifact delivery must never inspect
// historical tool calls from the whole dedicated session.
requestMessages,
};
assertWechatAgentReplyIsSendable(reply);
return reply;
}
}
}
throw new Error('公众号消息事件流提前结束');
} catch (err) {
if (timedOut) {
const timeoutError = new Error(`公众号消息处理超时(${normalizedTimeoutMs}ms),请稍后重试`);
timeoutError.code = 'WECHAT_AGENT_REPLY_TIMEOUT';
timeoutError.retryable = true;
throw timeoutError;
}
throw err;
} finally {
if (timeout) clearTimeout(timeout);
await reader?.cancel?.().catch?.(() => {});
}
}
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 : ['我先收到了,但这次没有生成可发送的文本结果。'];
}
function appendGeneratedImageFallbackLink(text, generatedImage) {
const normalized = String(text ?? '').trim();
const publicUrl = String(generatedImage?.publicUrl ?? '').trim();
if (!publicUrl || normalized.includes(publicUrl)) return normalized;
return [normalized, `图片链接:${publicUrl}`].filter(Boolean).join('\n');
}
export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES };
function flattenSessionTools(extensions = []) {
return new Set(
extensions.flatMap((extension) =>
Array.isArray(extension?.available_tools) ? extension.available_tools : [],
),
);
}
function needsWorkspaceTools(sessionPolicy) {
return developerToolsFromPolicy(sessionPolicy).some((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool));
}
async function readSessionExtensions(fetchForSession, sessionId) {
const payload = await readJsonResponse(await fetchForSession(sessionId, `/sessions/${sessionId}/extensions`));
return payload?.extensions ?? [];
}
async function sessionHasRequiredTools(fetchForSession, sessionId, sessionPolicy) {
if (!needsWorkspaceTools(sessionPolicy)) return true;
const desired = developerToolsFromPolicy(sessionPolicy).filter((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool));
if (desired.length === 0) return true;
const available = flattenSessionTools(await readSessionExtensions(fetchForSession, sessionId));
return desired.some((tool) => available.has(tool));
}
function extractHtmlWriteTargets(messages = []) {
const targets = new Set();
for (const message of messages) {
for (const item of message?.content ?? []) {
if (item?.type !== 'toolRequest') continue;
const toolCall = item.toolCall?.value;
const name = toolCall?.name;
const args = toolCall?.arguments ?? {};
const action = String(args.action ?? '').toLowerCase();
const writeLikeDeveloper = name === 'developer' && action === 'write';
const normalizedName = String(name ?? '').trim();
const writeLikeSandbox =
(normalizedName === 'write_file' ||
normalizedName === 'edit_file' ||
normalizedName.endsWith('__write_file') ||
normalizedName.endsWith('__edit_file')) &&
typeof args.path === 'string';
if (!writeLikeDeveloper && !writeLikeSandbox) continue;
const candidate = String(args.path ?? '').trim();
if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate);
}
}
return [...targets];
}
function looksLikeHtmlGenerationIntent(text) {
return isPageGenerateText(text);
}
function replyRequestMessages(reply) {
return reply?.requestMessages ?? reply?.messages ?? [];
}
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';
}),
);
}
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(replyRequestMessages(reply)).length > 0) return false;
return !hasAnyToolRequest(replyRequestMessages(reply));
}
function looksLikePublishSuccessClaim(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return /(?:页面|网页|html).*(?:已创建|已生成|已发布|创建完毕|生成完成|发布成功)|(?:成功发布|已经发布|已创建完毕).*(?:页面|网页|html)/iu.test(normalized);
}
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
}
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(replyRequestMessages(reply)).length > 0) return false;
return !(await hasAnyValidPublishedHtmlLink(text, linkExists));
}
function isMissingRequiredPublishSkill(reply, intent) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
const wroteHtml = extractHtmlWriteTargets(replyRequestMessages(reply)).length > 0;
if (!wroteHtml) return false;
return !usedStaticPagePublishSkill(replyRequestMessages(reply));
}
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
const owner = path.basename(path.resolve(workingDir));
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
return buildPublicUrl(publicBaseUrl, owner, normalized);
}
function ensurePublicHtmlArtifact(htmlPath, workingDir) {
const workspaceRoot = path.resolve(workingDir);
const source = path.isAbsolute(String(htmlPath ?? ''))
? path.resolve(String(htmlPath))
: path.resolve(workspaceRoot, String(htmlPath ?? ''));
if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null;
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null;
const publicRoot = path.join(workspaceRoot, 'public');
let publishedPath = source;
if (source !== publicRoot && !source.startsWith(`${publicRoot}${path.sep}`)) {
fs.mkdirSync(publicRoot, { recursive: true });
publishedPath = path.join(publicRoot, path.basename(source));
if (publishedPath !== source) fs.copyFileSync(source, publishedPath);
}
const relativePath = path.relative(workspaceRoot, publishedPath);
if (!relativePath || relativePath.startsWith('..')) return null;
return {
localPath: publishedPath,
relativePath,
};
}
function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) {
const artifacts = [];
const htmlTargets = extractHtmlWriteTargets(replyRequestMessages(reply));
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;
}
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);
}
return filenames;
}
function collectRecentPublishedHtmlArtifacts(
intent,
{ 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, '/') : '';
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;
const normalizedRelativePath = relativePath.replace(/\\/g, '/');
const filename = path.posix.basename(normalizedRelativePath);
if (linkedFilenames.size > 0 && filename && !linkedFilenames.has(filename)) continue;
artifacts.push({
localPath: absolutePath,
relativePath: normalizedRelativePath,
url: buildPublicHtmlUrl(workingDir, normalizedRelativePath, publicBaseUrl),
mtimeMs: Number(stat.mtimeMs ?? 0),
matchesExpected: expectedRelativePath
? 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,
allowAttachment = true,
},
) {
if (!allowAttachment) return String(reply?.text ?? '').trim();
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;
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();
}
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('简版页')
);
}
export { maybeAttachPublishedHtmlLink };
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,
userId = '',
sessionId = '',
onPageGenerated = null,
allowRecentArtifacts = true,
}) {
const artifactMessages = reply?.requestMessages ?? reply?.messages ?? [];
const artifactReply = { ...reply, messages: artifactMessages };
materializeMissingPublicHtmlWrites({
messages: artifactMessages,
publishDir: workingDir,
});
const publishedArtifacts = collectPublishedHtmlArtifacts(artifactReply, {
workingDir,
publicBaseUrl,
});
const expectedArtifacts = collectExpectedHtmlArtifacts(intent, {
workingDir,
publicBaseUrl,
});
const recentArtifacts = allowRecentArtifacts
? collectRecentPublishedHtmlArtifacts(intent, {
workingDir,
publicBaseUrl,
replyText: reply?.text,
sinceMs: requestStartedAt,
})
: [];
const confirmedArtifacts = allExistingHtmlArtifacts({
publishedArtifacts,
expectedArtifacts,
recentArtifacts,
});
const verifiedArtifacts = buildVerifiedHtmlArtifacts({
publishedArtifacts,
expectedArtifacts,
recentArtifacts,
replyText: reply?.text,
});
if (
typeof onPageGenerated === 'function'
&& confirmedArtifacts.length > 0
&& (allowRecentArtifacts || publishedArtifacts.length > 0)
) {
void onPageGenerated({ userId, sessionId, artifacts: confirmedArtifacts });
}
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));
}
export function shouldRetryHtmlGenerationReply({
reply,
intent,
confirmedArtifacts = [],
hasValidLinkInReply = false,
}) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
if (nonStubHtmlArtifacts(confirmedArtifacts).length > 0) return false;
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;
}
if (!usedStaticPagePublishSkill(replyRequestMessages(reply))) return false;
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 (/wechat_page_fresh_thumbnail_required:/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;
if (/historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(normalized)) {
return true;
}
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
if (/wechat_agent_incomplete_reply/i.test(normalized)) return true;
return false;
}
export function shouldDeliverWechatHtmlArtifacts(wechatIntent, intent) {
return (
wechatIntent?.kind === 'page.generate'
|| looksLikeHtmlGenerationIntent(intent?.agentText)
|| isPageDataIntent(intent?.agentText)
);
}
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 replyRequestMessages(reply)) {
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);
const text = String(reply?.text ?? '').trim();
if (
/^(?:let me|i(?:'ll| will))\s+(?:first\s+)?(?:look|check|inspect|analy[sz]e)(?:\s+at)?\s+(?:the\s+)?image(?:\s+first)?[.!]?$/iu.test(text) ||
/^(?:让我|我先)(?:先)?(?:看|查看|检查|分析)(?:一下)?(?:这张|该张|这个)?图片[。!!]?$/u.test(text)
) {
throw new Error('wechat_agent_incomplete_reply');
}
}
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 (err?.code === 'WECHAT_IMAGE_GENERATION_REQUIRED') {
return '这次图片生成没有获得新的有效位图,所以我没有发送旧图或占位图。请稍后重试。';
}
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 isWechatMediaGrayUser(user, configuredUsers = []) {
const allowlist = Array.isArray(configuredUsers)
? configuredUsers.map((value) => String(value ?? '').trim().toLowerCase()).filter(Boolean)
: [];
if (allowlist.length === 0) return false;
if (allowlist.includes('*')) return true;
const identities = [
user?.userId,
user?.username,
user?.slug,
user?.displayName,
user?.nickname,
]
.map((value) => String(value ?? '').trim().toLowerCase())
.filter(Boolean);
return identities.some((identity) => allowlist.includes(identity));
}
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 === 'file') {
const filename = String(inbound?.fileName ?? '').trim();
return {
...base,
displayText: filename ? `收到文件:${filename}` : '收到文件',
agentText: '',
media: {
mediaId: inbound?.mediaId || '',
},
attachment: {
filename,
sizeBytes: normalizeNumber(inbound?.fileSize),
},
};
}
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,
submitSessionReply = null,
scheduleService = null,
wechatScheduleLlmConfigService = null,
llmProviderService = null,
chatIntentRouter = null,
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,
};
}
const requireFreshPageThumbnail = config.requireFreshPageThumbnail === true;
const repairFreshPageThumbnail = config.repairFreshPageThumbnail === true;
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)),
maxFileBytes: Math.max(1, Number(config.maxFileBytes ?? 30 * 1024 * 1024)),
acceptVoice: config.acceptVoice !== false,
acceptImage: config.acceptImage !== false,
acceptFile: config.acceptFile !== false,
acceptLocation: config.acceptLocation !== false,
acceptLink: config.acceptLink !== false,
mediaAnalysisGrayUsers: Array.isArray(config.mediaAnalysisGrayUsers)
? config.mediaAnalysisGrayUsers
: [],
reliabilityGrayUsers: Array.isArray(config.reliabilityGrayUsers)
? config.reliabilityGrayUsers
: [],
agentReplyTimeoutMs: Math.max(
0,
Number(config.agentReplyTimeoutMs ?? DEFAULT_WECHAT_AGENT_REPLY_TIMEOUT_MS),
),
requireFreshPageThumbnail,
repairFreshPageThumbnail,
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
};
let accessTokenCache = {
token: null,
expiresAt: 0,
};
const rememberedWechatContexts = new Map();
const messageTasksByOpenid = new Map();
const recentMediaByOpenid = new Map();
let jsapiTicketCache = {
ticket: null,
expiresAt: 0,
};
const fetchForSession = (sessionId, pathname, init) =>
sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch(pathname, init);
const enqueueMessageTask = (openid, taskFactory) => {
const key = String(openid ?? '').trim();
const previous = messageTasksByOpenid.get(key) ?? Promise.resolve();
const task = previous.catch(() => undefined).then(taskFactory);
messageTasksByOpenid.set(key, task);
void task
.finally(() => {
if (messageTasksByOpenid.get(key) === task) messageTasksByOpenid.delete(key);
})
.catch(() => {});
return task;
};
const rememberRecentMedia = (openid, intent) => {
const publicUrl = String(intent?.media?.publicUrl ?? '').trim();
if (!publicUrl) return;
const key = String(openid ?? '').trim();
const now = Date.now();
const item = {
media: { ...(intent.media ?? {}) },
attachment: intent.attachment ? { ...intent.attachment } : null,
};
const current = recentMediaByOpenid.get(key);
const canAppendImage =
intent?.msgType === 'image' &&
current &&
!current.claimed &&
now - current.rememberedAt <= WECHAT_RECENT_MEDIA_TTL_MS &&
current.items.every((recentItem) => !recentItem.attachment);
const candidates = canAppendImage ? [...current.items, item] : [item];
const deduped = candidates.filter(
(candidate, index, values) =>
values.findIndex(
(value) => String(value?.media?.publicUrl ?? '') === String(candidate?.media?.publicUrl ?? ''),
) === index,
);
recentMediaByOpenid.set(key, {
items: deduped.slice(-WECHAT_RECENT_IMAGE_MAX_COUNT),
rememberedAt: now,
batchId: crypto.randomUUID(),
claimed: false,
});
};
const attachRecentMediaForFollowup = (openid, intent, mediaAnalysisEnabled) => {
if (!mediaAnalysisEnabled || intent?.msgType !== 'text' || intent?.media?.publicUrl) return;
const text = String(intent?.agentText ?? '');
if (!/(?:刚才|之前|上一|这张|这份|图片|图像|照片|文件|文档|表格|excel|word)/iu.test(text)) return;
const key = String(openid ?? '').trim();
const recent = recentMediaByOpenid.get(key);
if (
!recent ||
recent.claimed ||
Date.now() - recent.rememberedAt > WECHAT_RECENT_MEDIA_TTL_MS ||
recent.items.length === 0
) {
return;
}
const items = recent.items.map((item) => ({
media: { ...(item.media ?? {}), source: 'wechat_recent_media' },
attachment: item.attachment ? { ...item.attachment } : null,
}));
const primary = items.at(-1);
intent.media = { ...(primary?.media ?? {}) };
if (primary?.attachment) intent.attachment = { ...primary.attachment };
intent.recentMediaItems = items;
intent.recentMediaBatchId = recent.batchId;
recent.claimed = true;
};
const settleRecentMediaBatch = (openid, intent, { succeeded }) => {
const batchId = String(intent?.recentMediaBatchId ?? '').trim();
if (!batchId) return;
const key = String(openid ?? '').trim();
const recent = recentMediaByOpenid.get(key);
if (!recent || recent.batchId !== batchId) return;
if (succeeded) recentMediaByOpenid.delete(key);
else recent.claimed = false;
};
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,
jsApiList: [
'startRecord',
'stopRecord',
'onVoiceRecordEnd',
'translateVoice',
'updateAppMessageShareData',
'updateTimelineShareData',
'onMenuShareAppMessage',
'onMenuShareTimeline',
],
};
};
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) => {
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 sendCustomerServiceImage = async (openid, generatedImage) => {
const publicUrl = String(generatedImage?.publicUrl ?? '').trim();
if (!publicUrl) throw new Error('生成图片缺少可发送的公网地址');
const accessToken = await getStableAccessToken();
const uploaded = await uploadWechatGeneratedImage(accessToken, publicUrl, {
wechatFetch,
publicBaseUrl: config.publicBaseUrl,
allowedPublicBaseUrls: config.generatedImagePublicBaseUrls,
});
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: 'image',
image: { media_id: uploaded.mediaId },
}),
},
),
);
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}`);
}
return uploaded;
};
const sendTextToUser = async (userId, content) => {
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
if (!openid) {
throw new Error('用户尚未绑定服务号,无法推送提醒');
}
await sendCustomerServiceText(openid, content);
};
const enforceFreshPageThumbnailDelivery = async ({
artifacts,
reply,
openid,
user,
imagePolicy,
publishDir,
notifyFailure = true,
}) => {
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
const images = collectWechatGeneratedImages(replyRequestMessages(reply));
let verification = verifyFreshWechatPageThumbnails(artifacts, images);
if (!verification.ok) {
logger.warn?.(
'WeChat MP fresh thumbnail verification failed:',
summarizeFreshWechatThumbnailVerification(verification, images),
);
if (config.repairFreshPageThumbnail) {
const repair = repairUnambiguousFreshWechatPageThumbnail({
artifacts,
images,
currentRunHtmlArtifacts: extractPublicHtmlWriteArtifacts(replyRequestMessages(reply), {
publishDir,
}),
verificationReason: verification.reason,
});
if (repair.ok) {
verification = verifyFreshWechatPageThumbnails(artifacts, images);
logger.info?.('WeChat MP fresh thumbnail repaired:', {
relativePath: String(repair.artifact?.relativePath ?? ''),
jobId: String(repair.image?.jobId ?? ''),
cover: repair.cover,
verified: verification.ok,
});
} else {
logger.warn?.('WeChat MP fresh thumbnail repair skipped:', {
reason: repair.reason,
artifactCount: repair.artifactCount,
imageCount: repair.imageCount,
});
}
}
}
if (verification.ok) {
try {
for (const match of verification.matches) {
const htmlRelativePath = path.relative(publishDir, match.artifact.localPath);
if (!htmlRelativePath || htmlRelativePath.startsWith('..') || path.isAbsolute(htmlRelativePath)) {
throw new Error('缩略图页面路径不在用户发布目录内');
}
await ensureWorkspaceHtmlThumbnail(publishDir, htmlRelativePath);
}
return;
} catch (error) {
verification = {
...verification,
ok: false,
reason: `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`,
};
}
}
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
if (notifyFailure) {
try {
await sendCustomerServiceText(openid, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
}
}
const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`);
error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED';
throw notifyFailure ? markWechatUserNotified(error) : error;
};
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);
}
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,
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);
}
} 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 会话创建失败');
}
// `/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, { mediaAnalysisEnabled = false, imagePolicy = null } = {}) => {
const mediaPublicUrl = intent.media?.publicUrl || null;
const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0
? intent.recentMediaItems
: mediaPublicUrl
? [{ media: intent.media, attachment: intent.attachment ?? null }]
: [];
const imageUrls = mediaItems
.filter((item) => !item.attachment)
.map((item) => String(item?.media?.publicUrl ?? '').trim())
.filter((url, index, values) => url && values.indexOf(url) === index);
const fileAttachments = mediaItems
.filter((item) => item.attachment?.filename && item.media?.publicUrl)
.map((item) => ({
assetId: '',
downloadUrl: item.media.publicUrl,
filename: item.attachment.filename,
mimeType: item.attachment.mimeType || 'application/octet-stream',
}));
return {
source: 'wechat_mp',
msgType: intent.msgType,
originalMsgId: intent.msgId || null,
displayText: intent.displayText || '',
mediaPublicUrl,
...(mediaAnalysisEnabled && imageUrls.length > 0
? { imageUrls }
: {}),
...(mediaAnalysisEnabled && fileAttachments.length > 0 ? { fileAttachments } : {}),
recognition: intent.msgType === 'voice' ? intent.agentText || null : null,
location: intent.location || null,
link: intent.link || null,
memindRun: buildWechatImageRunMetadata(imagePolicy),
};
};
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 prepareWechatAgentUserMessage = async ({ userId, sessionId, userMessage }) => {
if (
!chatIntentRouter?.resolveAgentMemoryContext
|| !chatIntentRouter?.applyAgentOrchestration
) {
return userMessage;
}
const displayText = String(
userMessage?.metadata?.displayText
?? messageVisibleText(userMessage),
).trim();
try {
const memoryContext = await chatIntentRouter.resolveAgentMemoryContext({
userId,
sessionId,
text: displayText,
forceDeepReasoning: false,
});
if (
!memoryContext?.injectionEnabled
|| !Array.isArray(memoryContext.memories)
|| memoryContext.memories.length === 0
) {
return userMessage;
}
return chatIntentRouter.applyAgentOrchestration(
userMessage,
{
route: 'agent_orchestration',
reason: '微信服务号消息由 Agent 处理',
source: 'wechat_mp',
},
{ memoryContext },
);
} catch (err) {
logger.warn?.(
'WeChat MP agent memory resolve skipped:',
err instanceof Error ? err.message : err,
);
return userMessage;
}
};
const runIntentMessage = async ({ inbound, intent, user }) => {
const wechatIntent = classifyWechatIntent(intent);
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
const reliabilityEnabled = isWechatMediaGrayUser(user, config.reliabilityGrayUsers);
const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0;
const resetCandidate =
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
const imagePolicy = resolveWechatImageGenerationPolicy({
text: resetCandidate,
isPageGenerate: wechatIntent.kind === 'page.generate',
requireFreshPageThumbnail: config.requireFreshPageThumbnail,
});
// 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 htmlArtifactDeliveryExpected = shouldDeliverWechatHtmlArtifacts(wechatIntent, intent);
const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate);
let route = await ensureWechatAgentSession({
userId: user.userId,
openid: inbound.fromUserName,
userContext: user,
forceNew,
});
let sessionId = route.sessionId;
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,
imagePolicy,
})
: buildWechatAgentPrompt(intent, { imagePolicy });
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
requestId,
agentPrompt,
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
{
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
userId: user.userId,
sessionId,
userMessage,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
submitSessionReply({
userId: user.userId,
sessionId,
requestId: replyRequestId,
userMessage,
options: { requireHistoricalImageIsolation: true },
})
: null,
timeoutMs: agentReplyTimeoutMs,
},
);
const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
throw error;
}
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
const {
publishedArtifacts,
verifiedArtifacts,
expectedArtifacts,
recentArtifacts,
confirmedArtifacts,
} = resolveHtmlPublishArtifacts({
reply,
intent,
workingDir,
publicBaseUrl: config.publicBaseUrl,
requestStartedAt,
userId: user.userId,
sessionId,
onPageGenerated,
allowRecentArtifacts: htmlArtifactDeliveryExpected,
});
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 =
htmlArtifactDeliveryExpected || publishedArtifacts.length > 0
? 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) {
throw new Error('stale_session_poisoned_completion');
}
if (wechatIntent.kind === 'page.generate') {
await enforceFreshPageThumbnailDelivery({
artifacts: publishArtifacts,
reply,
openid: inbound.fromUserName,
user,
imagePolicy,
publishDir: workingDir,
notifyFailure: false,
});
}
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);
}
let finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
artifacts: publishArtifacts,
allowAttachment: publishArtifacts.length > 0,
});
if (
wechatIntent.kind !== 'page.generate'
&& imagePolicy.standaloneImageMode !== 'disabled'
&& generatedImages.length > 0
) {
try {
await sendCustomerServiceImage(inbound.fromUserName, generatedImages[0]);
} catch (sendErr) {
logger.warn?.('WeChat MP generated image native delivery failed, falling back to link:', sendErr);
finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]);
}
}
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
linkExistsForRequest,
});
return { sessionId };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (err?.code === 'WECHAT_AGENT_REPLY_TIMEOUT') {
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
logger.warn?.('WeChat MP timed-out session route clear failed:', clearErr);
});
}
// 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) || isWechatAgentApiErrorText(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,
imagePolicy,
})
: buildWechatAgentPrompt(intent, { imagePolicy });
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
retryId,
retryPrompt,
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
{
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
userId: user.userId,
sessionId,
userMessage,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
submitSessionReply({
userId: user.userId,
sessionId,
requestId: replyRequestId,
userMessage,
options: { requireHistoricalImageIsolation: true },
})
: null,
timeoutMs: agentReplyTimeoutMs,
},
);
const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
throw error;
}
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
const {
publishedArtifacts,
verifiedArtifacts,
expectedArtifacts,
recentArtifacts,
confirmedArtifacts,
} = resolveHtmlPublishArtifacts({
reply,
intent,
workingDir,
publicBaseUrl: config.publicBaseUrl,
requestStartedAt: retryStartedAt,
userId: user.userId,
sessionId,
onPageGenerated,
allowRecentArtifacts: htmlArtifactDeliveryExpected,
});
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 =
htmlArtifactDeliveryExpected || publishedArtifacts.length > 0
? 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) {
throw new Error(buildHtmlPublishFailureText());
}
if (wechatIntent.kind === 'page.generate') {
await enforceFreshPageThumbnailDelivery({
artifacts: publishArtifacts,
reply,
openid: inbound.fromUserName,
user,
imagePolicy,
publishDir: workingDir,
});
}
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);
}
let finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
artifacts: publishArtifacts,
allowAttachment: publishArtifacts.length > 0,
});
if (
wechatIntent.kind !== 'page.generate'
&& imagePolicy.standaloneImageMode !== 'disabled'
&& generatedImages.length > 0
) {
try {
await sendCustomerServiceImage(inbound.fromUserName, generatedImages[0]);
} catch (sendErr) {
logger.warn?.('WeChat MP generated image native delivery failed, falling back to link:', sendErr);
finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]);
}
}
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
linkExistsForRequest,
});
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 === 'file' && config.acceptFile) ||
(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: '当前账号不可用,请联系管理员处理。',
}),
};
}
const mediaAnalysisEnabled = isWechatMediaGrayUser(
boundUser,
config.mediaAnalysisGrayUsers,
);
attachRecentMediaForFollowup(inbound.fromUserName, intent, mediaAnalysisEnabled);
if (intent.msgType === 'file' && !mediaAnalysisEnabled) {
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: '当前账号尚未开启服务号文件分析灰度,请先通过 H5 上传文件。',
}),
};
}
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}`;
rememberRecentMedia(inbound.fromUserName, intent);
} 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 === 'file') {
try {
const accessToken = await getStableAccessToken();
const persisted = await persistWechatAttachment(
{
userId: boundUser.userId,
appId: config.appId,
openid: inbound.fromUserName,
msgId: inbound.msgId,
mediaId: inbound.mediaId,
filename: inbound.fileName,
publicBaseUrl: config.mediaPublicBaseUrl,
maxFileBytes: config.maxFileBytes,
},
{
wechatFetch,
accessToken,
},
);
intent.media = {
...intent.media,
mediaId: inbound.mediaId || intent.media?.mediaId || '',
publicUrl: persisted.publicUrl,
format: persisted.contentType,
source: persisted.source,
};
intent.attachment = {
...intent.attachment,
filename: persisted.filename,
publicUrl: persisted.publicUrl,
mimeType: persisted.contentType,
sizeBytes: persisted.bytes,
};
intent.agentText = `[文件1: ${persisted.filename}]: ${persisted.publicUrl}`;
intent.displayText = `文件:${persisted.filename}`;
rememberRecentMedia(inbound.fromUserName, intent);
} 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: error instanceof Error ? error.message : '文件处理失败,请稍后重试。',
}),
};
}
}
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'
? 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 = enqueueMessageTask(inbound.fromUserName, () =>
runIntentMessage({
inbound,
intent,
user: boundUser,
}),
)
.then(async ({ sessionId } = {}) => {
settleRecentMediaBatch(inbound.fromUserName, intent, { succeeded: true });
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) => {
settleRecentMediaBatch(inbound.fromUserName, intent, { succeeded: false });
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,
};
}