Files
memind/wechat-mp.mjs
T

2494 lines
87 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fetch as undiciFetch } from 'undici';
import { developerToolsFromPolicy } from './capabilities.mjs';
import { mergeMessageContent } from './message-stream.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
import { materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs';
import { buildCurrentTimeAgentPrefix } from './user-memory-profile.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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function parseXmlField(xml, field) {
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${field}>`));
if (cdataMatch) return cdataMatch[1];
const plainMatch = xml.match(new RegExp(`<${field}>([\\s\\S]*?)<\\/${field}>`));
return plainMatch ? plainMatch[1].trim() : '';
}
function parseWechatMessage(xml) {
return {
toUserName: parseXmlField(xml, 'ToUserName'),
fromUserName: parseXmlField(xml, 'FromUserName'),
createTime: Number(parseXmlField(xml, 'CreateTime') || 0),
msgType: parseXmlField(xml, 'MsgType').toLowerCase(),
content: parseXmlField(xml, 'Content'),
msgId: parseXmlField(xml, 'MsgId'),
mediaId: parseXmlField(xml, 'MediaId'),
format: parseXmlField(xml, 'Format'),
recognition: parseXmlField(xml, 'Recognition'),
picUrl: parseXmlField(xml, 'PicUrl'),
locationX: parseXmlField(xml, 'Location_X'),
locationY: parseXmlField(xml, 'Location_Y'),
scale: parseXmlField(xml, 'Scale'),
label: parseXmlField(xml, 'Label'),
title: parseXmlField(xml, 'Title'),
description: parseXmlField(xml, 'Description'),
url: parseXmlField(xml, 'Url'),
thumbMediaId: parseXmlField(xml, 'ThumbMediaId'),
event: parseXmlField(xml, 'Event').toLowerCase(),
eventKey: parseXmlField(xml, 'EventKey'),
latitude: parseXmlField(xml, 'Latitude'),
longitude: parseXmlField(xml, 'Longitude'),
precision: parseXmlField(xml, 'Precision'),
encrypt: parseXmlField(xml, 'Encrypt'),
rawXml: xml,
};
}
function readJsonResponse(response) {
return response.text().then((text) => {
if (!response.ok) {
throw new Error(text || `upstream ${response.status}`);
}
return text ? JSON.parse(text) : null;
});
}
async function readJsonBody(response) {
const text = await response.text();
if (!text) return { payload: null, text: '' };
try {
return { payload: JSON.parse(text), text };
} catch {
return { payload: null, text };
}
}
function sanitizeAsrMessage(message) {
if (!message || typeof message !== 'string') return '识别失败,请重试';
const trimmed = message.trim();
if (!trimmed) return '识别失败,请重试';
if (/Failed to load audio|ffmpeg|Invalid data found when processing input|moov atom not found/i.test(trimmed)) {
return '音频格式无法识别,请重新录制';
}
if (/timeout|timed out/i.test(trimmed)) return '识别超时,请重试';
if (trimmed.length > 160) return `${trimmed.slice(0, 160)}`;
return trimmed;
}
function guessVoiceMimeType(format = '', contentType = '') {
const normalizedContentType = String(contentType ?? '')
.split(';')[0]
.trim()
.toLowerCase();
if (normalizedContentType) return normalizedContentType;
const normalizedFormat = String(format ?? '').trim().toLowerCase();
if (normalizedFormat === 'amr') return 'audio/amr';
if (normalizedFormat === 'wav') return 'audio/wav';
if (normalizedFormat === 'mp3') return 'audio/mpeg';
if (normalizedFormat === 'm4a') return 'audio/mp4';
if (normalizedFormat === 'ogg') return 'audio/ogg';
return 'application/octet-stream';
}
function deriveWechatEndpointFromUrl(sourceUrl, pathname) {
if (!sourceUrl) return '';
try {
const url = new URL(sourceUrl);
url.pathname = pathname;
url.search = '';
return url.toString();
} catch {
return '';
}
}
function createUserMessage(text, metadata = {}) {
return {
id: crypto.randomUUID(),
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text }],
metadata: {
userVisible: true,
agentVisible: true,
...metadata,
},
};
}
function messageVisibleText(message) {
if (!message?.content) return '';
return message.content
.filter((item) => item.type === 'text' && typeof item.text === 'string')
.map((item) => item.text)
.join('');
}
function pushMessage(messages, incoming) {
const last = messages[messages.length - 1];
if (last?.id && incoming?.id && last.id === incoming.id) {
return [
...messages.slice(0, -1),
{
...last,
content: mergeMessageContent(last.content, incoming.content),
},
];
}
return [...messages, incoming];
}
async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metadata = {}) {
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
method: 'GET',
headers: { Accept: 'text/event-stream' },
});
if (!eventsResponse.ok || !eventsResponse.body) {
const text = await eventsResponse.text().catch(() => '');
throw new Error(text || '无法建立公众号消息事件流');
}
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
method: 'POST',
body: JSON.stringify({
request_id: requestId,
user_message: createUserMessage(prompt, metadata),
}),
});
if (!replyResponse.ok) {
const text = await replyResponse.text().catch(() => '');
throw new Error(text || 'Agent reply 请求失败');
}
replyResponse.body?.cancel().catch?.(() => {});
const reader = eventsResponse.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let messages = [];
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 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(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, { 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(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 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 }) {
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, '生成未完成');
}
function buildHtmlPublishFailureText() {
return [
'这次页面没有按 H5 里的页面技能真正生成成功,所以我先不发不一致的简版页。',
'请直接重发一次你的页面需求,我会按和 H5 相同的 `static-page-publish` 技能链路重做。',
].join('\n');
}
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,
}) {
materializeMissingPublicHtmlWrites({
messages: reply?.messages ?? [],
publishDir: workingDir,
});
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
workingDir,
publicBaseUrl,
});
const expectedArtifacts = collectExpectedHtmlArtifacts(intent, {
workingDir,
publicBaseUrl,
});
const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, {
workingDir,
publicBaseUrl,
replyText: reply?.text,
sinceMs: requestStartedAt,
});
const confirmedArtifacts = allExistingHtmlArtifacts({
publishedArtifacts,
expectedArtifacts,
recentArtifacts,
});
const verifiedArtifacts = buildVerifiedHtmlArtifacts({
publishedArtifacts,
expectedArtifacts,
recentArtifacts,
replyText: reply?.text,
});
return {
publishedArtifacts,
expectedArtifacts,
recentArtifacts,
confirmedArtifacts,
verifiedArtifacts,
};
}
export function shouldRetryHtmlGenerationReply({
reply,
intent,
confirmedArtifacts = [],
hasValidLinkInReply = false,
}) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) 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(reply?.messages ?? [])) return true;
return !hasAnyUrl(reply?.text);
}
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;
}
export function buildWechatAgentPrompt(intent) {
const msgType = String(intent?.msgType ?? 'text');
const docxDownloadHint =
looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content) &&
looksLikeDocxDownloadIntent(intent?.agentText ?? intent?.content)
? [
'【Word 下载要求】用户明确要求页面里可下载 Word / docx。',
'开始前必须先调用 `load_skill` → `docx-generate`,并按技能说明生成目标 `public/*.docx`。',
'生成后必须确认目标 `.docx` 已存在,再调用 `load_skill` → `static-page-publish` 写 `public/*.html`。',
'HTML 中只能用同目录相对路径链接该文档;禁止只写下载按钮却没有先把 `.docx` 落盘。',
'',
].join('\n')
: '';
const pagePublishHint = looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content)
? [
'【页面发布技能要求】这条消息是在生成可访问 HTML 页面。',
'开始前必须先调用 `load_skill` → `static-page-publish`,不能省略,也不能写完页面后再补调。',
'随后必须用 sandbox-fs 的 `write_file` / `edit_file` 写入 `public/*.html`。',
'禁止用 shell / cat / heredoc / echo / cp 写入 HTMLshell 在容器内执行,文件不会出现在公网 MindSpace 路径,用户会收到「文件不存在」。',
'在回复用户前,确认 `public/` 下目标 HTML 已通过 write_file 落盘;没有落盘就不要发送链接或说「已发布」。',
'最终只给用户一个正式域名的唯一正确链接;不要输出错误域名、备用链接或让用户手动保存文件。',
'',
].join('\n')
: '';
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
const currentTimeHint = buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone });
const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content)
? [
'【日程技能要求】这条消息涉及待办、提醒或日程。',
'开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。',
'写入工具时优先使用 startLocal / endLocal / remindLocalYYYY-MM-DD HH:mm),不要自行估算 Unix 毫秒。',
'只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。',
intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '',
'',
].filter(Boolean).join('\n')
: '';
if (msgType === 'voice') {
return [
docxDownloadHint,
currentTimeHint,
scheduleAssistantHint,
'【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。',
'',
`用户语音识别文本:${String(intent?.agentText ?? '').trim()}`,
]
.filter(Boolean)
.join('\n');
}
if (msgType === 'image') {
return [
docxDownloadHint,
currentTimeHint,
scheduleAssistantHint,
'【微信服务号图片消息】用户发送了图片。',
'如用户没有明确要求,请先根据图片内容给出简短理解,并询问下一步。',
'',
String(intent?.agentText ?? '').trim(),
]
.filter(Boolean)
.join('\n');
}
if (msgType === 'location') {
const location = intent?.location ?? {};
return [
currentTimeHint,
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 [
currentTimeHint,
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 = [currentTimeHint];
if (docxDownloadHint) lines.push(docxDownloadHint);
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',
'updateAppMessageShareData',
'updateTimelineShareData',
'onMenuShareAppMessage',
'onMenuShareTimeline',
],
};
};
const sendCustomerServiceText = async (
openid,
content,
user = null,
{ verifiedHtmlUrls = [], linkExistsForRequest = linkExists } = {},
) => {
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) => {
const normalized = String(url ?? '').trim();
if (verifiedUrlSet.has(normalized) && !linkExistsForRequest(normalized)) {
verifiedUrlSet.delete(normalized);
}
return linkExistsForRequest(url);
},
}),
);
const chunks = splitWechatText(guarded);
const accessToken = await getStableAccessToken();
for (const chunk of chunks) {
const payload = await readJsonResponse(
await wechatFetch(
`${config.customerServiceUrl}?access_token=${encodeURIComponent(accessToken)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
touser: openid,
msgtype: 'text',
text: {
content: chunk,
},
}),
},
),
);
if (Number(payload?.errcode ?? 0) !== 0) {
const errcode = Number(payload?.errcode ?? 0);
const errmsg = String(payload?.errmsg ?? '').trim() || 'unknown_error';
throw new Error(`微信客服消息发送失败 errcode=${errcode} errmsg=${errmsg}`);
}
}
};
const sendTextToUser = async (userId, content) => {
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
if (!openid) {
throw new Error('用户尚未绑定服务号,无法推送提醒');
}
await sendCustomerServiceText(openid, content);
};
const ensureSessionProvider = async (sessionId) => {
if (!applySessionLlmProvider || !sessionId) return;
const applied = await applySessionLlmProvider(sessionId);
if (applied && applied.ok === false) {
throw new Error(applied.message || '专属 Agent Provider 未配置');
}
};
const transcribeWechatVoiceMedia = async (mediaId, format) => {
if (!mediaId) return '';
const accessToken = await getStableAccessToken();
const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch });
if (!downloaded.buffer?.length) return '';
const extension = String(format ?? '').trim().toLowerCase() || 'amr';
const form = new FormData();
form.append(
'file',
new Blob([downloaded.buffer], {
type: guessVoiceMimeType(format, downloaded.contentType),
}),
`wechat-voice.${extension}`,
);
const response = await wechatFetch(`${config.asrTarget.replace(/\/$/, '')}/asr/oneshot`, {
method: 'POST',
body: form,
});
const { payload, text } = await readJsonBody(response);
if (!response.ok) {
throw new Error(sanitizeAsrMessage(payload?.message ?? text ?? `ASR HTTP ${response.status}`));
}
if (Number(payload?.code ?? 200) !== 200) {
throw new Error(sanitizeAsrMessage(payload?.message ?? text ?? '识别失败,请重试'));
}
return String(payload?.data?.text ?? '').trim();
};
const ensureWechatAgentSession = async ({
userId,
openid,
forceNew = false,
userContext = null,
}) => {
if (forceNew) {
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
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 会话创建失败');
}
// `/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 && 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 linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
const {
publishedArtifacts,
verifiedArtifacts,
expectedArtifacts,
recentArtifacts,
confirmedArtifacts,
} = resolveHtmlPublishArtifacts({
reply,
intent,
workingDir,
publicBaseUrl: config.publicBaseUrl,
requestStartedAt,
});
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
confirmedArtifacts,
});
if (
shouldRetryHtmlGenerationReply({
reply,
intent,
confirmedArtifacts,
hasValidLinkInReply,
}) ||
(expectedArtifacts.length === 0 &&
recentArtifacts.length === 0 &&
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })))
) {
throw new Error('stale_session_poisoned_completion');
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
}
if (looksLikeHtmlGenerationIntent(intent?.agentText) && verifiedArtifacts.length === 0 && confirmedArtifacts.length === 0) {
throw new Error(buildHtmlPublishFailureText());
}
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts,
});
await refreshWechatSessionSnapshot(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map(
(artifact) => artifact.url,
),
linkExistsForRequest,
});
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 linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
const {
publishedArtifacts,
verifiedArtifacts,
expectedArtifacts,
recentArtifacts,
confirmedArtifacts,
} = resolveHtmlPublishArtifacts({
reply,
intent,
workingDir,
publicBaseUrl: config.publicBaseUrl,
requestStartedAt: retryStartedAt,
});
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
confirmedArtifacts,
});
if (
shouldRetryHtmlGenerationReply({
reply,
intent,
confirmedArtifacts,
hasValidLinkInReply,
}) ||
(expectedArtifacts.length === 0 &&
recentArtifacts.length === 0 &&
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })))
) {
throw new Error(buildHtmlPublishFailureText());
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
}
if (looksLikeHtmlGenerationIntent(intent?.agentText) && verifiedArtifacts.length === 0 && confirmedArtifacts.length === 0) {
throw new Error(buildHtmlPublishFailureText());
}
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts,
});
await refreshWechatSessionSnapshot(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map(
(artifact) => artifact.url,
),
linkExistsForRequest,
});
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,
};
}