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 { resolveSessionAccess } from './session-broker.mjs'; import { isStubPublicHtmlContent, materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs'; import { loadWechatMpConfig } from './wechat-mp-config.mjs'; import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs'; import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs'; import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs'; import { buildAckText } from './wechat/ack/ack-provider.mjs'; import { guardScheduleConfirmationReply } from './wechat/handlers/schedule-guard.mjs'; import { handleWechatScheduleIntent } from './wechat/handlers/schedule.mjs'; import { buildStatusText, resolveSyncReply, shouldHandleSyncReply, } from './wechat/handlers/sync-replies.mjs'; import { classifyWechatIntent } from './wechat/intent/classifier.mjs'; import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs'; import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs'; import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs'; import { buildPageGenerateAgentPrompt, buildPagePublishFailureText, } from './wechat/prompts/page-generate.mjs'; import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs'; export { buildWechatAgentPrompt }; 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'; export { loadWechatMpConfig }; const PUBLIC_HTML_LINK_PATTERN = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi; const SESSION_WORKSPACE_TOOL_NAMES = new Set([ 'write', 'edit', 'tree', 'read_file', 'write_file', 'edit_file', 'create_dir', 'list_dir', ]); function escapeRegExp(value) { return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function escapeHtml(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function parseXmlField(xml, field) { const cdataMatch = xml.match(new RegExp(`<${field}><\\/${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('本轮未收到可发送的新回复,请稍后重试'); } const reply = { text: messageVisibleText(assistant), tokenState: event.token_state ?? null, messages, }; assertWechatAgentReplyIsSendable(reply); return reply; } } } 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) { return isPageGenerateText(text); } function looksLikeDocxDownloadIntent(text) { const normalized = String(text ?? '').trim(); if (!normalized) return false; return /(?:(?:word|docx|\.docx|\.doc|文档).*(?:下载|链接|导出|给我)|(?:下载|导出|提供|给我).*(?:word|docx|\.docx|\.doc|文档))/iu.test(normalized); } function usedStaticPagePublishSkill(messages = []) { return messages.some((message) => message?.content?.some((item) => { if (item?.type !== 'toolRequest') return false; const toolCall = item.toolCall?.value; const name = String(toolCall?.name ?? '').trim(); const args = toolCall?.arguments ?? {}; const skillName = String(args.name ?? '').trim(); return name === 'load_skill' && skillName === 'static-page-publish'; }), ); } function isBareCompletionText(text) { const normalized = String(text ?? '').trim(); return /^(?:已完成|完成了|完成)$/u.test(normalized); } function hasAnyToolRequest(messages = []) { return messages.some((message) => message?.content?.some((item) => item?.type === 'toolRequest'), ); } function isSuspiciousBareCompletionReply(reply, intent) { if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; if (!isBareCompletionText(reply?.text)) return false; if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false; return !hasAnyToolRequest(reply?.messages ?? []); } function looksLikePublishSuccessClaim(text) { const normalized = String(text ?? '').trim(); if (!normalized) return false; return /(?:页面|网页|html).*(?:已创建|已生成|已发布|创建完毕|生成完成|发布成功)|(?:成功发布|已经发布|已创建完毕).*(?:页面|网页|html)/iu.test(normalized); } async function hasAnyValidPublishedHtmlLink(text, linkExists, { confirmedArtifacts = [] } = {}) { const value = String(text ?? ''); for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN)) { try { if (await linkExists(match[0])) return true; } catch { // treat lookup failures as missing links } const filename = String(match[2] ?? '').split('/').pop()?.trim(); if ( filename && confirmedArtifacts.some((artifact) => { const artifactName = path.posix.basename(String(artifact.relativePath ?? '').replace(/\\/g, '/')); return artifactName === filename && artifactFileExists(artifact); }) ) { return true; } } return false; } async function isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists = defaultPublicHtmlLinkExists } = {}) { if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; const text = String(reply?.text ?? '').trim(); if (!looksLikePublishSuccessClaim(text)) return false; if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false; return !(await hasAnyValidPublishedHtmlLink(text, linkExists)); } function isMissingRequiredPublishSkill(reply, intent) { if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; const wroteHtml = extractHtmlWriteTargets(reply?.messages ?? []).length > 0; if (!wroteHtml) return false; return !usedStaticPagePublishSkill(reply?.messages ?? []); } function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) { const owner = path.basename(path.resolve(workingDir)); const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/'); return buildPublicUrl(publicBaseUrl, owner, normalized); } function ensurePublicHtmlArtifact(htmlPath, workingDir) { const workspaceRoot = path.resolve(workingDir); const source = path.isAbsolute(String(htmlPath ?? '')) ? path.resolve(String(htmlPath)) : path.resolve(workspaceRoot, String(htmlPath ?? '')); if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null; if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null; const publicRoot = path.join(workspaceRoot, 'public'); let publishedPath = source; if (source !== publicRoot && !source.startsWith(`${publicRoot}${path.sep}`)) { fs.mkdirSync(publicRoot, { recursive: true }); publishedPath = path.join(publicRoot, path.basename(source)); if (publishedPath !== source) fs.copyFileSync(source, publishedPath); } const relativePath = path.relative(workspaceRoot, publishedPath); if (!relativePath || relativePath.startsWith('..')) return null; return { localPath: publishedPath, relativePath, }; } function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) { const artifacts = []; const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []); for (const target of htmlTargets) { const artifact = ensurePublicHtmlArtifact(target, workingDir); if (!artifact) continue; artifacts.push({ ...artifact, url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl), }); } return artifacts; } function extractRequestedHtmlTarget(text) { const value = String(text ?? ''); if (!value) return ''; const explicitPublicMatch = value.match(/(?:^|[\s`'"])(public\/[a-z0-9._-]+\.html)\b/i); if (explicitPublicMatch?.[1]) return explicitPublicMatch[1]; const bareMatch = value.match(/(?:^|[\s`'"])([a-z0-9._-]+\.html)\b/i); if (bareMatch?.[1]) return `public/${bareMatch[1]}`; return ''; } function collectExpectedHtmlArtifacts(intent, { workingDir, publicBaseUrl }) { const target = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? ''); if (!target) return []; const artifact = ensurePublicHtmlArtifact(target, workingDir); if (!artifact) return []; return [{ ...artifact, url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl), }]; } function sanitizeFilenameSlug(value) { const normalized = String(value ?? '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return normalized || 'simple-page'; } function hasAnyUrl(text) { return /https?:\/\/\S+/i.test(String(text ?? '')); } function hasAnyPublicHtmlLink(text) { return [...String(text ?? '').matchAll(PUBLIC_HTML_LINK_PATTERN)].length > 0; } function collectPublicHtmlLinkFilenames(text) { const filenames = new Set(); for (const match of String(text ?? '').matchAll(PUBLIC_HTML_LINK_PATTERN)) { const relativePath = String(match[2] ?? '').trim(); const filename = path.posix.basename(relativePath); if (filename) filenames.add(filename); } return filenames; } function collectRecentPublishedHtmlArtifacts( intent, { workingDir, publicBaseUrl, replyText = '', sinceMs = 0, limit = 20 } = {}, ) { const publicRoot = path.join(path.resolve(workingDir), 'public'); if (!fs.existsSync(publicRoot) || !fs.statSync(publicRoot).isDirectory()) return []; const expectedTarget = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? ''); const expectedRelativePath = expectedTarget ? expectedTarget.replace(/^\/+/, '').replace(/\\/g, '/') : ''; const linkedFilenames = collectPublicHtmlLinkFilenames(replyText); const artifacts = []; const stack = [publicRoot]; while (stack.length > 0 && artifacts.length < limit) { const current = stack.pop(); let entries = []; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const absolutePath = path.join(current, entry.name); if (entry.isDirectory()) { stack.push(absolutePath); continue; } if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.html')) continue; let stat; try { stat = fs.statSync(absolutePath); } catch { continue; } if (sinceMs > 0 && Number(stat.mtimeMs ?? 0) + 1 < sinceMs) continue; const relativePath = path.relative(path.resolve(workingDir), absolutePath); if (!relativePath || relativePath.startsWith('..')) continue; const normalizedRelativePath = relativePath.replace(/\\/g, '/'); const filename = path.posix.basename(normalizedRelativePath); if (linkedFilenames.size > 0 && filename && !linkedFilenames.has(filename)) continue; artifacts.push({ localPath: absolutePath, relativePath: normalizedRelativePath, url: buildPublicHtmlUrl(workingDir, normalizedRelativePath, publicBaseUrl), mtimeMs: Number(stat.mtimeMs ?? 0), matchesExpected: expectedRelativePath ? normalizedRelativePath === expectedRelativePath : false, }); if (artifacts.length >= limit) break; } } return artifacts .sort((left, right) => { if (left.matchesExpected !== right.matchesExpected) return left.matchesExpected ? -1 : 1; return right.mtimeMs - left.mtimeMs; }) .map(({ mtimeMs, matchesExpected, ...artifact }) => artifact); } function rewritePublishedHtmlLinks(text, artifacts = []) { const value = String(text ?? ''); if (!value || artifacts.length === 0) return value; let next = value; for (const artifact of artifacts) { const canonicalUrl = String(artifact?.url ?? '').trim(); const filename = String(artifact?.relativePath ?? '').replace(/\\/g, '/').split('/').pop(); if (!canonicalUrl || !filename) continue; const wrongPublicLinkPattern = new RegExp( `https?:\\/\\/[^\\s)]+\\/(?:MindSpace\\/[^\\s/]+\\/)?public\\/${escapeRegExp(filename)}\\b`, 'g', ); const wrongTkmindHtmlLinkPattern = new RegExp( `https?:\\/\\/(?:[^\\s./]+\\.)*tkmind\\.cn\\/[^\n\\s)]*${escapeRegExp(filename)}\\b`, 'gi', ); next = next.replace(wrongPublicLinkPattern, canonicalUrl); next = next.replace(wrongTkmindHtmlLinkPattern, canonicalUrl); next = next.replace(PUBLIC_HTML_LINK_PATTERN, (match) => { if (match === canonicalUrl) return match; const matchedFilename = String(match).split('/').pop(); return matchedFilename === filename ? canonicalUrl : match; }); } return next; } async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl, artifacts: providedArtifacts = null }) { const artifacts = Array.isArray(providedArtifacts) ? providedArtifacts : collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }); const baseText = rewritePublishedHtmlLinks(String(reply?.text ?? '').trim(), artifacts).trim(); for (const artifact of artifacts) { const url = artifact.url; if (baseText.includes(url)) return baseText; return baseText ? `${baseText}\n\n查看页面:\n${url}` : `查看页面:\n${url}`; } return baseText; } function stripInternalWechatUsername(text) { return String(text ?? '').replace(/^\s*wx_[a-z0-9_]{4,64}\s*[,,、::]\s*/i, ''); } function replaceLeadingInternalWechatUsername(text, user) { const addressName = normalizeWechatName(user?.nickname) || normalizeWechatName(user?.displayName); if (!addressName) return stripInternalWechatUsername(text); return String(text ?? '').replace( /(^|[\s,,、::])wx_[a-z0-9_]{4,64}\s*([,,、::])\s*/gi, (_match, prefix = '', punctuation = ',') => `${prefix}${addressName}${punctuation}`, ); } function convertMarkdownLinks(text) { return String(text ?? '').replace(/\[([^\]\n]{1,160})\]\((https?:\/\/[^\s)]+)\)/g, (_match, label, url) => { const cleanLabel = String(label ?? '').trim(); const cleanUrl = String(url ?? '').trim(); return cleanLabel ? `${cleanLabel}\n${cleanUrl}` : cleanUrl; }); } function splitMarkdownTableRow(line) { const trimmed = String(line ?? '').trim(); if (!trimmed.includes('|')) return null; return trimmed .replace(/^\|/, '') .replace(/\|$/, '') .split('|') .map((cell) => cell.trim()); } function isMarkdownTableSeparator(line) { const cells = splitMarkdownTableRow(line); return Boolean(cells?.length) && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); } function convertMarkdownTables(text) { const lines = String(text ?? '').split('\n'); const output = []; for (let index = 0; index < lines.length; index += 1) { const headers = splitMarkdownTableRow(lines[index]); if (!headers || !isMarkdownTableSeparator(lines[index + 1])) { output.push(lines[index]); continue; } const readableRows = []; index += 2; while (index < lines.length) { const cells = splitMarkdownTableRow(lines[index]); if (!cells) break; const parts = cells .map((cell, cellIndex) => { const header = headers[cellIndex] || `列${cellIndex + 1}`; return cell ? `${header}:${cell}` : ''; }) .filter(Boolean); if (parts.length) readableRows.push(`- ${parts.join(';')}`); index += 1; } index -= 1; output.push(...readableRows); } return output.join('\n'); } function stripMarkdownEmphasis(text) { return String(text ?? '') .replace(/\*\*([^*\n]+)\*\*/g, '$1') .replace(/__([^_\n]+)__/g, '$1') .replace(/`([^`\n]+)`/g, '$1') .replace(/\*/g, '') .replace(/__/g, ''); } function formatWechatOutboundText(text, user = null) { return replaceLeadingInternalWechatUsername( stripMarkdownEmphasis(convertMarkdownTables(convertMarkdownLinks(text))) .replace(/^\s*#{1,6}\s+/gm, '') .replace(/^\s*---+\s*$/gm, '') .replace(/\n{3,}/g, '\n\n'), user, ); } function defaultPublicHtmlLinkExists(urlText) { let url; try { url = new URL(urlText); } catch { return true; } const parts = url.pathname.split('/').filter(Boolean).map((part) => { try { return decodeURIComponent(part); } catch { return part; } }); if (parts.length < 4 || parts[0] !== PUBLISH_ROOT_DIR || parts[2] !== 'public') return true; const owner = parts[1]; const rest = parts.slice(3); if (!owner || rest.some((part) => !part || part === '.' || part === '..')) return false; const root = path.resolve(process.cwd(), PUBLISH_ROOT_DIR, owner, 'public'); const target = path.resolve(root, ...rest); if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false; return fs.existsSync(target) && fs.statSync(target).isFile(); } export function createPublicHtmlLinkExists(workingDir) { const workspaceRoot = path.resolve(String(workingDir ?? '')); const workspaceKey = path.basename(workspaceRoot); return (urlText) => { let url; try { url = new URL(urlText); } catch { return true; } const parts = url.pathname.split('/').filter(Boolean).map((part) => { try { return decodeURIComponent(part); } catch { return part; } }); if (parts.length < 4 || parts[0] !== PUBLISH_ROOT_DIR || parts[2] !== 'public') return true; const owner = parts[1]; const rest = parts.slice(3); if (!owner || rest.some((part) => !part || part === '.' || part === '..')) return false; if (owner === workspaceKey) { const root = path.join(workspaceRoot, 'public'); const target = path.resolve(root, ...rest); if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false; return fs.existsSync(target) && fs.statSync(target).isFile(); } return defaultPublicHtmlLinkExists(urlText); }; } function resolveLinkExistsForWorkingDir(workingDir, linkExists = defaultPublicHtmlLinkExists) { if (linkExists !== defaultPublicHtmlLinkExists) return linkExists; return createPublicHtmlLinkExists(workingDir); } export async function guardMissingPublicHtmlLinks( text, { linkExists = defaultPublicHtmlLinkExists } = {}, ) { const value = String(text ?? ''); const replacements = []; for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN)) { const url = match[0]; let exists = true; try { exists = await linkExists(url); } catch { exists = false; } if (!exists) { const filename = match[2].split('/').pop() ?? '页面'; replacements.push([ url, `(页面生成未完成,已阻止发送失效链接:${filename}。请重新生成页面后再打开。)`, ]); } } return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value); } function downgradePrematurePublishClaims(text) { const value = String(text ?? ''); if (!value.includes('页面生成未完成')) return value; return value .replace(/(^|\n)([^\n]*页面都已经成功发布了[^\n]*)(?=\n|$)/g, '$1页面暂未生成完成,这次我先不发送失效链接。') .replace(/(^|\n)([^\n]*主题页面已发布[^\n]*)(?=\n|$)/g, '$1🌴 夏日主题页面生成未完成') .replace(/页面已创建完毕/g, '页面生成未完成') .replace(/页面已创建/g, '页面生成未完成') .replace(/发布成功/g, '生成未完成') .replace(/已发布成功/g, '生成未完成'); } export function buildHtmlPublishFailureText() { return buildPagePublishFailureText(); } export function isHtmlPublishFailureMessage(message) { const normalized = String(message ?? '').trim(); return ( normalized.includes('页面技能') || normalized.includes('static-page-publish') || normalized.includes('简版页') ); } export { maybeAttachPublishedHtmlLink }; function artifactFileExists(artifact) { const localPath = String(artifact?.localPath ?? '').trim(); if (!localPath) return false; try { return fs.existsSync(localPath) && fs.statSync(localPath).isFile(); } catch { return false; } } function uniqueArtifactsByUrl(artifacts = []) { const byUrl = new Map(); for (const artifact of artifacts) { const url = String(artifact?.url ?? '').trim(); if (url) byUrl.set(url, artifact); } return [...byUrl.values()]; } function allExistingHtmlArtifacts({ publishedArtifacts = [], expectedArtifacts = [], recentArtifacts = [] } = {}) { return uniqueArtifactsByUrl([ ...publishedArtifacts, ...expectedArtifacts, ...recentArtifacts, ]).filter((artifact) => artifactFileExists(artifact)); } function buildVerifiedHtmlArtifacts({ publishedArtifacts = [], expectedArtifacts = [], recentArtifacts = [], replyText = '', } = {}) { const existing = allExistingHtmlArtifacts({ publishedArtifacts, expectedArtifacts, recentArtifacts }); const linkedFilenames = collectPublicHtmlLinkFilenames(replyText); if (linkedFilenames.size === 0) return existing; const matched = existing.filter((artifact) => { const filename = path.posix.basename(String(artifact.relativePath ?? '').replace(/\\/g, '/')); return filename && linkedFilenames.has(filename); }); return matched; } function resolveHtmlPublishArtifacts({ reply, intent, workingDir, publicBaseUrl, requestStartedAt, }) { materializeMissingPublicHtmlWrites({ messages: reply?.messages ?? [], publishDir: workingDir, }); const publishedArtifacts = collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl, }); const expectedArtifacts = collectExpectedHtmlArtifacts(intent, { workingDir, publicBaseUrl, }); const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, { workingDir, publicBaseUrl, replyText: reply?.text, sinceMs: requestStartedAt, }); const confirmedArtifacts = allExistingHtmlArtifacts({ publishedArtifacts, expectedArtifacts, recentArtifacts, }); const verifiedArtifacts = buildVerifiedHtmlArtifacts({ publishedArtifacts, expectedArtifacts, recentArtifacts, replyText: reply?.text, }); return { publishedArtifacts, expectedArtifacts, recentArtifacts, confirmedArtifacts, verifiedArtifacts, }; } function selectHtmlPublishArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) { return selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }); } function isStubPublicHtmlArtifact(artifact) { const localPath = String(artifact?.localPath ?? '').trim(); if (!localPath) return false; try { return isStubPublicHtmlContent(fs.readFileSync(localPath, 'utf8')); } catch { return false; } } function nonStubHtmlArtifacts(artifacts = []) { return artifacts.filter((artifact) => artifactFileExists(artifact) && !isStubPublicHtmlArtifact(artifact)); } export function shouldRetryHtmlGenerationReply({ reply, intent, confirmedArtifacts = [], hasValidLinkInReply = false, }) { if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; if (nonStubHtmlArtifacts(confirmedArtifacts).length > 0) return false; const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); if (replyHasPublicLinks) { if (!hasValidLinkInReply) return true; if (isMissingRequiredPublishSkill(reply, intent)) return true; return false; } if (confirmedArtifacts.length > 0) return false; if (isMissingRequiredPublishSkill(reply, intent) || isSuspiciousBareCompletionReply(reply, intent)) { return true; } if (!usedStaticPagePublishSkill(reply?.messages ?? [])) return false; return !hasAnyUrl(reply?.text); } function isTopicResetIntent(text) { return isTopicResetText(text); } export function isRecoverableWechatAgentSessionError(message) { const normalized = String(message ?? '').trim(); if (!normalized) return false; if (/stale_session_poisoned_completion/i.test(normalized)) return true; if (/403|404|not found|无权访问/i.test(normalized)) return true; if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true; return false; } function collectWechatAgentReplyVisibleTexts(reply) { const texts = []; const seen = new Set(); const append = (text) => { const normalized = String(text ?? '').trim(); if (!normalized || seen.has(normalized)) return; seen.add(normalized); texts.push(normalized); }; append(reply?.text); for (const message of reply?.messages ?? []) { if (message?.role !== 'assistant') continue; append(messageVisibleText(message)); } return texts; } export function findRecoverableWechatAgentErrorInReply(reply) { for (const text of collectWechatAgentReplyVisibleTexts(reply)) { if (isRecoverableWechatAgentSessionError(text)) return text; } return null; } export function assertWechatAgentReplyIsSendable(reply) { const recoverable = findRecoverableWechatAgentErrorInReply(reply); if (recoverable) throw new Error(recoverable); } export function isWechatAgentApiErrorText(message) { const normalized = String(message ?? '').trim(); if (!normalized) return false; if (isRecoverableWechatAgentSessionError(normalized)) return true; if (/Ran into this error:/i.test(normalized)) return true; return /Request failed:\s*(Bad request|Internal Server Error)/i.test(normalized); } export function sanitizeWechatAgentOutboundText(text) { const normalized = String(text ?? '').trim(); if (!normalized) return normalized; const stripped = normalized .replace(/Ran into this error:[\s\S]*?(?=\n\n|$)/gi, '') .replace(/Request failed:\s*Bad request \(400\):[\s\S]*?(?=\n\n|$)/gi, '') .replace(/\n{3,}/g, '\n\n') .trim(); if (!stripped || isWechatAgentApiErrorText(stripped)) { return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。'; } return stripped; } function formatWechatAgentFailureMessage(err) { const message = err instanceof Error ? err.message : String(err); if (isHtmlPublishFailureMessage(message)) return buildHtmlPublishFailureText(); if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) { return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。'; } return `这次转发到专属 Agent 失败了:${message.slice(0, 200)}`; } function markWechatUserNotified(err) { if (err && typeof err === 'object') err.wechatUserNotified = true; return err; } function wasWechatUserNotified(err) { return Boolean(err && typeof err === 'object' && err.wechatUserNotified); } 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 successResponse(task = null) { return { ok: true, status: 200, contentType: 'text/plain; charset=utf-8', body: 'success', ...(task ? { task } : {}), }; } 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, sessionAccess = null, apiFetch, startAgentSession = null, sessionApiFetch = null, scheduleService = null, wechatScheduleLlmConfigService = null, llmProviderService = null, applySessionLlmProvider = null, refreshSessionSnapshot = null, wechatFetch = undiciFetch, linkExists = defaultPublicHtmlLinkExists, logger = console, }) { const sessionStore = resolveSessionAccess({ userAuth, sessionAccess }); if (!config?.enabled) { return { enabled: false, }; } config = { ...loadWechatMpConfig({}), ...config, tokenUrl: config.tokenUrl || DEFAULT_WECHAT_TOKEN_URL, customerServiceUrl: config.customerServiceUrl || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL, jsapiTicketUrl: config.jsapiTicketUrl || DEFAULT_WECHAT_JSAPI_TICKET_URL, 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, }; const rememberedWechatContexts = new Map(); let jsapiTicketCache = { ticket: null, expiresAt: 0, }; const fetchForSession = (sessionId, pathname, init) => sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch(pathname, init); const buildBindUrl = () => { if (/^https?:\/\//i.test(config.bindPath)) return config.bindPath; const path = config.bindPath.startsWith('/') ? config.bindPath : `/${config.bindPath}`; return `${config.publicBaseUrl}${path}`; }; const verifyRequest = (query = {}) => verifyWechatMpSignature({ token: config.token, timestamp: String(query.timestamp ?? ''), nonce: String(query.nonce ?? ''), signature: String(query.signature ?? ''), }); const verifyUrlChallenge = (query = {}) => verifyWechatMpUrlChallenge(query, config); const getStableAccessToken = async () => { if (accessTokenCache.token && accessTokenCache.expiresAt > Date.now() + 60_000) { return accessTokenCache.token; } const payload = await readJsonResponse( await wechatFetch(config.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'client_credential', appid: config.appId, secret: config.appSecret, force_refresh: false, }), }), ); if (!payload?.access_token) { throw new Error(payload?.errmsg || '获取微信 access_token 失败'); } const expiresIn = Number(payload.expires_in ?? 7200); accessTokenCache = { token: payload.access_token, expiresAt: Date.now() + expiresIn * 1000, }; return accessTokenCache.token; }; const getJsapiTicket = async () => { if (jsapiTicketCache.ticket && jsapiTicketCache.expiresAt > Date.now() + 60_000) { return jsapiTicketCache.ticket; } const accessToken = await getStableAccessToken(); const url = new URL(config.jsapiTicketUrl); url.searchParams.set('access_token', accessToken); url.searchParams.set('type', 'jsapi'); const payload = await readJsonResponse( await wechatFetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json' }, }), ); if (Number(payload?.errcode ?? 0) !== 0 || !payload?.ticket) { throw new Error(payload?.errmsg || `获取微信 jsapi_ticket 失败 (${payload?.errcode})`); } const expiresIn = Number(payload.expires_in ?? 7200); jsapiTicketCache = { ticket: payload.ticket, expiresAt: Date.now() + expiresIn * 1000, }; return jsapiTicketCache.ticket; }; const createJsSdkSignature = async (pageUrl) => { const normalizedUrl = String(pageUrl ?? '').split('#')[0]; if (!/^https?:\/\//i.test(normalizedUrl)) { throw new Error('签名 URL 必须是完整 http(s) 地址'); } const ticket = await getJsapiTicket(); const nonceStr = crypto.randomBytes(12).toString('hex'); const timestamp = Math.floor(Date.now() / 1000); const signature = crypto .createHash('sha1') .update( [ `jsapi_ticket=${ticket}`, `noncestr=${nonceStr}`, `timestamp=${timestamp}`, `url=${normalizedUrl}`, ].join('&'), ) .digest('hex'); return { appId: config.appId, timestamp, nonceStr, signature, url: normalizedUrl, jsApiList: [ 'startRecord', 'stopRecord', 'onVoiceRecordEnd', 'translateVoice', 'updateAppMessageShareData', 'updateTimelineShareData', 'onMenuShareAppMessage', 'onMenuShareTimeline', ], }; }; const sendCustomerServiceText = async ( openid, content, user = null, { verifiedHtmlUrls = [], linkExistsForRequest = linkExists } = {}, ) => { const formatted = formatWechatOutboundText(sanitizeWechatAgentOutboundText(content), user); const verifiedUrlSet = new Set( verifiedHtmlUrls .map((url) => String(url ?? '').trim()) .filter(Boolean), ); const guarded = downgradePrematurePublishClaims( await guardMissingPublicHtmlLinks(formatted, { linkExists: async (url) => { const normalized = String(url ?? '').trim(); if (verifiedUrlSet.has(normalized) && !linkExistsForRequest(normalized)) { verifiedUrlSet.delete(normalized); } return linkExistsForRequest(url); }, }), ); const chunks = splitWechatText(guarded); const accessToken = await getStableAccessToken(); for (const chunk of chunks) { const payload = await readJsonResponse( await wechatFetch( `${config.customerServiceUrl}?access_token=${encodeURIComponent(accessToken)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ touser: openid, msgtype: 'text', text: { content: chunk, }, }), }, ), ); if (Number(payload?.errcode ?? 0) !== 0) { const errcode = Number(payload?.errcode ?? 0); const errmsg = String(payload?.errmsg ?? '').trim() || 'unknown_error'; throw new Error(`微信客服消息发送失败 errcode=${errcode} errmsg=${errmsg}`); } } }; const sendTextToUser = async (userId, content) => { const openid = await userAuth.getWechatOpenidForUser(userId, config.appId); if (!openid) { throw new Error('用户尚未绑定服务号,无法推送提醒'); } await sendCustomerServiceText(openid, content); }; const ensureSessionProvider = async (sessionId) => { if (!applySessionLlmProvider || !sessionId) return; const applied = await applySessionLlmProvider(sessionId); if (applied && applied.ok === false) { throw new Error(applied.message || '专属 Agent Provider 未配置'); } }; const transcribeWechatVoiceMedia = async (mediaId, format) => { if (!mediaId) return ''; const accessToken = await getStableAccessToken(); const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch }); if (!downloaded.buffer?.length) return ''; const extension = String(format ?? '').trim().toLowerCase() || 'amr'; const form = new FormData(); form.append( 'file', new Blob([downloaded.buffer], { type: guessVoiceMimeType(format, downloaded.contentType), }), `wechat-voice.${extension}`, ); const response = await wechatFetch(`${config.asrTarget.replace(/\/$/, '')}/asr/oneshot`, { method: 'POST', body: form, }); const { payload, text } = await readJsonBody(response); if (!response.ok) { throw new Error(sanitizeAsrMessage(payload?.message ?? text ?? `ASR HTTP ${response.status}`)); } if (Number(payload?.code ?? 200) !== 200) { throw new Error(sanitizeAsrMessage(payload?.message ?? text ?? '识别失败,请重试')); } return String(payload?.data?.text ?? '').trim(); }; const ensureWechatAgentSession = async ({ userId, openid, forceNew = false, userContext = null, }) => { if (forceNew) { await userAuth.clearWechatAgentRoute(config.appId, openid); } const workingDir = await userAuth.resolveWorkingDir(userId); const sessionPolicy = await userAuth.getAgentSessionPolicy(userId); const publishLayout = await userAuth.getUserPublishLayout(userId); const addressName = resolveWechatAddressName(userContext); const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid); if (existingRoute?.agentSessionId) { const now = Date.now(); const routeUpdatedAt = Number(existingRoute.updatedAt ?? 0); const routeIsIdle = config.sessionIdleRotateMs > 0 && routeUpdatedAt > 0 && now - routeUpdatedAt > config.sessionIdleRotateMs; let routeMessageCount = 0; if (config.sessionMessageRotateCount > 0) { const counts = []; if (typeof userAuth.countWechatAgentSessionMessages === 'function') { counts.push( userAuth .countWechatAgentSessionMessages({ appId: config.appId, openid, agentSessionId: existingRoute.agentSessionId, }) .catch((err) => { logger.warn?.('WeChat MP route message count lookup failed:', err); return 0; }), ); } if (typeof userAuth.getWechatAgentSessionSnapshotMessageCount === 'function') { counts.push( userAuth .getWechatAgentSessionSnapshotMessageCount(existingRoute.agentSessionId) .catch((err) => { logger.warn?.('WeChat MP snapshot message count lookup failed:', err); return 0; }), ); } if (counts.length > 0) { const resolved = await Promise.all(counts); routeMessageCount = Math.max(0, ...resolved.map((value) => Number(value) || 0)); } } const routeIsTooLong = config.sessionMessageRotateCount > 0 && Number(routeMessageCount ?? 0) >= config.sessionMessageRotateCount; if (routeIsIdle || routeIsTooLong) { await userAuth.clearWechatAgentRoute(config.appId, openid); rememberedWechatContexts.delete(existingRoute.agentSessionId); } else { 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) { if (typeof userAuth.touchWechatAgentRoute === 'function') { await userAuth.touchWechatAgentRoute(config.appId, openid, now).catch((err) => { logger.warn?.('WeChat MP route touch failed:', err); }); } return { sessionId: existingRoute.agentSessionId, isNewSession: false }; } await userAuth.clearWechatAgentRoute(config.appId, openid); rememberedWechatContexts.delete(existingRoute.agentSessionId); } } else if (existingRoute?.status === 'disabled') { await userAuth.clearWechatAgentRoute(config.appId, openid); } const gate = await userAuth.canUseChat(userId); if (!gate.ok) { throw new Error(gate.message || '当前用户无法使用聊天能力'); } const started = startAgentSession ? await startAgentSession({ userId, workingDir, sessionPolicy }) : await readJsonResponse( await apiFetch('/agent/start', { method: 'POST', body: JSON.stringify({ working_dir: workingDir, enable_context_memory: sessionPolicy.enableContextMemory, ...(sessionPolicy.extensionOverrides ? { extension_overrides: sessionPolicy.extensionOverrides } : {}), }), }), ); const sessionId = started?.id; if (!sessionId) { throw new Error('公众号专属 Agent 会话创建失败'); } // `/agent/start` already persists the owning user and goosed node via the // portal proxy. Re-registering here without the node can overwrite the // correct mapping back to node 0 in multi-goosed production. if (!startAgentSession) { await sessionStore.registerSession({ userId, sessionId, target: 0, origin: 'wechat', }); } 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, isNewSession: true }; }; 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 scheduleWechatSessionSnapshotRefresh = (sessionId, userId) => { void refreshWechatSessionSnapshot(sessionId, userId); }; const rememberWechatUserContext = async (sessionId, user, { forceBootstrap = false } = {}) => { const addressName = resolveWechatAddressName(user); if (!addressName) return; const cacheKey = `${sessionId}:${addressName}`; const alreadyRemembered = rememberedWechatContexts.get(sessionId) === cacheKey; if (alreadyRemembered && !forceBootstrap) 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: Boolean(forceBootstrap) }), }), ); rememberedWechatContexts.set(sessionId, cacheKey); } 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 resolveGrantedSkills = async (userId) => { try { const row = await userAuth.getUserById(userId); if (!row) return []; const caps = await userAuth.resolveUserCapabilities(row); return Array.isArray(caps?.grantedSkills) ? caps.grantedSkills : []; } catch (err) { logger.warn?.('WeChat MP granted skills lookup failed:', err); return []; } }; const runIntentMessage = async ({ inbound, intent, user }) => { const wechatIntent = classifyWechatIntent(intent); const resetCandidate = intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : ''; const forceNew = wechatIntent.kind === 'session.reset' || isTopicResetIntent(resetCandidate); let route = await ensureWechatAgentSession({ userId: user.userId, openid: inbound.fromUserName, userContext: user, forceNew, }); let sessionId = route.sessionId; const publishLayout = await userAuth.getUserPublishLayout(user.userId); await ensureSessionProvider(sessionId); await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession }); 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 = (replyText) => guardScheduleConfirmationReply({ replyText, scheduleService, userId: user.userId, sourceMessageId: intent.msgId, logger, }); try { const requestStartedAt = Date.now(); const agentPrompt = wechatIntent.kind === 'page.generate' ? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx }) : buildWechatAgentPrompt(intent); const reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, requestId, agentPrompt, buildIntentMetadata(intent), ); const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); const { publishedArtifacts, verifiedArtifacts, expectedArtifacts, recentArtifacts, confirmedArtifacts, } = resolveHtmlPublishArtifacts({ reply, intent, workingDir, publicBaseUrl: config.publicBaseUrl, requestStartedAt, }); const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { confirmedArtifacts, }); const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); const suspiciousPublishClaim = expectedArtifacts.length === 0 && recentArtifacts.length === 0 && (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })); const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({ reply, intent, confirmedArtifacts, hasValidLinkInReply, }); const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }); if (wechatIntent.kind === 'page.generate') { const pageOutcome = resolvePageGenerateOutcome({ reply, confirmedArtifacts, verifiedArtifacts, suspiciousPublishClaim, bareCompletionReply, htmlGenerationNeedsRetry, replyHasPublicLinks, }); if (pageOutcome.action === 'session_retry') { throw new Error('stale_session_poisoned_completion'); } if (pageOutcome.action === 'fail') { const text = pageOutcome.failureText ?? buildPagePublishFailureText(); try { await sendCustomerServiceText(inbound.fromUserName, text, user); } catch (sendErr) { logger.error?.('WeChat MP page generate failure notice failed:', sendErr); } throw markWechatUserNotified(new Error(text)); } publishArtifacts = pageOutcome.artifacts ?? publishArtifacts; } else if (looksLikeHtmlGenerationIntent(intent?.agentText)) { if ( suspiciousPublishClaim || bareCompletionReply || (htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0) ) { throw new Error('stale_session_poisoned_completion'); } if (htmlGenerationNeedsRetry) { const text = buildHtmlPublishFailureText(); try { await sendCustomerServiceText(inbound.fromUserName, text, user); } catch (sendErr) { logger.error?.('WeChat MP html publish failure notice failed:', sendErr); } throw markWechatUserNotified(new Error(text)); } } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { throw new Error('stale_session_poisoned_completion'); } if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId); } const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl: config.publicBaseUrl, artifacts: publishArtifacts, }); scheduleWechatSessionSnapshotRefresh(sessionId, user.userId); await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url), linkExistsForRequest, }); return { sessionId }; } catch (err) { const message = err instanceof Error ? err.message : String(err); const mayBeStaleSession = sessionId && isRecoverableWechatAgentSessionError(message); if (mayBeStaleSession) { route = await ensureWechatAgentSession({ userId: user.userId, openid: inbound.fromUserName, forceNew: true, userContext: user, }); sessionId = route.sessionId; await ensureSessionProvider(sessionId); await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession }); const retryId = crypto.randomUUID(); const retryStartedAt = Date.now(); const retryPrompt = wechatIntent.kind === 'page.generate' ? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx }) : buildWechatAgentPrompt(intent); const reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, retryId, retryPrompt, buildIntentMetadata(intent), ); const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); const { publishedArtifacts, verifiedArtifacts, expectedArtifacts, recentArtifacts, confirmedArtifacts, } = resolveHtmlPublishArtifacts({ reply, intent, workingDir, publicBaseUrl: config.publicBaseUrl, requestStartedAt: retryStartedAt, }); const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { confirmedArtifacts, }); const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); const suspiciousPublishClaim = expectedArtifacts.length === 0 && recentArtifacts.length === 0 && (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })); const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({ reply, intent, confirmedArtifacts, hasValidLinkInReply, }); const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }); if (wechatIntent.kind === 'page.generate') { const pageOutcome = resolvePageGenerateOutcome({ reply, confirmedArtifacts, verifiedArtifacts, suspiciousPublishClaim, bareCompletionReply, htmlGenerationNeedsRetry, replyHasPublicLinks, }); if (pageOutcome.action === 'session_retry' || pageOutcome.action === 'fail') { const text = pageOutcome.failureText ?? buildPagePublishFailureText(); try { await sendCustomerServiceText(inbound.fromUserName, text, user); } catch (sendErr) { logger.error?.('WeChat MP page generate retry failure notice failed:', sendErr); } throw markWechatUserNotified(new Error(text)); } publishArtifacts = pageOutcome.artifacts ?? publishArtifacts; } else if (looksLikeHtmlGenerationIntent(intent?.agentText)) { if ( suspiciousPublishClaim || bareCompletionReply || (htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0) ) { throw new Error(buildHtmlPublishFailureText()); } if (htmlGenerationNeedsRetry) { const text = buildHtmlPublishFailureText(); try { await sendCustomerServiceText(inbound.fromUserName, text, user); } catch (sendErr) { logger.error?.('WeChat MP html publish failure notice failed:', sendErr); } throw markWechatUserNotified(new Error(text)); } } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { throw new Error(buildHtmlPublishFailureText()); } if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId); } const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl: config.publicBaseUrl, artifacts: publishArtifacts, }); scheduleWechatSessionSnapshotRefresh(sessionId, user.userId); await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url), linkExistsForRequest, }); return { sessionId }; } throw err; } finally { finished = true; if (progressTimer) clearTimeout(progressTimer); } }; 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' || intent.msgType === 'voice') { const wechatIntent = classifyWechatIntent(intent); if (shouldHandleSyncReply(intent, wechatIntent)) { const syncReply = resolveSyncReply(wechatIntent, boundUser, { statusFallbackText: config.statusText, }); if (syncReply) { 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: syncReply, }), }; } } } 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 handleWechatScheduleIntent({ intent, user: boundUser, scheduleService, wechatScheduleLlmConfigService, llmProviderService, logger, }) : 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); if (wasWechatUserNotified(err)) return; try { await sendCustomerServiceText( inbound.fromUserName, formatWechatAgentFailureMessage(err), boundUser, ); } catch (sendErr) { logger.error?.('WeChat MP failure notice failed:', sendErr); } }); 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, }; }