import crypto from 'node:crypto'; import { fetch as undiciFetch } from 'undici'; import { mergeMessageContent } from './message-stream.mjs'; import { reconcileAgentSession } from './session-reconcile.mjs'; import { isScheduleIntent, parseScheduleIntent } from './schedule-intent.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_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; 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'), event: parseXmlField(xml, 'Event').toLowerCase(), eventKey: parseXmlField(xml, 'EventKey'), encrypt: parseXmlField(xml, 'Encrypt'), }; } function readJsonResponse(response) { return response.text().then((text) => { if (!response.ok) { throw new Error(text || `upstream ${response.status}`); } return text ? JSON.parse(text) : null; }); } 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) { return { id: crypto.randomUUID(), role: 'user', created: Math.floor(Date.now() / 1000), content: [{ type: 'text', text }], metadata: { userVisible: true, agentVisible: true, }, }; } 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) { 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), }), }); 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 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 formatWechatOutboundText(text) { return stripInternalWechatUsername( convertMarkdownTables(convertMarkdownLinks(text)) .replace(/^\s*#{1,6}\s+/gm, '') .replace(/^\s*---+\s*$/gm, '') .replace(/\n{3,}/g, '\n\n'), ); } 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 buildWechatAgentPrompt(text) { const content = String(text ?? '').trim(); return [ '【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。', '若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。', '', `用户消息:${content}`, ].join('\n'); } 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) || normalizeWechatName(user?.username) || '' ); } 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, 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, }; } export function verifyWechatMpSignature({ token, timestamp, nonce, signature }) { const digest = crypto .createHash('sha1') .update([token, timestamp, nonce].sort().join('')) .digest('hex'); return digest === String(signature ?? ''); } 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, wechatFetch = undiciFetch, 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, }; 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 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) => { const chunks = splitWechatText(formatWechatOutboundText(content)); 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) { throw new Error(payload?.errmsg || `微信客服消息发送失败 (${payload?.errcode})`); } } }; const sendTextToUser = async (userId, content) => { const openid = await userAuth.getWechatOpenidForUser(userId, config.appId); if (!openid) { throw new Error('用户尚未绑定服务号,无法推送提醒'); } await sendCustomerServiceText(openid, content); }; 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 会话创建失败'); } await userAuth.registerAgentSession(userId, sessionId); 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: publishLayout.username, slug: publishLayout.slug, } : 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 runTextMessage = async ({ inbound, user }) => { const forceNew = isTopicResetIntent(inbound.content); let sessionId = await ensureWechatAgentSession({ userId: user.userId, openid: inbound.fromUserName, userContext: user, forceNew, }); 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).catch((err) => { logger.warn?.('WeChat MP progress reply failed:', err); }); }, config.progressDelayMs); progressTimer.unref?.(); } const requestId = crypto.randomUUID(); try { const reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, requestId, buildWechatAgentPrompt(inbound.content), ); if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId); } await sendCustomerServiceText(inbound.fromUserName, reply.text); 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 rememberWechatUserContext(sessionId, user); const retryId = crypto.randomUUID(); const reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, retryId, buildWechatAgentPrompt(inbound.content), ); if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId); } await sendCustomerServiceText(inbound.fromUserName, reply.text); return { sessionId }; } throw err; } finally { finished = true; if (progressTimer) clearTimeout(progressTimer); } }; const handleScheduleIntent = async ({ inbound, user }) => { if (!scheduleService) return null; const intent = parseScheduleIntent(inbound.content); if (!isScheduleIntent(intent)) return null; if (intent.action === 'create_daily_todo_digest') { if (intent.needsClarification?.includes('digest_time')) { return '可以。你想每天几点收到当天待办记录?比如“每天早上 7 点发给我”。'; } const subscription = await scheduleService.createDailyTodoDigest({ userId: user.userId, hour: intent.hour, minute: intent.minute, timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', channel: 'wechat', sourceChannel: 'wechat', sourceMessageId: inbound.msgId || null, sourceText: inbound.content, }); const minuteText = subscription.minute === 0 ? '' : `${String(subscription.minute).padStart(2, '0')}分`; return `已设置:我会每天早上 ${subscription.hour}点${minuteText} 通过服务号把当天待办记录发给你。`; } if (intent.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 ?? '')); 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 === 'subscribe') { 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 !== 'text' || !inbound.content.trim()) { 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) { 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') { return { ok: true, status: 200, contentType: 'application/xml; charset=utf-8', body: buildWechatTextReply({ toUserName: inbound.fromUserName, fromUserName: inbound.toUserName, content: '当前账号不可用,请联系管理员处理。', }), }; } if (isQuestionStatusProbe(inbound.content)) { 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 (isSimpleGreeting(inbound.content)) { return { ok: true, status: 200, contentType: 'application/xml; charset=utf-8', body: buildWechatTextReply({ toUserName: inbound.fromUserName, fromUserName: inbound.toUserName, content: buildGreetingText(boundUser), }), }; } if (isConnectivityTest(inbound.content)) { 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), }), }; } } const scheduleReply = await handleScheduleIntent({ inbound, user: boundUser }); 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 = runTextMessage({ inbound, 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)}`, ).catch(() => {}); }); return { ok: true, status: 200, contentType: 'application/xml; charset=utf-8', body: buildWechatTextReply({ toUserName: inbound.fromUserName, fromUserName: inbound.toUserName, content: 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, handleInboundMessage, getRouteStatusForUser, recreateRouteForUser, createJsSdkSignature, sendTextToUser, }; }