Files
memind/wechat-mp.mjs
T

2452 lines
88 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 { 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 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,
};
}