3337 lines
116 KiB
JavaScript
3337 lines
116 KiB
JavaScript
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 {
|
||
isWechatSessionResetAction,
|
||
resolveWechatSessionAction,
|
||
} from './wechat/intent/session-action.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('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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' ||
|
||
isWechatSessionResetAction(wechatIntent?.sessionActionResult) ||
|
||
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,
|
||
sessionIntentClassifier = 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 = await resolveWechatIntent(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 resolveWechatIntent = async (intent) => {
|
||
const wechatIntent = classifyWechatIntent(intent);
|
||
if (intent.msgType !== 'text' && intent.msgType !== 'voice') return wechatIntent;
|
||
const sessionActionResult = await resolveWechatSessionAction(intent.agentText, {
|
||
semanticClassifier: sessionIntentClassifier,
|
||
logger,
|
||
});
|
||
if (isWechatSessionResetAction(sessionActionResult)) {
|
||
intent.sessionAction = sessionActionResult.action;
|
||
return { ...wechatIntent, kind: 'session.reset', sessionActionResult };
|
||
}
|
||
if (sessionActionResult.action === 'ignore_previous') {
|
||
intent.sessionAction = sessionActionResult.action;
|
||
}
|
||
return { ...wechatIntent, sessionActionResult };
|
||
};
|
||
|
||
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,
|
||
};
|
||
}
|