import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fetch as undiciFetch } from 'undici'; 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; function parseXmlField(xml, field) { const cdataMatch = xml.match(new RegExp(`<${field}><\\/${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 = []; 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('当前回复需要人工确认,公众号通道暂不支持'); } messages = pushMessage(messages, event.message); } else if (event.type === 'UpdateConversation') { 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'); return { text: messageVisibleText(assistant), tokenState: event.token_state ?? null, }; } } } 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 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 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 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 (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 [ '', ``, ``, `${Number(createTime) || Math.floor(Date.now() / 1000)}`, '', ``, '', ].join(''); } export function createWechatMpService({ config, userAuth, apiFetch, sessionApiFetch = null, scheduleService = null, applySessionLlmProvider = 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) => { const formatted = formatWechatOutboundText(content, user); const guarded = await guardMissingPublicHtmlLinks(formatted, { linkExists }); 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 existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid); if (existingRoute?.agentSessionId) { return existingRoute.agentSessionId; } const gate = await userAuth.canUseChat(userId); if (!gate.ok) { throw new Error(gate.message || '当前用户无法使用聊天能力'); } const workingDir = await userAuth.resolveWorkingDir(userId); const sessionPolicy = await userAuth.getAgentSessionPolicy(userId); const publishLayout = await userAuth.getUserPublishLayout(userId); const started = 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. const addressName = resolveWechatAddressName(userContext); 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 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, }); 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 reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, requestId, buildWechatAgentPrompt(intent), buildIntentMetadata(intent), ); if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId); } await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user); return { sessionId }; } catch (err) { const message = err instanceof Error ? err.message : String(err); const mayBeStaleSession = /403|404|not found|无权访问|session/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 reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, retryId, buildWechatAgentPrompt(intent), buildIntentMetadata(intent), ); if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId); } await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user); 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, }; }