2452 lines
88 KiB
JavaScript
2452 lines
88 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 { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
|
||
import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
||
import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs';
|
||
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
||
|
||
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 DEFAULT_ACK_TEXT = '已收到,正在处理,完成后发到这里。';
|
||
const DEFAULT_PROGRESS_TEXT = '还在处理,请稍等片刻。';
|
||
const DEFAULT_STATUS_TEXT =
|
||
'我在这边。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。';
|
||
const DEFAULT_UNSUPPORTED_TEXT = '当前先支持文字消息,你可以直接发文字给我。';
|
||
const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agent 对话:';
|
||
const DEFAULT_PROGRESS_DELAY_MS = 8000;
|
||
const 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'),
|
||
event: parseXmlField(xml, 'Event').toLowerCase(),
|
||
eventKey: parseXmlField(xml, 'EventKey'),
|
||
latitude: parseXmlField(xml, 'Latitude'),
|
||
longitude: parseXmlField(xml, 'Longitude'),
|
||
precision: parseXmlField(xml, 'Precision'),
|
||
encrypt: parseXmlField(xml, 'Encrypt'),
|
||
rawXml: xml,
|
||
};
|
||
}
|
||
|
||
function readJsonResponse(response) {
|
||
return response.text().then((text) => {
|
||
if (!response.ok) {
|
||
throw new Error(text || `upstream ${response.status}`);
|
||
}
|
||
return text ? JSON.parse(text) : null;
|
||
});
|
||
}
|
||
|
||
async function readJsonBody(response) {
|
||
const text = await response.text();
|
||
if (!text) return { payload: null, text: '' };
|
||
try {
|
||
return { payload: JSON.parse(text), text };
|
||
} catch {
|
||
return { payload: null, text };
|
||
}
|
||
}
|
||
|
||
function sanitizeAsrMessage(message) {
|
||
if (!message || typeof message !== 'string') return '识别失败,请重试';
|
||
const trimmed = message.trim();
|
||
if (!trimmed) return '识别失败,请重试';
|
||
if (/Failed to load audio|ffmpeg|Invalid data found when processing input|moov atom not found/i.test(trimmed)) {
|
||
return '音频格式无法识别,请重新录制';
|
||
}
|
||
if (/timeout|timed out/i.test(trimmed)) return '识别超时,请重试';
|
||
if (trimmed.length > 160) return `${trimmed.slice(0, 160)}…`;
|
||
return trimmed;
|
||
}
|
||
|
||
function guessVoiceMimeType(format = '', contentType = '') {
|
||
const normalizedContentType = String(contentType ?? '')
|
||
.split(';')[0]
|
||
.trim()
|
||
.toLowerCase();
|
||
if (normalizedContentType) return normalizedContentType;
|
||
const normalizedFormat = String(format ?? '').trim().toLowerCase();
|
||
if (normalizedFormat === 'amr') return 'audio/amr';
|
||
if (normalizedFormat === 'wav') return 'audio/wav';
|
||
if (normalizedFormat === 'mp3') return 'audio/mpeg';
|
||
if (normalizedFormat === 'm4a') return 'audio/mp4';
|
||
if (normalizedFormat === 'ogg') return 'audio/ogg';
|
||
return 'application/octet-stream';
|
||
}
|
||
|
||
function deriveWechatEndpointFromUrl(sourceUrl, pathname) {
|
||
if (!sourceUrl) return '';
|
||
try {
|
||
const url = new URL(sourceUrl);
|
||
url.pathname = pathname;
|
||
url.search = '';
|
||
return url.toString();
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function createUserMessage(text, metadata = {}) {
|
||
return {
|
||
id: crypto.randomUUID(),
|
||
role: 'user',
|
||
created: Math.floor(Date.now() / 1000),
|
||
content: [{ type: 'text', text }],
|
||
metadata: {
|
||
userVisible: true,
|
||
agentVisible: true,
|
||
...metadata,
|
||
},
|
||
};
|
||
}
|
||
|
||
function messageVisibleText(message) {
|
||
if (!message?.content) return '';
|
||
return message.content
|
||
.filter((item) => item.type === 'text' && typeof item.text === 'string')
|
||
.map((item) => item.text)
|
||
.join('');
|
||
}
|
||
|
||
function pushMessage(messages, incoming) {
|
||
const last = messages[messages.length - 1];
|
||
if (last?.id && incoming?.id && last.id === incoming.id) {
|
||
return [
|
||
...messages.slice(0, -1),
|
||
{
|
||
...last,
|
||
content: mergeMessageContent(last.content, incoming.content),
|
||
},
|
||
];
|
||
}
|
||
return [...messages, incoming];
|
||
}
|
||
|
||
async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metadata = {}) {
|
||
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
|
||
method: 'GET',
|
||
headers: { Accept: 'text/event-stream' },
|
||
});
|
||
if (!eventsResponse.ok || !eventsResponse.body) {
|
||
const text = await eventsResponse.text().catch(() => '');
|
||
throw new Error(text || '无法建立公众号消息事件流');
|
||
}
|
||
|
||
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
request_id: requestId,
|
||
user_message: createUserMessage(prompt, metadata),
|
||
}),
|
||
});
|
||
if (!replyResponse.ok) {
|
||
const text = await replyResponse.text().catch(() => '');
|
||
throw new Error(text || 'Agent reply 请求失败');
|
||
}
|
||
replyResponse.body?.cancel().catch?.(() => {});
|
||
|
||
const reader = eventsResponse.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let messages = [];
|
||
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);
|
||
} 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('本轮未收到可发送的新回复,请稍后重试');
|
||
}
|
||
return {
|
||
text: messageVisibleText(assistant),
|
||
tokenState: event.token_state ?? null,
|
||
messages,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
throw new Error('公众号消息事件流提前结束');
|
||
}
|
||
|
||
const WECHAT_CUSTOMER_TEXT_MAX_BYTES = 2048;
|
||
|
||
function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) {
|
||
const normalized = String(text ?? '').trim();
|
||
if (!normalized) return ['我先收到了,但这次没有生成可发送的文本结果。'];
|
||
|
||
const chunks = [];
|
||
let offset = 0;
|
||
while (offset < normalized.length) {
|
||
let byteCount = 0;
|
||
let end = offset;
|
||
while (end < normalized.length) {
|
||
const charBytes = Buffer.byteLength(normalized[end], 'utf8');
|
||
if (byteCount + charBytes > maxBytes) break;
|
||
byteCount += charBytes;
|
||
end += 1;
|
||
}
|
||
if (end === offset) break;
|
||
chunks.push(normalized.slice(offset, end));
|
||
offset = end;
|
||
}
|
||
return chunks.length > 0 ? chunks : ['我先收到了,但这次没有生成可发送的文本结果。'];
|
||
}
|
||
|
||
export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES };
|
||
|
||
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) {
|
||
const normalized = String(text ?? '').trim().toLowerCase();
|
||
if (!normalized) return false;
|
||
return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.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(reply?.messages ?? []).length > 0) return false;
|
||
return !hasAnyToolRequest(reply?.messages ?? []);
|
||
}
|
||
|
||
function looksLikePublishSuccessClaim(text) {
|
||
const normalized = String(text ?? '').trim();
|
||
if (!normalized) return false;
|
||
return /(?:页面|网页|html).*(?:已创建|已生成|已发布|创建完毕|生成完成|发布成功)|(?:成功发布|已经发布|已创建完毕).*(?:页面|网页|html)/iu.test(normalized);
|
||
}
|
||
|
||
async function hasAnyValidPublishedHtmlLink(text, linkExists) {
|
||
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
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async function isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists = defaultPublicHtmlLinkExists } = {}) {
|
||
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
||
const text = String(reply?.text ?? '').trim();
|
||
if (!looksLikePublishSuccessClaim(text)) return false;
|
||
if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false;
|
||
return !(await hasAnyValidPublishedHtmlLink(text, linkExists));
|
||
}
|
||
|
||
function isMissingRequiredPublishSkill(reply, intent) {
|
||
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
||
const wroteHtml = extractHtmlWriteTargets(reply?.messages ?? []).length > 0;
|
||
if (!wroteHtml) return false;
|
||
return !usedStaticPagePublishSkill(reply?.messages ?? []);
|
||
}
|
||
|
||
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
|
||
const owner = path.basename(path.resolve(workingDir));
|
||
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
|
||
return `${String(publicBaseUrl ?? '').replace(/\/+$/, '')}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(owner)}/${normalized}`;
|
||
}
|
||
|
||
function ensurePublicHtmlArtifact(htmlPath, workingDir) {
|
||
const workspaceRoot = path.resolve(workingDir);
|
||
const source = path.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(reply?.messages ?? []);
|
||
for (const target of htmlTargets) {
|
||
const artifact = ensurePublicHtmlArtifact(target, workingDir);
|
||
if (!artifact) continue;
|
||
artifacts.push({
|
||
...artifact,
|
||
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
|
||
});
|
||
}
|
||
return artifacts;
|
||
}
|
||
|
||
function extractRequestedHtmlTarget(text) {
|
||
const value = String(text ?? '');
|
||
if (!value) return '';
|
||
const explicitPublicMatch = value.match(/(?:^|[\s`'"])(public\/[a-z0-9._-]+\.html)\b/i);
|
||
if (explicitPublicMatch?.[1]) return explicitPublicMatch[1];
|
||
const bareMatch = value.match(/(?:^|[\s`'"])([a-z0-9._-]+\.html)\b/i);
|
||
if (bareMatch?.[1]) return `public/${bareMatch[1]}`;
|
||
return '';
|
||
}
|
||
|
||
function collectExpectedHtmlArtifacts(intent, { workingDir, publicBaseUrl }) {
|
||
const target = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? '');
|
||
if (!target) return [];
|
||
const artifact = ensurePublicHtmlArtifact(target, workingDir);
|
||
if (!artifact) return [];
|
||
return [{
|
||
...artifact,
|
||
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
|
||
}];
|
||
}
|
||
|
||
function sanitizeFilenameSlug(value) {
|
||
const normalized = String(value ?? '')
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
return normalized || 'simple-page';
|
||
}
|
||
|
||
function inferFallbackHtmlName(intent, reply) {
|
||
const explicit = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? '');
|
||
if (explicit) return explicit;
|
||
const replyText = String(reply?.text ?? '');
|
||
const linkMatch = replyText.match(/([a-z0-9._-]+\.html)\b/i);
|
||
if (linkMatch?.[1]) return `public/${linkMatch[1]}`;
|
||
const source = `${String(intent?.agentText ?? '')}\n${replyText}`;
|
||
if (/泰国|thailand/i.test(source)) return 'public/thailand-guide.html';
|
||
if (/夏日|summer/i.test(source)) return 'public/summer-guide.html';
|
||
const topic = source
|
||
.replace(/https?:\/\/\S+/g, '')
|
||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||
.trim()
|
||
.split(/\s+/)
|
||
.slice(0, 6)
|
||
.join('-');
|
||
return `public/${sanitizeFilenameSlug(topic)}.html`;
|
||
}
|
||
|
||
function extractFallbackPageTitle(intent, reply) {
|
||
const lines = String(reply?.text ?? '')
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter((line) => line && !/^https?:\/\//i.test(line) && !/^\[.+\]\(https?:\/\//i.test(line));
|
||
const preferred = lines.find(
|
||
(line) =>
|
||
!/^(已完成|页面已生成|点击下方链接查看|查看页面|还在处理|明白|开始了)$/u.test(
|
||
line.replace(/[🌴✨⭐️]/gu, '').trim(),
|
||
) &&
|
||
!/成功发布|已发布|好消息|页面都已经/u.test(line),
|
||
);
|
||
if (preferred) return preferred.replace(/[🌴✨⭐️]/gu, '').trim();
|
||
if (/泰国|thailand/i.test(String(intent?.agentText ?? ''))) return '泰国简易攻略';
|
||
return '简版页面';
|
||
}
|
||
|
||
function hasAnyUrl(text) {
|
||
return /https?:\/\/\S+/i.test(String(text ?? ''));
|
||
}
|
||
|
||
function hasAnyPublicHtmlLink(text) {
|
||
return [...String(text ?? '').matchAll(PUBLIC_HTML_LINK_PATTERN)].length > 0;
|
||
}
|
||
|
||
function buildFallbackHtmlDocument(intent, reply, title) {
|
||
const source = `${String(intent?.agentText ?? '')}\n${String(reply?.text ?? '')}`;
|
||
if (/泰国|thailand/i.test(source)) {
|
||
return `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>${escapeHtml(title)}</title>
|
||
<meta name="description" content="泰国旅行快速上手版,适合先看一遍再出发。">
|
||
<meta name="mindspace-cover" content='{"tag":"旅行","emoji":"🇹🇭","accent":"#ff8a3d","accent2":"#185a9d","subtitle":"轻量版泰国出行建议"}'>
|
||
<style>
|
||
:root { color-scheme: light; --bg:#fff7ef; --card:#ffffff; --ink:#18212f; --muted:#5b6472; --accent:#ff8a3d; --accent2:#185a9d; }
|
||
* { box-sizing:border-box; }
|
||
body { margin:0; font-family:"PingFang SC","Noto Sans SC",sans-serif; background:linear-gradient(180deg,#fff4e8 0%,#f7fbff 100%); color:var(--ink); }
|
||
main { max-width:760px; margin:0 auto; padding:40px 20px 72px; }
|
||
.hero { background:linear-gradient(135deg,var(--accent),#ffd36e 58%,#fff 100%); border-radius:28px; padding:28px; box-shadow:0 20px 50px rgba(24,33,47,.12); }
|
||
.eyebrow { font-size:13px; letter-spacing:.08em; text-transform:uppercase; opacity:.72; }
|
||
h1 { margin:10px 0 12px; font-size:34px; line-height:1.15; }
|
||
.summary { margin:0; max-width:520px; line-height:1.7; }
|
||
section { margin-top:18px; background:var(--card); border-radius:22px; padding:22px; box-shadow:0 14px 34px rgba(24,33,47,.08); }
|
||
h2 { margin:0 0 12px; font-size:20px; }
|
||
ul { margin:0; padding-left:20px; line-height:1.8; }
|
||
.grid { display:grid; gap:18px; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); }
|
||
.tip { color:var(--muted); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<div class="hero">
|
||
<div class="eyebrow">Thailand Quick Guide</div>
|
||
<h1>${escapeHtml(title)}</h1>
|
||
<p class="summary">适合第一次去泰国时先快速浏览:先定城市,再定节奏,预算和注意事项尽量简单清楚,出发前照着检查一遍就够了。</p>
|
||
</div>
|
||
<div class="grid">
|
||
<section>
|
||
<h2>推荐路线</h2>
|
||
<ul>
|
||
<li>曼谷 2 天:寺庙、夜市、商场和城市观景台。</li>
|
||
<li>清迈 2 到 3 天:古城慢逛、咖啡馆、夜间集市。</li>
|
||
<li>海岛 2 到 3 天:普吉、甲米或苏梅,选一个就够。</li>
|
||
</ul>
|
||
</section>
|
||
<section>
|
||
<h2>预算参考</h2>
|
||
<ul>
|
||
<li>住宿:普通酒店每晚约 200 到 500 元人民币。</li>
|
||
<li>餐饮:街边小吃和简餐通常比较友好。</li>
|
||
<li>交通:市内尽量打正规车或用常见打车软件。</li>
|
||
</ul>
|
||
</section>
|
||
</div>
|
||
<section>
|
||
<h2>出发前准备</h2>
|
||
<ul>
|
||
<li>提前确认签证或入境政策,护照有效期留足。</li>
|
||
<li>准备一点现金,热门景点与夜市会更方便。</li>
|
||
<li>防晒、驱蚊、轻便衣物尽量提前备好。</li>
|
||
</ul>
|
||
</section>
|
||
<section>
|
||
<h2>注意事项</h2>
|
||
<ul>
|
||
<li>尊重寺庙着装要求,进入室内前留意是否需要脱鞋。</li>
|
||
<li>海岛项目先问清价格和往返方式,避免临时加价。</li>
|
||
<li>如果行程只想轻松一点,城市和海岛不要排太满。</li>
|
||
</ul>
|
||
<p class="tip">这是一个简版攻略页,后续如果你要,我也可以继续扩成 5 天行程版、夜市清单版或海岛专版。</p>
|
||
</section>
|
||
</main>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
const description = `根据你的要求临时补出的简版页面:${String(intent?.agentText ?? '').trim() || '已生成可访问页面'}`;
|
||
return `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>${escapeHtml(title)}</title>
|
||
<meta name="description" content="${escapeHtml(description)}">
|
||
<meta name="mindspace-cover" content='{"tag":"页面","emoji":"📄","accent":"#2f6fed","accent2":"#0f172a","subtitle":"服务号自动补出简版页面"}'>
|
||
<style>
|
||
body { margin:0; font-family:"PingFang SC","Noto Sans SC",sans-serif; background:#f5f7fb; color:#172033; }
|
||
main { max-width:760px; margin:0 auto; padding:48px 20px 72px; }
|
||
article { background:#fff; border-radius:24px; padding:28px; box-shadow:0 18px 44px rgba(15,23,42,.08); }
|
||
h1 { margin:0 0 12px; font-size:32px; }
|
||
p, li { line-height:1.8; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<article>
|
||
<h1>${escapeHtml(title)}</h1>
|
||
<p>${escapeHtml(description)}</p>
|
||
<ul>
|
||
<li>这是服务号兜底生成的简版页面,先确保你能直接打开和分享。</li>
|
||
<li>如果你还想加图片、配色、分栏或更完整内容,可以继续在当前话题上细化。</li>
|
||
</ul>
|
||
</article>
|
||
</main>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
function createFallbackPublishedHtmlArtifact(intent, reply, { workingDir, publicBaseUrl }) {
|
||
if (!looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content)) return null;
|
||
const relativePath = inferFallbackHtmlName(intent, reply);
|
||
const absolutePath = path.resolve(workingDir, relativePath);
|
||
const workspaceRoot = path.resolve(workingDir);
|
||
if (absolutePath !== workspaceRoot && !absolutePath.startsWith(`${workspaceRoot}${path.sep}`)) return null;
|
||
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
||
const title = extractFallbackPageTitle(intent, reply);
|
||
fs.writeFileSync(absolutePath, buildFallbackHtmlDocument(intent, reply, title), 'utf8');
|
||
const artifact = ensurePublicHtmlArtifact(relativePath, workingDir);
|
||
if (!artifact) return null;
|
||
return {
|
||
...artifact,
|
||
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
|
||
fallbackTitle: title,
|
||
};
|
||
}
|
||
|
||
function collectRecentPublishedHtmlArtifacts(
|
||
intent,
|
||
{ workingDir, publicBaseUrl, 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 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;
|
||
artifacts.push({
|
||
localPath: absolutePath,
|
||
relativePath,
|
||
url: buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl),
|
||
mtimeMs: Number(stat.mtimeMs ?? 0),
|
||
matchesExpected: expectedRelativePath
|
||
? relativePath.replace(/\\/g, '/') === expectedRelativePath
|
||
: false,
|
||
});
|
||
if (artifacts.length >= limit) break;
|
||
}
|
||
}
|
||
|
||
return artifacts
|
||
.sort((left, right) => {
|
||
if (left.matchesExpected !== right.matchesExpected) return left.matchesExpected ? -1 : 1;
|
||
return right.mtimeMs - left.mtimeMs;
|
||
})
|
||
.map(({ mtimeMs, matchesExpected, ...artifact }) => artifact);
|
||
}
|
||
|
||
function rewritePublishedHtmlLinks(text, artifacts = []) {
|
||
const value = String(text ?? '');
|
||
if (!value || artifacts.length === 0) return value;
|
||
let next = value;
|
||
for (const artifact of artifacts) {
|
||
const canonicalUrl = String(artifact?.url ?? '').trim();
|
||
const filename = String(artifact?.relativePath ?? '').replace(/\\/g, '/').split('/').pop();
|
||
if (!canonicalUrl || !filename) continue;
|
||
const wrongPublicLinkPattern = new RegExp(
|
||
`https?:\\/\\/[^\\s)]+\\/(?:MindSpace\\/[^\\s/]+\\/)?public\\/${escapeRegExp(filename)}\\b`,
|
||
'g',
|
||
);
|
||
const wrongTkmindHtmlLinkPattern = new RegExp(
|
||
`https?:\\/\\/(?:[^\\s./]+\\.)*tkmind\\.cn\\/[^\n\\s)]*${escapeRegExp(filename)}\\b`,
|
||
'gi',
|
||
);
|
||
next = next.replace(wrongPublicLinkPattern, canonicalUrl);
|
||
next = next.replace(wrongTkmindHtmlLinkPattern, canonicalUrl);
|
||
next = next.replace(PUBLIC_HTML_LINK_PATTERN, (match) => {
|
||
if (match === canonicalUrl) return match;
|
||
const matchedFilename = String(match).split('/').pop();
|
||
return matchedFilename === filename ? canonicalUrl : match;
|
||
});
|
||
}
|
||
return next;
|
||
}
|
||
|
||
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl, artifacts: providedArtifacts = null }) {
|
||
const artifacts = Array.isArray(providedArtifacts)
|
||
? providedArtifacts
|
||
: collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl });
|
||
const baseText = rewritePublishedHtmlLinks(String(reply?.text ?? '').trim(), artifacts).trim();
|
||
for (const artifact of artifacts) {
|
||
const url = artifact.url;
|
||
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 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, '生成未完成');
|
||
}
|
||
|
||
function rewriteFallbackPageSuccessText(text, fallbackArtifact) {
|
||
if (!fallbackArtifact) return String(text ?? '');
|
||
const fallbackTitle = String(fallbackArtifact.fallbackTitle ?? '简版页面').trim() || '简版页面';
|
||
return [
|
||
'已为你补出一个可直接打开的简版页面。',
|
||
fallbackTitle,
|
||
'查看页面:',
|
||
fallbackArtifact.url,
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n\n');
|
||
}
|
||
|
||
export { maybeAttachPublishedHtmlLink };
|
||
|
||
function isQuestionStatusProbe(text) {
|
||
return /^[??]+$/.test(String(text ?? '').trim());
|
||
}
|
||
|
||
function isSimpleGreeting(text) {
|
||
const normalized = String(text ?? '')
|
||
.trim()
|
||
.replace(/[!!。.\s]+$/g, '')
|
||
.toLowerCase();
|
||
return /^(你好|您好|在吗|在不在|嗨|hi|hello|hey)$/.test(normalized);
|
||
}
|
||
|
||
function isConnectivityTest(text) {
|
||
const normalized = String(text ?? '')
|
||
.trim()
|
||
.replace(/[!!。.\s]+$/g, '');
|
||
return /^(测试\s*\d*|test\s*\d*)$/i.test(normalized);
|
||
}
|
||
|
||
function isTopicResetIntent(text) {
|
||
const normalized = String(text ?? '').trim();
|
||
return (
|
||
/^(换(个)?话题|新问题|忽略之前|不管之前|重新开始|reset)$/i.test(normalized) ||
|
||
/忽略.*之前|不要管.*之前|别管.*之前/.test(normalized)
|
||
);
|
||
}
|
||
|
||
function normalizeNumber(value) {
|
||
if (value === '' || value === null || value === undefined) return null;
|
||
const num = Number(value);
|
||
return Number.isFinite(num) ? num : null;
|
||
}
|
||
|
||
function normalizeWechatInboundIntent(inbound) {
|
||
const msgType = String(inbound?.msgType ?? '').toLowerCase();
|
||
const base = {
|
||
appId: '',
|
||
openid: String(inbound?.fromUserName ?? '').trim(),
|
||
msgId: String(inbound?.msgId ?? '').trim(),
|
||
msgType,
|
||
displayText: '',
|
||
agentText: '',
|
||
raw: { ...inbound },
|
||
};
|
||
|
||
if (msgType === 'text') {
|
||
const content = String(inbound?.content ?? '').trim();
|
||
return {
|
||
...base,
|
||
displayText: content,
|
||
agentText: content,
|
||
};
|
||
}
|
||
|
||
if (msgType === 'voice') {
|
||
const recognition = String(inbound?.recognition ?? '').trim();
|
||
return {
|
||
...base,
|
||
displayText: recognition ? `语音:${recognition}` : '语音消息',
|
||
agentText: recognition,
|
||
media: {
|
||
mediaId: inbound?.mediaId || '',
|
||
format: inbound?.format || '',
|
||
},
|
||
};
|
||
}
|
||
|
||
if (msgType === 'image') {
|
||
return {
|
||
...base,
|
||
displayText: '收到图片,请描述你想让我怎么处理',
|
||
agentText: '',
|
||
media: {
|
||
mediaId: inbound?.mediaId || '',
|
||
picUrl: inbound?.picUrl || '',
|
||
},
|
||
};
|
||
}
|
||
|
||
if (msgType === 'location') {
|
||
const latitude = normalizeNumber(inbound?.locationX);
|
||
const longitude = normalizeNumber(inbound?.locationY);
|
||
const scale = normalizeNumber(inbound?.scale);
|
||
const label = String(inbound?.label ?? '').trim();
|
||
const fragments = [
|
||
label ? `用户发送了位置:${label}` : '用户发送了位置',
|
||
latitude !== null ? `纬度:${latitude}` : '',
|
||
longitude !== null ? `经度:${longitude}` : '',
|
||
scale !== null ? `缩放:${scale}` : '',
|
||
].filter(Boolean);
|
||
return {
|
||
...base,
|
||
displayText: fragments.join(';'),
|
||
agentText: fragments.join(';'),
|
||
location: {
|
||
latitude,
|
||
longitude,
|
||
scale,
|
||
label,
|
||
},
|
||
};
|
||
}
|
||
|
||
if (msgType === 'link') {
|
||
const title = String(inbound?.title ?? '').trim();
|
||
const description = String(inbound?.description ?? '').trim();
|
||
const url = String(inbound?.url ?? '').trim();
|
||
const fragments = [
|
||
'用户分享了链接',
|
||
title ? `标题:${title}` : '',
|
||
description ? `描述:${description}` : '',
|
||
url ? `URL:${url}` : '',
|
||
].filter(Boolean);
|
||
return {
|
||
...base,
|
||
displayText: fragments.join(';'),
|
||
agentText: fragments.join(';'),
|
||
link: { title, description, url },
|
||
};
|
||
}
|
||
|
||
if (msgType === 'video' || msgType === 'shortvideo') {
|
||
return {
|
||
...base,
|
||
displayText: msgType === 'shortvideo' ? '收到小视频' : '收到视频',
|
||
agentText: '',
|
||
media: {
|
||
mediaId: inbound?.mediaId || '',
|
||
thumbMediaId: inbound?.thumbMediaId || '',
|
||
},
|
||
};
|
||
}
|
||
|
||
if (msgType === 'event') {
|
||
return {
|
||
...base,
|
||
displayText: inbound?.event ? `事件:${inbound.event}` : '事件消息',
|
||
agentText: '',
|
||
location:
|
||
inbound?.event === 'location'
|
||
? {
|
||
latitude: normalizeNumber(inbound?.latitude),
|
||
longitude: normalizeNumber(inbound?.longitude),
|
||
precision: normalizeNumber(inbound?.precision),
|
||
}
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
return base;
|
||
}
|
||
|
||
function buildWechatAgentPrompt(intent) {
|
||
const msgType = String(intent?.msgType ?? 'text');
|
||
const pagePublishHint = looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content)
|
||
? [
|
||
'【页面发布技能要求】这条消息是在生成可访问 HTML 页面。',
|
||
'开始前必须先调用 `load_skill` → `static-page-publish`,不能省略,也不能写完页面后再补调。',
|
||
'随后必须用 `write_file` / `edit_file` 写入 `public/*.html`。',
|
||
'最终只给用户一个正式域名的唯一正确链接;不要输出错误域名、备用链接或让用户手动保存文件。',
|
||
'',
|
||
].join('\n')
|
||
: '';
|
||
const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content)
|
||
? [
|
||
'【日程技能要求】这条消息涉及待办、提醒或日程。',
|
||
'开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。',
|
||
'只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。',
|
||
intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '',
|
||
'',
|
||
].filter(Boolean).join('\n')
|
||
: '';
|
||
if (msgType === 'voice') {
|
||
return [
|
||
scheduleAssistantHint,
|
||
'【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。',
|
||
'',
|
||
`用户语音识别文本:${String(intent?.agentText ?? '').trim()}`,
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
}
|
||
if (msgType === 'image') {
|
||
return [
|
||
scheduleAssistantHint,
|
||
'【微信服务号图片消息】用户发送了图片。',
|
||
'如用户没有明确要求,请先根据图片内容给出简短理解,并询问下一步。',
|
||
'',
|
||
String(intent?.agentText ?? '').trim(),
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
}
|
||
if (msgType === 'location') {
|
||
const location = intent?.location ?? {};
|
||
return [
|
||
scheduleAssistantHint,
|
||
'【微信服务号位置消息】用户发送了当前位置。',
|
||
location.label ? `地址:${location.label}` : '',
|
||
location.latitude !== null && location.latitude !== undefined
|
||
? `纬度:${location.latitude}`
|
||
: '',
|
||
location.longitude !== null && location.longitude !== undefined
|
||
? `经度:${location.longitude}`
|
||
: '',
|
||
'请结合位置回答用户可能的路线、附近、行程或提醒需求;如果意图不明确,先简短询问。',
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
}
|
||
if (msgType === 'link') {
|
||
const link = intent?.link ?? {};
|
||
return [
|
||
scheduleAssistantHint,
|
||
'【微信服务号链接消息】用户分享了链接。',
|
||
link.title ? `标题:${link.title}` : '',
|
||
link.description ? `描述:${link.description}` : '',
|
||
link.url ? `URL:${link.url}` : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
}
|
||
const content = String(intent?.agentText ?? intent?.content ?? '').trim();
|
||
const lines = [];
|
||
if (pagePublishHint) lines.push(pagePublishHint);
|
||
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
|
||
lines.push(
|
||
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
|
||
'若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。',
|
||
'',
|
||
`用户消息:${content}`,
|
||
);
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function looksLikeScheduleConfirmation(text) {
|
||
const compact = String(text ?? '').replace(/\s+/g, '');
|
||
if (!compact) return false;
|
||
if (/(没有|没能|未能|无法|不能|失败|需要你|请补充|请告诉)/.test(compact)) return false;
|
||
return /(已经|已|现在).{0,12}(设置|安排|记录|加上|添加|创建).{0,12}(待办|提醒|日程|安排|闹钟)|设置好了|已经设置好了|已经加上/.test(
|
||
compact,
|
||
);
|
||
}
|
||
|
||
function buildConnectivityTestReply(user) {
|
||
const name = resolveWechatAddressName(user);
|
||
return name
|
||
? `${name},公众号消息通道正常,我收到你的测试了。`
|
||
: '公众号消息通道正常,我收到你的测试了。';
|
||
}
|
||
|
||
function normalizeWechatName(value) {
|
||
const name = String(value ?? '').trim();
|
||
if (!name || /^wx_[a-z0-9_]{4,64}$/i.test(name)) return '';
|
||
return name;
|
||
}
|
||
|
||
function resolveWechatAddressName(user) {
|
||
return normalizeWechatName(user?.nickname) || normalizeWechatName(user?.displayName) || '';
|
||
}
|
||
|
||
function buildGreetingText(user) {
|
||
const name = resolveWechatAddressName(user);
|
||
return name ? `你好,${name}!我在呢,有什么需要?` : '你好!我在呢,有什么需要?';
|
||
}
|
||
|
||
function buildStatusText(user, fallbackText) {
|
||
const name = resolveWechatAddressName(user);
|
||
if (!name) return fallbackText;
|
||
return `我在这边,${name}。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。`;
|
||
}
|
||
|
||
function successResponse(task = null) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'text/plain; charset=utf-8',
|
||
body: 'success',
|
||
...(task ? { task } : {}),
|
||
};
|
||
}
|
||
|
||
export function loadWechatMpConfig(env = process.env) {
|
||
const appId = env.H5_WECHAT_MP_APP_ID?.trim() ?? env.H5_WECHAT_APP_ID?.trim() ?? '';
|
||
const appSecret =
|
||
env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? '';
|
||
const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? '';
|
||
const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? '';
|
||
const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1';
|
||
const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login';
|
||
return {
|
||
enabled: enabledFlag && Boolean(appId && appSecret && token && publicBaseUrl),
|
||
appId,
|
||
appSecret,
|
||
token,
|
||
publicBaseUrl,
|
||
bindPath,
|
||
ackText: env.H5_WECHAT_MP_ACK_TEXT?.trim() || DEFAULT_ACK_TEXT,
|
||
ackRandomEnabled: env.H5_WECHAT_MP_ACK_RANDOM !== '0',
|
||
ackNicknameEnabled: env.H5_WECHAT_MP_ACK_NICKNAME !== '0',
|
||
progressText: env.H5_WECHAT_MP_PROGRESS_TEXT?.trim() || DEFAULT_PROGRESS_TEXT,
|
||
progressDelayMs: Math.max(
|
||
0,
|
||
Number(env.H5_WECHAT_MP_PROGRESS_DELAY_MS ?? DEFAULT_PROGRESS_DELAY_MS),
|
||
),
|
||
statusText: env.H5_WECHAT_MP_STATUS_TEXT?.trim() || DEFAULT_STATUS_TEXT,
|
||
unsupportedText: env.H5_WECHAT_MP_UNSUPPORTED_TEXT?.trim() || DEFAULT_UNSUPPORTED_TEXT,
|
||
unboundTextPrefix: env.H5_WECHAT_MP_UNBOUND_TEXT_PREFIX?.trim() || DEFAULT_UNBOUND_TEXT,
|
||
tokenUrl: env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_WECHAT_TOKEN_URL,
|
||
customerServiceUrl:
|
||
env.H5_WECHAT_MP_CUSTOMER_SERVICE_URL?.trim() || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
||
jsapiTicketUrl:
|
||
env.H5_WECHAT_MP_JSAPI_TICKET_URL?.trim() ||
|
||
deriveWechatEndpointFromUrl(env.H5_WECHAT_MP_TOKEN_URL?.trim(), '/cgi-bin/ticket/getticket') ||
|
||
DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
||
mediaPublicBaseUrl:
|
||
env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') || publicBaseUrl,
|
||
maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)),
|
||
acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0',
|
||
acceptImage: env.H5_WECHAT_MP_ACCEPT_IMAGE !== '0',
|
||
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
||
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
|
||
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
|
||
};
|
||
}
|
||
|
||
function sha1Hex(parts) {
|
||
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,
|
||
apiFetch,
|
||
startAgentSession = null,
|
||
sessionApiFetch = null,
|
||
scheduleService = null,
|
||
applySessionLlmProvider = null,
|
||
refreshSessionSnapshot = null,
|
||
wechatFetch = undiciFetch,
|
||
linkExists = defaultPublicHtmlLinkExists,
|
||
logger = console,
|
||
}) {
|
||
if (!config?.enabled) {
|
||
return {
|
||
enabled: false,
|
||
};
|
||
}
|
||
config = {
|
||
...config,
|
||
tokenUrl: config.tokenUrl || DEFAULT_WECHAT_TOKEN_URL,
|
||
customerServiceUrl: config.customerServiceUrl || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
||
jsapiTicketUrl: config.jsapiTicketUrl || DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
||
progressText: config.progressText ?? DEFAULT_PROGRESS_TEXT,
|
||
progressDelayMs: Math.max(0, Number(config.progressDelayMs ?? DEFAULT_PROGRESS_DELAY_MS)),
|
||
statusText: config.statusText || DEFAULT_STATUS_TEXT,
|
||
mediaPublicBaseUrl: config.mediaPublicBaseUrl || config.publicBaseUrl,
|
||
maxImageBytes: Math.max(1, Number(config.maxImageBytes ?? 10 * 1024 * 1024)),
|
||
acceptVoice: config.acceptVoice !== false,
|
||
acceptImage: config.acceptImage !== false,
|
||
acceptLocation: config.acceptLocation !== false,
|
||
acceptLink: config.acceptLink !== false,
|
||
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
|
||
};
|
||
|
||
let accessTokenCache = {
|
||
token: null,
|
||
expiresAt: 0,
|
||
};
|
||
let jsapiTicketCache = {
|
||
ticket: null,
|
||
expiresAt: 0,
|
||
};
|
||
|
||
const fetchForSession = (sessionId, pathname, init) =>
|
||
sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch(pathname, init);
|
||
|
||
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'],
|
||
};
|
||
};
|
||
|
||
const sendCustomerServiceText = async (openid, content, user = null, { verifiedHtmlUrls = [] } = {}) => {
|
||
const formatted = formatWechatOutboundText(content, user);
|
||
const verifiedUrlSet = new Set(
|
||
verifiedHtmlUrls
|
||
.map((url) => String(url ?? '').trim())
|
||
.filter(Boolean),
|
||
);
|
||
const guarded = downgradePrematurePublishClaims(
|
||
await guardMissingPublicHtmlLinks(formatted, {
|
||
linkExists: async (url) => {
|
||
if (verifiedUrlSet.has(String(url ?? '').trim())) return true;
|
||
return linkExists(url);
|
||
},
|
||
}),
|
||
);
|
||
const chunks = splitWechatText(guarded);
|
||
const accessToken = await getStableAccessToken();
|
||
for (const chunk of chunks) {
|
||
const payload = await readJsonResponse(
|
||
await wechatFetch(
|
||
`${config.customerServiceUrl}?access_token=${encodeURIComponent(accessToken)}`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
touser: openid,
|
||
msgtype: 'text',
|
||
text: {
|
||
content: chunk,
|
||
},
|
||
}),
|
||
},
|
||
),
|
||
);
|
||
if (Number(payload?.errcode ?? 0) !== 0) {
|
||
const errcode = Number(payload?.errcode ?? 0);
|
||
const errmsg = String(payload?.errmsg ?? '').trim() || 'unknown_error';
|
||
throw new Error(`微信客服消息发送失败 errcode=${errcode} errmsg=${errmsg}`);
|
||
}
|
||
}
|
||
};
|
||
|
||
const sendTextToUser = async (userId, content) => {
|
||
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
|
||
if (!openid) {
|
||
throw new Error('用户尚未绑定服务号,无法推送提醒');
|
||
}
|
||
await sendCustomerServiceText(openid, content);
|
||
};
|
||
|
||
const ensureSessionProvider = async (sessionId) => {
|
||
if (!applySessionLlmProvider || !sessionId) return;
|
||
const applied = await applySessionLlmProvider(sessionId);
|
||
if (applied && applied.ok === false) {
|
||
throw new Error(applied.message || '专属 Agent Provider 未配置');
|
||
}
|
||
};
|
||
|
||
const transcribeWechatVoiceMedia = async (mediaId, format) => {
|
||
if (!mediaId) return '';
|
||
const accessToken = await getStableAccessToken();
|
||
const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch });
|
||
if (!downloaded.buffer?.length) return '';
|
||
|
||
const extension = String(format ?? '').trim().toLowerCase() || 'amr';
|
||
const form = new FormData();
|
||
form.append(
|
||
'file',
|
||
new Blob([downloaded.buffer], {
|
||
type: guessVoiceMimeType(format, downloaded.contentType),
|
||
}),
|
||
`wechat-voice.${extension}`,
|
||
);
|
||
|
||
const response = await wechatFetch(`${config.asrTarget.replace(/\/$/, '')}/asr/oneshot`, {
|
||
method: 'POST',
|
||
body: form,
|
||
});
|
||
const { payload, text } = await readJsonBody(response);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(sanitizeAsrMessage(payload?.message ?? text ?? `ASR HTTP ${response.status}`));
|
||
}
|
||
if (Number(payload?.code ?? 200) !== 200) {
|
||
throw new Error(sanitizeAsrMessage(payload?.message ?? text ?? '识别失败,请重试'));
|
||
}
|
||
return String(payload?.data?.text ?? '').trim();
|
||
};
|
||
|
||
const ensureWechatAgentSession = async ({
|
||
userId,
|
||
openid,
|
||
forceNew = false,
|
||
userContext = null,
|
||
}) => {
|
||
if (forceNew) {
|
||
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
||
}
|
||
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) {
|
||
await reconcileAgentSession(
|
||
(pathname, init) => fetchForSession(existingRoute.agentSessionId, pathname, init),
|
||
existingRoute.agentSessionId,
|
||
{
|
||
workingDir,
|
||
sessionPolicy,
|
||
sandboxConstraints: publishLayout?.constraints ?? null,
|
||
userContext: publishLayout
|
||
? {
|
||
userId,
|
||
displayName: addressName || publishLayout.displayName,
|
||
username: addressName || null,
|
||
slug: null,
|
||
}
|
||
: null,
|
||
tolerateInvalidWorkingDir: true,
|
||
},
|
||
);
|
||
const routeHasTools = await sessionHasRequiredTools(
|
||
fetchForSession,
|
||
existingRoute.agentSessionId,
|
||
sessionPolicy,
|
||
).catch(() => false);
|
||
if (routeHasTools) {
|
||
return existingRoute.agentSessionId;
|
||
}
|
||
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
||
} else if (existingRoute?.status === 'disabled') {
|
||
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
||
}
|
||
|
||
const gate = await userAuth.canUseChat(userId);
|
||
if (!gate.ok) {
|
||
throw new Error(gate.message || '当前用户无法使用聊天能力');
|
||
}
|
||
const 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 会话创建失败');
|
||
}
|
||
|
||
if (!startAgentSession && typeof userAuth.registerAgentSession === 'function') {
|
||
await userAuth.registerAgentSession(userId, sessionId);
|
||
}
|
||
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;
|
||
};
|
||
|
||
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 rememberWechatUserContext = async (sessionId, user) => {
|
||
const addressName = resolveWechatAddressName(user);
|
||
if (!addressName) 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: true }),
|
||
}),
|
||
);
|
||
} catch (err) {
|
||
logger.warn?.('WeChat MP user context remember failed:', err);
|
||
}
|
||
};
|
||
|
||
const buildIntentMetadata = (intent) => ({
|
||
source: 'wechat_mp',
|
||
msgType: intent.msgType,
|
||
originalMsgId: intent.msgId || null,
|
||
displayText: intent.displayText || '',
|
||
mediaPublicUrl: intent.media?.publicUrl || null,
|
||
recognition: intent.msgType === 'voice' ? intent.agentText || null : null,
|
||
location: intent.location || null,
|
||
link: intent.link || null,
|
||
});
|
||
|
||
const persistIntentDetail = async ({ intent, userId = null, rawXmlHash = '' }) => {
|
||
if (typeof userAuth.insertWechatMpMessageDetail !== 'function') return;
|
||
await userAuth.insertWechatMpMessageDetail({
|
||
appId: config.appId,
|
||
openid: intent.openid,
|
||
msgId: intent.msgId || null,
|
||
userId,
|
||
msgType: intent.msgType,
|
||
displayText: intent.displayText || '',
|
||
agentText: intent.agentText || '',
|
||
mediaId: intent.media?.mediaId || null,
|
||
mediaUrl: intent.media?.picUrl || null,
|
||
mediaPublicUrl: intent.media?.publicUrl || null,
|
||
mediaFormat: intent.media?.format || null,
|
||
locationLat: intent.location?.latitude ?? null,
|
||
locationLng: intent.location?.longitude ?? null,
|
||
locationLabel: intent.location?.label || null,
|
||
linkUrl: intent.link?.url || null,
|
||
linkTitle: intent.link?.title || null,
|
||
rawXmlHash,
|
||
rawJson: intent.raw,
|
||
});
|
||
};
|
||
|
||
const runIntentMessage = async ({ inbound, intent, user }) => {
|
||
const resetCandidate =
|
||
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
||
const forceNew = isTopicResetIntent(resetCandidate);
|
||
let sessionId = await ensureWechatAgentSession({
|
||
userId: user.userId,
|
||
openid: inbound.fromUserName,
|
||
userContext: user,
|
||
forceNew,
|
||
});
|
||
const publishLayout = await userAuth.getUserPublishLayout(user.userId);
|
||
await ensureSessionProvider(sessionId);
|
||
await rememberWechatUserContext(sessionId, user);
|
||
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 = async (replyText) => {
|
||
if (!scheduleService || !looksLikeScheduleConfirmation(replyText)) return replyText;
|
||
const sourceMessageId = String(intent.msgId ?? '').trim();
|
||
if (!sourceMessageId || typeof scheduleService.listItemsBySourceMessage !== 'function') {
|
||
return replyText;
|
||
}
|
||
const items = await scheduleService
|
||
.listItemsBySourceMessage({
|
||
userId: user.userId,
|
||
sourceMessageId,
|
||
limit: 5,
|
||
})
|
||
.catch((err) => {
|
||
logger.warn?.('Schedule confirmation guard failed:', err);
|
||
return [];
|
||
});
|
||
if (items.length > 0) return replyText;
|
||
return [
|
||
'我刚才没有确认到待办/提醒已经写入系统,所以这次不能算设置成功。',
|
||
'请再发一次完整安排,比如“明天早上 6 点跑步,5 点半提醒我”。我会在工具写入成功后再确认。',
|
||
].join('\n');
|
||
};
|
||
try {
|
||
const requestStartedAt = Date.now();
|
||
const reply = await executeSessionReply(
|
||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||
sessionId,
|
||
requestId,
|
||
buildWechatAgentPrompt(intent),
|
||
buildIntentMetadata(intent),
|
||
);
|
||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||
const expectedArtifacts = collectExpectedHtmlArtifacts(intent, {
|
||
workingDir,
|
||
publicBaseUrl: config.publicBaseUrl,
|
||
});
|
||
const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, {
|
||
workingDir,
|
||
publicBaseUrl: config.publicBaseUrl,
|
||
sinceMs: requestStartedAt,
|
||
});
|
||
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExists);
|
||
const shouldFallbackFromReply =
|
||
!hasAnyUrl(reply?.text) || (hasAnyPublicHtmlLink(reply?.text) && !hasValidLinkInReply);
|
||
const fallbackArtifact =
|
||
expectedArtifacts.length === 0 &&
|
||
recentArtifacts.length === 0 &&
|
||
shouldFallbackFromReply &&
|
||
collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl: config.publicBaseUrl }).length === 0
|
||
? createFallbackPublishedHtmlArtifact(intent, reply, {
|
||
workingDir,
|
||
publicBaseUrl: config.publicBaseUrl,
|
||
})
|
||
: null;
|
||
if (
|
||
isMissingRequiredPublishSkill(reply, intent) ||
|
||
isSuspiciousBareCompletionReply(reply, intent) ||
|
||
(expectedArtifacts.length === 0 &&
|
||
recentArtifacts.length === 0 &&
|
||
!fallbackArtifact &&
|
||
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists })))
|
||
) {
|
||
throw new Error('stale_session_poisoned_completion');
|
||
}
|
||
if (reply.tokenState) {
|
||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
|
||
}
|
||
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
|
||
workingDir,
|
||
publicBaseUrl: config.publicBaseUrl,
|
||
});
|
||
if (fallbackArtifact) publishedArtifacts.push(fallbackArtifact);
|
||
const recentPublishedArtifacts = publishedArtifacts.length > 0 ? [] : recentArtifacts;
|
||
const fallbackArtifacts = publishedArtifacts.length > 0
|
||
? []
|
||
: (expectedArtifacts.length > 0 ? expectedArtifacts : recentPublishedArtifacts);
|
||
const verifiedArtifacts = publishedArtifacts.length > 0 ? publishedArtifacts : fallbackArtifacts;
|
||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||
workingDir,
|
||
publicBaseUrl: config.publicBaseUrl,
|
||
artifacts: verifiedArtifacts,
|
||
});
|
||
const outboundReply = rewriteFallbackPageSuccessText(finalizedReply, fallbackArtifact);
|
||
await refreshWechatSessionSnapshot(sessionId, user.userId);
|
||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(outboundReply), user, {
|
||
verifiedHtmlUrls: verifiedArtifacts.map((artifact) => artifact.url),
|
||
});
|
||
return { sessionId };
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
const mayBeStaleSession =
|
||
/403|404|not found|无权访问|session|stale_session_poisoned_completion/i.test(message) && sessionId;
|
||
if (mayBeStaleSession) {
|
||
sessionId = await ensureWechatAgentSession({
|
||
userId: user.userId,
|
||
openid: inbound.fromUserName,
|
||
forceNew: true,
|
||
userContext: user,
|
||
});
|
||
await ensureSessionProvider(sessionId);
|
||
await rememberWechatUserContext(sessionId, user);
|
||
const retryId = crypto.randomUUID();
|
||
const retryStartedAt = Date.now();
|
||
const reply = await executeSessionReply(
|
||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||
sessionId,
|
||
retryId,
|
||
buildWechatAgentPrompt(intent),
|
||
buildIntentMetadata(intent),
|
||
);
|
||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||
const expectedArtifacts = collectExpectedHtmlArtifacts(intent, {
|
||
workingDir,
|
||
publicBaseUrl: config.publicBaseUrl,
|
||
});
|
||
const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, {
|
||
workingDir,
|
||
publicBaseUrl: config.publicBaseUrl,
|
||
sinceMs: retryStartedAt,
|
||
});
|
||
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExists);
|
||
const shouldFallbackFromReply =
|
||
!hasAnyUrl(reply?.text) || (hasAnyPublicHtmlLink(reply?.text) && !hasValidLinkInReply);
|
||
const fallbackArtifact =
|
||
expectedArtifacts.length === 0 &&
|
||
recentArtifacts.length === 0 &&
|
||
shouldFallbackFromReply &&
|
||
collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl: config.publicBaseUrl }).length === 0
|
||
? createFallbackPublishedHtmlArtifact(intent, reply, {
|
||
workingDir,
|
||
publicBaseUrl: config.publicBaseUrl,
|
||
})
|
||
: null;
|
||
if (
|
||
isMissingRequiredPublishSkill(reply, intent) ||
|
||
isSuspiciousBareCompletionReply(reply, intent) ||
|
||
(expectedArtifacts.length === 0 &&
|
||
recentArtifacts.length === 0 &&
|
||
!fallbackArtifact &&
|
||
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists })))
|
||
) {
|
||
throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试');
|
||
}
|
||
if (reply.tokenState) {
|
||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
|
||
}
|
||
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
|
||
workingDir,
|
||
publicBaseUrl: config.publicBaseUrl,
|
||
});
|
||
if (fallbackArtifact) publishedArtifacts.push(fallbackArtifact);
|
||
const recentPublishedArtifacts = publishedArtifacts.length > 0 ? [] : recentArtifacts;
|
||
const fallbackArtifacts = publishedArtifacts.length > 0
|
||
? []
|
||
: (expectedArtifacts.length > 0 ? expectedArtifacts : recentPublishedArtifacts);
|
||
const verifiedArtifacts = publishedArtifacts.length > 0 ? publishedArtifacts : fallbackArtifacts;
|
||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||
workingDir,
|
||
publicBaseUrl: config.publicBaseUrl,
|
||
artifacts: verifiedArtifacts,
|
||
});
|
||
const outboundReply = rewriteFallbackPageSuccessText(finalizedReply, fallbackArtifact);
|
||
await refreshWechatSessionSnapshot(sessionId, user.userId);
|
||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(outboundReply), user, {
|
||
verifiedHtmlUrls: verifiedArtifacts.map((artifact) => artifact.url),
|
||
});
|
||
return { sessionId };
|
||
}
|
||
throw err;
|
||
} finally {
|
||
finished = true;
|
||
if (progressTimer) clearTimeout(progressTimer);
|
||
}
|
||
};
|
||
|
||
const handleScheduleIntent = async ({ intent, user }) => {
|
||
if (!scheduleService) return null;
|
||
const scheduleIntent = parseScheduleIntent(intent.agentText);
|
||
if (!isScheduleIntent(scheduleIntent)) return null;
|
||
|
||
if (scheduleIntent.action === 'create_todo') {
|
||
if (scheduleIntent.needsClarification?.includes('todo_title')) {
|
||
return '可以。你想让我记哪一条待办?例如“帮我记一下 跟段吃饭”。';
|
||
}
|
||
const item = await scheduleService.createItem({
|
||
userId: user.userId,
|
||
kind: 'task',
|
||
title: scheduleIntent.title,
|
||
timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
||
sourceChannel: 'wechat',
|
||
sourceMessageId: intent.msgId || null,
|
||
sourceText: intent.agentText,
|
||
metadata: {
|
||
source: 'wechat_mp',
|
||
},
|
||
});
|
||
return `已记录到待办列表:${item.title},未设置提醒。`;
|
||
}
|
||
|
||
if (scheduleIntent.action === 'create_daily_todo_digest') {
|
||
if (scheduleIntent.needsClarification?.includes('digest_time')) {
|
||
return '可以。你想每天几点收到当天待办记录?比如“每天早上 7 点发给我”。';
|
||
}
|
||
const subscription = await scheduleService.createDailyTodoDigest({
|
||
userId: user.userId,
|
||
hour: scheduleIntent.hour,
|
||
minute: scheduleIntent.minute,
|
||
timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
||
channel: 'wechat',
|
||
sourceChannel: 'wechat',
|
||
sourceMessageId: intent.msgId || null,
|
||
sourceText: intent.agentText,
|
||
});
|
||
const minuteText = subscription.minute === 0 ? '' : `${String(subscription.minute).padStart(2, '0')}分`;
|
||
return `已设置:我会每天早上 ${subscription.hour}点${minuteText} 通过服务号把当天待办记录发给你。`;
|
||
}
|
||
|
||
if (scheduleIntent.action === 'create_balance_alert') {
|
||
if (scheduleIntent.needsClarification?.includes('threshold')) {
|
||
return '可以。你想在余额低于多少时提醒我?例如“余额低于 20 元提醒我”。';
|
||
}
|
||
const subscription = await scheduleService.createBalanceLowAlert({
|
||
userId: user.userId,
|
||
thresholdCents: scheduleIntent.thresholdCents,
|
||
channel: 'wechat',
|
||
sourceChannel: 'wechat',
|
||
sourceMessageId: intent.msgId || null,
|
||
sourceText: intent.agentText,
|
||
});
|
||
return `已设置:当余额低于 ${(subscription.thresholdCents / 100).toFixed(2)} 元时,我会通过服务号提醒你。`;
|
||
}
|
||
|
||
if (scheduleIntent.action === 'query_schedule') {
|
||
const text = await scheduleService.buildTodoDigestText({
|
||
userId: user.userId,
|
||
timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
||
});
|
||
return text;
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const handleInboundMessage = async (bodyText, query = {}) => {
|
||
if (!verifyRequest(query)) {
|
||
return { ok: false, status: 403, body: 'invalid signature' };
|
||
}
|
||
const inbound = parseWechatMessage(String(bodyText ?? ''));
|
||
const rawXmlHash = crypto.createHash('sha1').update(inbound.rawXml || '').digest('hex');
|
||
const intent = normalizeWechatInboundIntent(inbound);
|
||
intent.appId = config.appId;
|
||
if (!inbound.fromUserName || !inbound.toUserName || !inbound.msgType) {
|
||
return { ok: false, status: 400, body: 'invalid xml' };
|
||
}
|
||
if (String(query.encrypt_type ?? '').toLowerCase() === 'aes' || inbound.encrypt) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: '当前公众号回调需使用明文模式接入,请联系管理员切换后再试。',
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (inbound.msgType === 'event' && inbound.event === 'location') {
|
||
await persistIntentDetail({ intent, rawXmlHash });
|
||
return successResponse();
|
||
}
|
||
|
||
if (inbound.msgType === 'event' && inbound.event === 'subscribe') {
|
||
await persistIntentDetail({ intent, rawXmlHash });
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: `${config.unboundTextPrefix}\n${buildBindUrl()}`,
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (inbound.msgType === 'event') {
|
||
await persistIntentDetail({ intent, rawXmlHash });
|
||
return successResponse();
|
||
}
|
||
|
||
const supportedByConfig =
|
||
(intent.msgType === 'voice' && config.acceptVoice) ||
|
||
(intent.msgType === 'image' && config.acceptImage) ||
|
||
(intent.msgType === 'location' && config.acceptLocation) ||
|
||
(intent.msgType === 'link' && config.acceptLink) ||
|
||
intent.msgType === 'text' ||
|
||
intent.msgType === 'video' ||
|
||
intent.msgType === 'shortvideo';
|
||
|
||
if (!supportedByConfig) {
|
||
await persistIntentDetail({ intent, rawXmlHash });
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: config.unsupportedText,
|
||
}),
|
||
};
|
||
}
|
||
|
||
const boundUser = await userAuth.findWechatUserByOpenid(config.appId, inbound.fromUserName);
|
||
if (!boundUser) {
|
||
await persistIntentDetail({ intent, rawXmlHash });
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: `${config.unboundTextPrefix}\n${buildBindUrl()}`,
|
||
}),
|
||
};
|
||
}
|
||
if (boundUser.status === 'disabled') {
|
||
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: '当前账号不可用,请联系管理员处理。',
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (intent.msgType === 'voice' && !intent.agentText.trim() && intent.media?.mediaId) {
|
||
try {
|
||
const fallbackText = await transcribeWechatVoiceMedia(intent.media.mediaId, intent.media.format);
|
||
if (fallbackText) {
|
||
intent.agentText = fallbackText;
|
||
intent.displayText = `语音:${fallbackText}`;
|
||
}
|
||
} catch (err) {
|
||
logger.warn?.('WeChat MP voice ASR fallback failed:', err);
|
||
}
|
||
}
|
||
|
||
if (intent.msgType === 'text' && !intent.agentText.trim()) {
|
||
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: config.unsupportedText,
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (intent.msgType === 'voice' && !intent.agentText.trim()) {
|
||
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: '未识别到语音文字,请再说一次或输入文字。',
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (intent.msgType === 'image') {
|
||
try {
|
||
const accessToken = await getStableAccessToken();
|
||
const persisted = await persistWechatImage(
|
||
{
|
||
userId: boundUser.userId,
|
||
appId: config.appId,
|
||
openid: inbound.fromUserName,
|
||
msgId: inbound.msgId,
|
||
mediaId: inbound.mediaId,
|
||
picUrl: inbound.picUrl,
|
||
publicBaseUrl: config.mediaPublicBaseUrl,
|
||
maxImageBytes: config.maxImageBytes,
|
||
},
|
||
{
|
||
wechatFetch,
|
||
accessToken,
|
||
},
|
||
);
|
||
intent.media = {
|
||
...intent.media,
|
||
mediaId: inbound.mediaId || intent.media?.mediaId || '',
|
||
picUrl: inbound.picUrl || intent.media?.picUrl || '',
|
||
publicUrl: persisted.publicUrl,
|
||
format: persisted.contentType,
|
||
source: persisted.source,
|
||
};
|
||
intent.agentText = `[图片1]: ${persisted.publicUrl}`;
|
||
} catch (error) {
|
||
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: '图片处理失败,请稍后重试或直接发送文字说明。',
|
||
}),
|
||
};
|
||
}
|
||
}
|
||
|
||
if (
|
||
intent.msgType === 'location' &&
|
||
(intent.location?.latitude == null || intent.location?.longitude == null)
|
||
) {
|
||
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: '这次位置字段不完整,请重新发送位置或直接输入地点。',
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (intent.msgType === 'link' && !intent.link?.url) {
|
||
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: '这次没有拿到完整链接,请重新发送一次。',
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (intent.msgType === 'video' || intent.msgType === 'shortvideo') {
|
||
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: '已收到视频,当前先支持文本、语音、图片、定位和链接。',
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (intent.msgType === 'text' && isQuestionStatusProbe(intent.agentText)) {
|
||
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: buildStatusText(boundUser, config.statusText),
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (intent.msgType === 'text' && isSimpleGreeting(intent.agentText)) {
|
||
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: buildGreetingText(boundUser),
|
||
}),
|
||
};
|
||
}
|
||
|
||
if ((intent.msgType === 'text' || intent.msgType === 'voice') && isConnectivityTest(intent.agentText)) {
|
||
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: buildConnectivityTestReply(boundUser),
|
||
}),
|
||
};
|
||
}
|
||
|
||
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 handleScheduleIntent({ intent, user: boundUser })
|
||
: null;
|
||
if (scheduleReply) {
|
||
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
|
||
await userAuth.finishWechatMpMessage({
|
||
appId: config.appId,
|
||
openid: inbound.fromUserName,
|
||
msgId: inbound.msgId,
|
||
status: 'done',
|
||
agentSessionId: null,
|
||
});
|
||
}
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: scheduleReply,
|
||
}),
|
||
};
|
||
}
|
||
|
||
const task = runIntentMessage({
|
||
inbound,
|
||
intent,
|
||
user: boundUser,
|
||
})
|
||
.then(async ({ sessionId } = {}) => {
|
||
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
|
||
await userAuth.finishWechatMpMessage({
|
||
appId: config.appId,
|
||
openid: inbound.fromUserName,
|
||
msgId: inbound.msgId,
|
||
status: 'done',
|
||
agentSessionId: sessionId,
|
||
});
|
||
}
|
||
})
|
||
.catch(async (err) => {
|
||
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
|
||
await userAuth.finishWechatMpMessage({
|
||
appId: config.appId,
|
||
openid: inbound.fromUserName,
|
||
msgId: inbound.msgId,
|
||
status: 'failed',
|
||
}).catch(() => {});
|
||
}
|
||
logger.error?.('WeChat MP background reply failed:', err);
|
||
return sendCustomerServiceText(
|
||
inbound.fromUserName,
|
||
`这次转发到专属 Agent 失败了:${err instanceof Error ? err.message : String(err)}`,
|
||
boundUser,
|
||
).catch(() => {});
|
||
});
|
||
|
||
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,
|
||
};
|
||
}
|