2026-06-19 23:06:43 +08:00
|
|
|
|
import crypto from 'node:crypto';
|
2026-06-26 15:19:03 +08:00
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
|
import path from 'node:path';
|
2026-06-19 23:06:43 +08:00
|
|
|
|
import { fetch as undiciFetch } from 'undici';
|
2026-06-27 22:28:41 +08:00
|
|
|
|
import { developerToolsFromPolicy } from './capabilities.mjs';
|
2026-06-19 23:06:43 +08:00
|
|
|
|
import { mergeMessageContent } from './message-stream.mjs';
|
|
|
|
|
|
import { reconcileAgentSession } from './session-reconcile.mjs';
|
2026-07-06 14:19:48 +08:00
|
|
|
|
import { resolveSessionAccess } from './session-broker.mjs';
|
2026-07-04 12:52:59 +08:00
|
|
|
|
import { isStubPublicHtmlContent, materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs';
|
|
|
|
|
|
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
|
2026-07-02 21:11:36 +08:00
|
|
|
|
import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
2026-06-26 15:19:03 +08:00
|
|
|
|
import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs';
|
2026-07-04 13:48:47 +08:00
|
|
|
|
import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs';
|
2026-06-26 15:19:03 +08:00
|
|
|
|
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
2026-07-04 13:48:47 +08:00
|
|
|
|
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';
|
2026-07-04 05:21:13 +00:00
|
|
|
|
import { classifyWechatIntent } from './wechat/intent/classifier.mjs';
|
|
|
|
|
|
import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs';
|
2026-07-12 22:44:18 +08:00
|
|
|
|
import { isPageDataIntent } from './chat-skills.mjs';
|
2026-07-04 05:21:13 +00:00
|
|
|
|
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
|
2026-07-04 13:48:47 +08:00
|
|
|
|
import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
|
2026-07-04 05:21:13 +00:00
|
|
|
|
import {
|
|
|
|
|
|
buildPageGenerateAgentPrompt,
|
|
|
|
|
|
buildPagePublishFailureText,
|
|
|
|
|
|
} from './wechat/prompts/page-generate.mjs';
|
|
|
|
|
|
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs';
|
2026-07-11 00:23:01 +08:00
|
|
|
|
import { resolveBillingTokenState } from './billing-token-state.mjs';
|
2026-07-12 13:54:24 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildPageDataCollectFailureText,
|
|
|
|
|
|
buildPageDataDeliveryArtifactsFromBindResult,
|
2026-07-12 15:30:52 +08:00
|
|
|
|
ensurePageDataDeliveryReady,
|
2026-07-12 13:54:24 +08:00
|
|
|
|
maybeAutoBindPageDataHtmlPages,
|
2026-07-12 15:30:52 +08:00
|
|
|
|
resolvePageDataCollectOutcomeAsync,
|
2026-07-12 13:54:24 +08:00
|
|
|
|
rewritePageDataDeliveryLinks,
|
|
|
|
|
|
} from './mindspace-page-data-finish-guard.mjs';
|
2026-06-19 23:06:43 +08:00
|
|
|
|
|
2026-07-04 13:48:47 +08:00
|
|
|
|
export { buildWechatAgentPrompt };
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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';
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const DEFAULT_ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn';
|
2026-07-04 12:52:59 +08:00
|
|
|
|
export { loadWechatMpConfig };
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const PUBLIC_HTML_LINK_PATTERN =
|
|
|
|
|
|
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
|
2026-06-27 22:28:41 +08:00
|
|
|
|
const SESSION_WORKSPACE_TOOL_NAMES = new Set([
|
|
|
|
|
|
'write',
|
|
|
|
|
|
'edit',
|
|
|
|
|
|
'tree',
|
|
|
|
|
|
'read_file',
|
|
|
|
|
|
'write_file',
|
|
|
|
|
|
'edit_file',
|
|
|
|
|
|
'create_dir',
|
|
|
|
|
|
'list_dir',
|
|
|
|
|
|
]);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
|
2026-06-29 06:59:37 +08:00
|
|
|
|
function escapeRegExp(value) {
|
|
|
|
|
|
return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeHtml(value) {
|
|
|
|
|
|
return String(value ?? '')
|
|
|
|
|
|
.replaceAll('&', '&')
|
|
|
|
|
|
.replaceAll('<', '<')
|
|
|
|
|
|
.replaceAll('>', '>')
|
|
|
|
|
|
.replaceAll('"', '"')
|
|
|
|
|
|
.replaceAll("'", ''');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
function parseXmlField(xml, field) {
|
|
|
|
|
|
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${field}>`));
|
|
|
|
|
|
if (cdataMatch) return cdataMatch[1];
|
|
|
|
|
|
const plainMatch = xml.match(new RegExp(`<${field}>([\\s\\S]*?)<\\/${field}>`));
|
|
|
|
|
|
return plainMatch ? plainMatch[1].trim() : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseWechatMessage(xml) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
toUserName: parseXmlField(xml, 'ToUserName'),
|
|
|
|
|
|
fromUserName: parseXmlField(xml, 'FromUserName'),
|
|
|
|
|
|
createTime: Number(parseXmlField(xml, 'CreateTime') || 0),
|
|
|
|
|
|
msgType: parseXmlField(xml, 'MsgType').toLowerCase(),
|
|
|
|
|
|
content: parseXmlField(xml, 'Content'),
|
|
|
|
|
|
msgId: parseXmlField(xml, 'MsgId'),
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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'),
|
2026-06-19 23:06:43 +08:00
|
|
|
|
event: parseXmlField(xml, 'Event').toLowerCase(),
|
|
|
|
|
|
eventKey: parseXmlField(xml, 'EventKey'),
|
2026-06-26 15:19:03 +08:00
|
|
|
|
latitude: parseXmlField(xml, 'Latitude'),
|
|
|
|
|
|
longitude: parseXmlField(xml, 'Longitude'),
|
|
|
|
|
|
precision: parseXmlField(xml, 'Precision'),
|
2026-06-19 23:06:43 +08:00
|
|
|
|
encrypt: parseXmlField(xml, 'Encrypt'),
|
2026-06-26 15:19:03 +08:00
|
|
|
|
rawXml: xml,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function readJsonResponse(response) {
|
|
|
|
|
|
return response.text().then((text) => {
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(text || `upstream ${response.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return text ? JSON.parse(text) : null;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
function deriveWechatEndpointFromUrl(sourceUrl, pathname) {
|
|
|
|
|
|
if (!sourceUrl) return '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = new URL(sourceUrl);
|
|
|
|
|
|
url.pathname = pathname;
|
|
|
|
|
|
url.search = '';
|
|
|
|
|
|
return url.toString();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
function createUserMessage(text, metadata = {}) {
|
2026-06-19 23:06:43 +08:00
|
|
|
|
return {
|
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
created: Math.floor(Date.now() / 1000),
|
|
|
|
|
|
content: [{ type: 'text', text }],
|
|
|
|
|
|
metadata: {
|
|
|
|
|
|
userVisible: true,
|
|
|
|
|
|
agentVisible: true,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
...metadata,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metadata = {}) {
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
user_message: createUserMessage(prompt, metadata),
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
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 = [];
|
2026-06-27 22:28:41 +08:00
|
|
|
|
let hasScopedAssistantUpdate = false;
|
2026-06-19 23:06:43 +08:00
|
|
|
|
|
|
|
|
|
|
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('当前回复需要人工确认,公众号通道暂不支持');
|
|
|
|
|
|
}
|
2026-06-27 22:28:41 +08:00
|
|
|
|
if (event.message.role === 'assistant') hasScopedAssistantUpdate = true;
|
2026-06-19 23:06:43 +08:00
|
|
|
|
messages = pushMessage(messages, event.message);
|
|
|
|
|
|
} else if (event.type === 'UpdateConversation') {
|
2026-06-27 22:28:41 +08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
} else if (event.type === 'Error') {
|
|
|
|
|
|
throw new Error(event.error || '任务执行失败');
|
|
|
|
|
|
} else if (event.type === 'Finish') {
|
|
|
|
|
|
const assistant = [...messages].reverse().find((item) => item.role === 'assistant');
|
2026-06-27 22:28:41 +08:00
|
|
|
|
if (!hasScopedAssistantUpdate || !assistant) {
|
|
|
|
|
|
throw new Error('本轮未收到可发送的新回复,请稍后重试');
|
|
|
|
|
|
}
|
2026-07-04 12:38:00 +08:00
|
|
|
|
const reply = {
|
2026-06-19 23:06:43 +08:00
|
|
|
|
text: messageVisibleText(assistant),
|
|
|
|
|
|
tokenState: event.token_state ?? null,
|
2026-06-27 22:28:41 +08:00
|
|
|
|
messages,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
};
|
2026-07-04 12:38:00 +08:00
|
|
|
|
assertWechatAgentReplyIsSendable(reply);
|
|
|
|
|
|
return reply;
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
|
|
2026-06-27 22:28:41 +08:00
|
|
|
|
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';
|
2026-06-29 06:59:37 +08:00
|
|
|
|
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';
|
2026-06-27 22:28:41 +08:00
|
|
|
|
if (!writeLikeDeveloper && !writeLikeSandbox) continue;
|
|
|
|
|
|
const candidate = String(args.path ?? '').trim();
|
|
|
|
|
|
if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...targets];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function looksLikeHtmlGenerationIntent(text) {
|
2026-07-04 05:21:13 +00:00
|
|
|
|
return isPageGenerateText(text);
|
2026-06-27 22:28:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 12:07:46 +08:00
|
|
|
|
function looksLikeDocxDownloadIntent(text) {
|
|
|
|
|
|
const normalized = String(text ?? '').trim();
|
|
|
|
|
|
if (!normalized) return false;
|
|
|
|
|
|
return /(?:(?:word|docx|\.docx|\.doc|文档).*(?:下载|链接|导出|给我)|(?:下载|导出|提供|给我).*(?:word|docx|\.docx|\.doc|文档))/iu.test(normalized);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 06:59:37 +08:00
|
|
|
|
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';
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 22:28:41 +08:00
|
|
|
|
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 ?? []);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 06:59:37 +08:00
|
|
|
|
function looksLikePublishSuccessClaim(text) {
|
|
|
|
|
|
const normalized = String(text ?? '').trim();
|
|
|
|
|
|
if (!normalized) return false;
|
|
|
|
|
|
return /(?:页面|网页|html).*(?:已创建|已生成|已发布|创建完毕|生成完成|发布成功)|(?:成功发布|已经发布|已创建完毕).*(?:页面|网页|html)/iu.test(normalized);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
async function hasAnyValidPublishedHtmlLink(text, linkExists, { confirmedArtifacts = [] } = {}) {
|
2026-06-29 06:59:37 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-29 06:59:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
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 ?? []);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 22:28:41 +08:00
|
|
|
|
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
|
|
|
|
|
|
const owner = path.basename(path.resolve(workingDir));
|
|
|
|
|
|
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
|
2026-07-02 21:11:36 +08:00
|
|
|
|
return buildPublicUrl(publicBaseUrl, owner, normalized);
|
2026-06-27 22:28:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ensurePublicHtmlArtifact(htmlPath, workingDir) {
|
|
|
|
|
|
const workspaceRoot = path.resolve(workingDir);
|
2026-06-29 06:59:37 +08:00
|
|
|
|
const source = path.isAbsolute(String(htmlPath ?? ''))
|
|
|
|
|
|
? path.resolve(String(htmlPath))
|
|
|
|
|
|
: path.resolve(workspaceRoot, String(htmlPath ?? ''));
|
2026-06-27 22:28:41 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 06:59:37 +08:00
|
|
|
|
function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) {
|
|
|
|
|
|
const artifacts = [];
|
2026-06-27 22:28:41 +08:00
|
|
|
|
const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []);
|
|
|
|
|
|
for (const target of htmlTargets) {
|
|
|
|
|
|
const artifact = ensurePublicHtmlArtifact(target, workingDir);
|
|
|
|
|
|
if (!artifact) continue;
|
2026-06-29 06:59:37 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
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);
|
2026-06-29 06:59:37 +08:00
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
return filenames;
|
2026-06-29 06:59:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function collectRecentPublishedHtmlArtifacts(
|
|
|
|
|
|
intent,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
{ workingDir, publicBaseUrl, replyText = '', sinceMs = 0, limit = 20 } = {},
|
2026-06-29 06:59:37 +08:00
|
|
|
|
) {
|
|
|
|
|
|
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, '/') : '';
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const linkedFilenames = collectPublicHtmlLinkFilenames(replyText);
|
2026-06-29 06:59:37 +08:00
|
|
|
|
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;
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const normalizedRelativePath = relativePath.replace(/\\/g, '/');
|
|
|
|
|
|
const filename = path.posix.basename(normalizedRelativePath);
|
|
|
|
|
|
if (linkedFilenames.size > 0 && filename && !linkedFilenames.has(filename)) continue;
|
2026-06-29 06:59:37 +08:00
|
|
|
|
artifacts.push({
|
|
|
|
|
|
localPath: absolutePath,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
relativePath: normalizedRelativePath,
|
|
|
|
|
|
url: buildPublicHtmlUrl(workingDir, normalizedRelativePath, publicBaseUrl),
|
2026-06-29 06:59:37 +08:00
|
|
|
|
mtimeMs: Number(stat.mtimeMs ?? 0),
|
|
|
|
|
|
matchesExpected: expectedRelativePath
|
2026-06-29 22:20:04 +08:00
|
|
|
|
? normalizedRelativePath === expectedRelativePath
|
2026-06-29 06:59:37 +08:00
|
|
|
|
: 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;
|
2026-06-27 22:28:41 +08:00
|
|
|
|
if (baseText.includes(url)) return baseText;
|
|
|
|
|
|
return baseText
|
|
|
|
|
|
? `${baseText}\n\n查看页面:\n${url}`
|
|
|
|
|
|
: `查看页面:\n${url}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return baseText;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
function stripInternalWechatUsername(text) {
|
|
|
|
|
|
return String(text ?? '').replace(/^\s*wx_[a-z0-9_]{4,64}\s*[,,、::]\s*/i, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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)))
|
2026-06-19 23:06:43 +08:00
|
|
|
|
.replace(/^\s*#{1,6}\s+/gm, '')
|
|
|
|
|
|
.replace(/^\s*---+\s*$/gm, '')
|
|
|
|
|
|
.replace(/\n{3,}/g, '\n\n'),
|
2026-06-26 15:19:03 +08:00
|
|
|
|
user,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 06:59:37 +08:00
|
|
|
|
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, '生成未完成');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 12:52:59 +08:00
|
|
|
|
export function buildHtmlPublishFailureText() {
|
2026-07-04 05:21:13 +00:00
|
|
|
|
return buildPagePublishFailureText();
|
2026-06-29 06:59:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 12:52:59 +08:00
|
|
|
|
export function isHtmlPublishFailureMessage(message) {
|
|
|
|
|
|
const normalized = String(message ?? '').trim();
|
|
|
|
|
|
return (
|
|
|
|
|
|
normalized.includes('页面技能') ||
|
|
|
|
|
|
normalized.includes('static-page-publish') ||
|
|
|
|
|
|
normalized.includes('简版页')
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 22:28:41 +08:00
|
|
|
|
export { maybeAttachPublishedHtmlLink };
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
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,
|
2026-07-16 21:06:28 +08:00
|
|
|
|
userId = '',
|
|
|
|
|
|
sessionId = '',
|
|
|
|
|
|
onPageGenerated = null,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-07-16 21:06:28 +08:00
|
|
|
|
if (typeof onPageGenerated === 'function' && confirmedArtifacts.length > 0) {
|
|
|
|
|
|
void onPageGenerated({ userId, sessionId, artifacts: confirmedArtifacts });
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
return {
|
|
|
|
|
|
publishedArtifacts,
|
|
|
|
|
|
expectedArtifacts,
|
|
|
|
|
|
recentArtifacts,
|
|
|
|
|
|
confirmedArtifacts,
|
|
|
|
|
|
verifiedArtifacts,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 12:52:59 +08:00
|
|
|
|
function selectHtmlPublishArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) {
|
2026-07-04 05:21:13 +00:00
|
|
|
|
return selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
2026-07-04 12:52:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
export function shouldRetryHtmlGenerationReply({
|
|
|
|
|
|
reply,
|
|
|
|
|
|
intent,
|
|
|
|
|
|
confirmedArtifacts = [],
|
|
|
|
|
|
hasValidLinkInReply = false,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
|
|
|
|
|
|
2026-07-04 12:52:59 +08:00
|
|
|
|
if (nonStubHtmlArtifacts(confirmedArtifacts).length > 0) return false;
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-07-03 10:43:13 +08:00
|
|
|
|
if (!usedStaticPagePublishSkill(reply?.messages ?? [])) return false;
|
2026-06-29 22:20:04 +08:00
|
|
|
|
return !hasAnyUrl(reply?.text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
function isTopicResetIntent(text) {
|
2026-07-04 05:21:13 +00:00
|
|
|
|
return isTopicResetText(text);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 22:44:18 +08:00
|
|
|
|
export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
wechatIntent?.kind === 'session.reset' ||
|
|
|
|
|
|
isTopicResetIntent(resetCandidate) ||
|
|
|
|
|
|
isPageDataIntent(resetCandidate)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 09:53:59 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 12:38:00 +08:00
|
|
|
|
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);
|
2026-07-04 12:52:59 +08:00
|
|
|
|
if (isHtmlPublishFailureMessage(message)) return buildHtmlPublishFailureText();
|
2026-07-04 12:38:00 +08:00
|
|
|
|
if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) {
|
|
|
|
|
|
return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。';
|
|
|
|
|
|
}
|
|
|
|
|
|
return `这次转发到专属 Agent 失败了:${message.slice(0, 200)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 12:52:59 +08:00
|
|
|
|
function markWechatUserNotified(err) {
|
|
|
|
|
|
if (err && typeof err === 'object') err.wechatUserNotified = true;
|
|
|
|
|
|
return err;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 13:54:24 +08:00
|
|
|
|
async function enforcePageDataCollectDelivery({
|
|
|
|
|
|
reply,
|
|
|
|
|
|
intent,
|
|
|
|
|
|
workingDir,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
pageDataFinishGuard,
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
requestStartedAt = 0,
|
|
|
|
|
|
notifyFailure,
|
|
|
|
|
|
}) {
|
2026-07-12 15:30:52 +08:00
|
|
|
|
let outcome = await resolvePageDataCollectOutcomeAsync({
|
2026-07-12 13:54:24 +08:00
|
|
|
|
reply,
|
|
|
|
|
|
intent,
|
|
|
|
|
|
publishDir: workingDir,
|
|
|
|
|
|
requestStartedAt,
|
2026-07-12 15:30:52 +08:00
|
|
|
|
pool: pageDataFinishGuard?.pool ?? null,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
findPageByRelativePath: null,
|
2026-07-12 22:42:47 +08:00
|
|
|
|
apiBase: publicBaseUrl,
|
2026-07-12 13:54:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
let autoBind = null;
|
|
|
|
|
|
if (outcome.action === 'skip') return outcome;
|
|
|
|
|
|
|
|
|
|
|
|
if (pageDataFinishGuard?.pool) {
|
2026-07-12 15:30:52 +08:00
|
|
|
|
const { createPageService } = await import('./mindspace-pages.mjs');
|
|
|
|
|
|
const pageService = createPageService(pageDataFinishGuard.pool, {
|
|
|
|
|
|
h5Root: pageDataFinishGuard.h5Root,
|
|
|
|
|
|
storageRoot: pageDataFinishGuard.storageRoot,
|
|
|
|
|
|
});
|
2026-07-12 13:54:24 +08:00
|
|
|
|
autoBind = await maybeAutoBindPageDataHtmlPages({
|
|
|
|
|
|
pool: pageDataFinishGuard.pool,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
publishDir: workingDir,
|
|
|
|
|
|
h5Root: pageDataFinishGuard.h5Root,
|
|
|
|
|
|
storageRoot: pageDataFinishGuard.storageRoot,
|
2026-07-12 15:30:52 +08:00
|
|
|
|
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
2026-07-12 13:54:24 +08:00
|
|
|
|
});
|
2026-07-12 15:30:52 +08:00
|
|
|
|
outcome = await resolvePageDataCollectOutcomeAsync({
|
2026-07-12 13:54:24 +08:00
|
|
|
|
reply,
|
|
|
|
|
|
intent,
|
|
|
|
|
|
publishDir: workingDir,
|
|
|
|
|
|
requestStartedAt,
|
2026-07-12 15:30:52 +08:00
|
|
|
|
pool: pageDataFinishGuard.pool,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
2026-07-12 22:42:47 +08:00
|
|
|
|
apiBase: publicBaseUrl,
|
2026-07-12 13:54:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (outcome.action === 'retry') {
|
|
|
|
|
|
throw new Error('stale_session_poisoned_completion');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (outcome.action === 'fail') {
|
|
|
|
|
|
const text = outcome.failureText ?? buildPageDataCollectFailureText();
|
|
|
|
|
|
if (typeof notifyFailure === 'function') {
|
|
|
|
|
|
await notifyFailure(text);
|
|
|
|
|
|
}
|
|
|
|
|
|
throw markWechatUserNotified(new Error(text));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const deliveryArtifacts = buildPageDataDeliveryArtifactsFromBindResult(autoBind, workingDir, {
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
});
|
2026-07-12 15:30:52 +08:00
|
|
|
|
if (deliveryArtifacts.length > 0 && pageDataFinishGuard?.pool) {
|
|
|
|
|
|
const { createPageService } = await import('./mindspace-pages.mjs');
|
|
|
|
|
|
const pageService = createPageService(pageDataFinishGuard.pool, {
|
|
|
|
|
|
h5Root: pageDataFinishGuard.h5Root,
|
|
|
|
|
|
storageRoot: pageDataFinishGuard.storageRoot,
|
|
|
|
|
|
});
|
|
|
|
|
|
const deliveryCheck = await ensurePageDataDeliveryReady({
|
|
|
|
|
|
publishDir: workingDir,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
pool: pageDataFinishGuard.pool,
|
|
|
|
|
|
h5Root: pageDataFinishGuard.h5Root,
|
|
|
|
|
|
storageRoot: pageDataFinishGuard.storageRoot,
|
|
|
|
|
|
apiBase: publicBaseUrl,
|
|
|
|
|
|
artifacts: deliveryArtifacts,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!deliveryCheck.ok) {
|
|
|
|
|
|
const text = buildPageDataCollectFailureText();
|
|
|
|
|
|
if (typeof notifyFailure === 'function') {
|
|
|
|
|
|
await notifyFailure(text);
|
|
|
|
|
|
}
|
|
|
|
|
|
throw markWechatUserNotified(new Error(text));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-12 13:54:24 +08:00
|
|
|
|
if (deliveryArtifacts.length > 0 && reply && typeof reply.text === 'string') {
|
|
|
|
|
|
reply.text = rewritePageDataDeliveryLinks(reply.text, deliveryArtifacts);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ...outcome, deliveryArtifacts, autoBind };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 12:52:59 +08:00
|
|
|
|
function wasWechatUserNotified(err) {
|
|
|
|
|
|
return Boolean(err && typeof err === 'object' && err.wechatUserNotified);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
function successResponse(task = null) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
status: 200,
|
|
|
|
|
|
contentType: 'text/plain; charset=utf-8',
|
|
|
|
|
|
body: 'success',
|
|
|
|
|
|
...(task ? { task } : {}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
function sha1Hex(parts) {
|
|
|
|
|
|
return crypto
|
2026-06-19 23:06:43 +08:00
|
|
|
|
.createHash('sha1')
|
2026-06-26 15:19:03 +08:00
|
|
|
|
.update([...parts].sort().join(''))
|
2026-06-19 23:06:43 +08:00
|
|
|
|
.digest('hex');
|
2026-06-26 15:19:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 };
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildWechatTextReply({
|
|
|
|
|
|
toUserName,
|
|
|
|
|
|
fromUserName,
|
|
|
|
|
|
content,
|
|
|
|
|
|
createTime = Math.floor(Date.now() / 1000),
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
'<xml>',
|
|
|
|
|
|
`<ToUserName><![CDATA[${toUserName ?? ''}]]></ToUserName>`,
|
|
|
|
|
|
`<FromUserName><![CDATA[${fromUserName ?? ''}]]></FromUserName>`,
|
|
|
|
|
|
`<CreateTime>${Number(createTime) || Math.floor(Date.now() / 1000)}</CreateTime>`,
|
|
|
|
|
|
'<MsgType><![CDATA[text]]></MsgType>',
|
|
|
|
|
|
`<Content><![CDATA[${String(content ?? '')}]]></Content>`,
|
|
|
|
|
|
'</xml>',
|
|
|
|
|
|
].join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function createWechatMpService({
|
|
|
|
|
|
config,
|
|
|
|
|
|
userAuth,
|
2026-07-06 14:19:48 +08:00
|
|
|
|
sessionAccess = null,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
apiFetch,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
startAgentSession = null,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
sessionApiFetch = null,
|
|
|
|
|
|
scheduleService = null,
|
2026-07-07 12:10:21 +08:00
|
|
|
|
wechatScheduleLlmConfigService = null,
|
|
|
|
|
|
llmProviderService = null,
|
2026-07-16 21:06:28 +08:00
|
|
|
|
onPageGenerated = null,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
applySessionLlmProvider = null,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
refreshSessionSnapshot = null,
|
2026-07-12 13:54:24 +08:00
|
|
|
|
pageDataFinishGuard = null,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
wechatFetch = undiciFetch,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
linkExists = defaultPublicHtmlLinkExists,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
logger = console,
|
|
|
|
|
|
}) {
|
2026-07-06 14:19:48 +08:00
|
|
|
|
const sessionStore = resolveSessionAccess({ userAuth, sessionAccess });
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (!config?.enabled) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
enabled: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
config = {
|
2026-07-04 12:52:59 +08:00
|
|
|
|
...loadWechatMpConfig({}),
|
2026-06-19 23:06:43 +08:00
|
|
|
|
...config,
|
|
|
|
|
|
tokenUrl: config.tokenUrl || DEFAULT_WECHAT_TOKEN_URL,
|
|
|
|
|
|
customerServiceUrl: config.customerServiceUrl || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
|
|
|
|
|
jsapiTicketUrl: config.jsapiTicketUrl || DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let accessTokenCache = {
|
|
|
|
|
|
token: null,
|
|
|
|
|
|
expiresAt: 0,
|
|
|
|
|
|
};
|
2026-07-04 12:10:22 +08:00
|
|
|
|
const rememberedWechatContexts = new Map();
|
2026-06-19 23:06:43 +08:00
|
|
|
|
let jsapiTicketCache = {
|
|
|
|
|
|
ticket: null,
|
|
|
|
|
|
expiresAt: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const fetchForSession = (sessionId, pathname, init) =>
|
|
|
|
|
|
sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch(pathname, init);
|
|
|
|
|
|
|
2026-07-11 00:23:01 +08:00
|
|
|
|
const resolveWechatBillingTokenState = (sessionId, tokenState) =>
|
|
|
|
|
|
resolveBillingTokenState(tokenState, {
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
fetchSession: async (sid) => {
|
|
|
|
|
|
const response = await fetchForSession(sid, `/sessions/${encodeURIComponent(sid)}`);
|
|
|
|
|
|
if (!response?.ok) return null;
|
|
|
|
|
|
return readJsonResponse(response);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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 ?? ''),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const verifyUrlChallenge = (query = {}) => verifyWechatMpUrlChallenge(query, config);
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
jsApiList: [
|
|
|
|
|
|
'startRecord',
|
|
|
|
|
|
'stopRecord',
|
|
|
|
|
|
'onVoiceRecordEnd',
|
|
|
|
|
|
'translateVoice',
|
|
|
|
|
|
'updateAppMessageShareData',
|
|
|
|
|
|
'updateTimelineShareData',
|
|
|
|
|
|
'onMenuShareAppMessage',
|
|
|
|
|
|
'onMenuShareTimeline',
|
|
|
|
|
|
],
|
2026-06-19 23:06:43 +08:00
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const sendCustomerServiceText = async (
|
|
|
|
|
|
openid,
|
|
|
|
|
|
content,
|
|
|
|
|
|
user = null,
|
|
|
|
|
|
{ verifiedHtmlUrls = [], linkExistsForRequest = linkExists } = {},
|
|
|
|
|
|
) => {
|
2026-07-04 12:38:00 +08:00
|
|
|
|
const formatted = formatWechatOutboundText(sanitizeWechatAgentOutboundText(content), user);
|
2026-06-29 06:59:37 +08:00
|
|
|
|
const verifiedUrlSet = new Set(
|
|
|
|
|
|
verifiedHtmlUrls
|
|
|
|
|
|
.map((url) => String(url ?? '').trim())
|
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
|
);
|
|
|
|
|
|
const guarded = downgradePrematurePublishClaims(
|
|
|
|
|
|
await guardMissingPublicHtmlLinks(formatted, {
|
|
|
|
|
|
linkExists: async (url) => {
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const normalized = String(url ?? '').trim();
|
|
|
|
|
|
if (verifiedUrlSet.has(normalized) && !linkExistsForRequest(normalized)) {
|
|
|
|
|
|
verifiedUrlSet.delete(normalized);
|
|
|
|
|
|
}
|
|
|
|
|
|
return linkExistsForRequest(url);
|
2026-06-29 06:59:37 +08:00
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const chunks = splitWechatText(guarded);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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) {
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const errcode = Number(payload?.errcode ?? 0);
|
|
|
|
|
|
const errmsg = String(payload?.errmsg ?? '').trim() || 'unknown_error';
|
|
|
|
|
|
throw new Error(`微信客服消息发送失败 errcode=${errcode} errmsg=${errmsg}`);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const sendTextToUser = async (userId, content) => {
|
|
|
|
|
|
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
|
|
|
|
|
|
if (!openid) {
|
|
|
|
|
|
throw new Error('用户尚未绑定服务号,无法推送提醒');
|
|
|
|
|
|
}
|
|
|
|
|
|
await sendCustomerServiceText(openid, content);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const ensureWechatAgentSession = async ({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
openid,
|
|
|
|
|
|
forceNew = false,
|
|
|
|
|
|
userContext = null,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
if (forceNew) {
|
|
|
|
|
|
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
|
|
|
|
|
}
|
2026-06-27 22:28:41 +08:00
|
|
|
|
const workingDir = await userAuth.resolveWorkingDir(userId);
|
|
|
|
|
|
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
|
|
|
|
|
|
const publishLayout = await userAuth.getUserPublishLayout(userId);
|
|
|
|
|
|
const addressName = resolveWechatAddressName(userContext);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
|
|
|
|
|
|
if (existingRoute?.agentSessionId) {
|
2026-07-04 12:10:22 +08:00
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const routeUpdatedAt = Number(existingRoute.updatedAt ?? 0);
|
|
|
|
|
|
const routeIsIdle =
|
|
|
|
|
|
config.sessionIdleRotateMs > 0 &&
|
|
|
|
|
|
routeUpdatedAt > 0 &&
|
|
|
|
|
|
now - routeUpdatedAt > config.sessionIdleRotateMs;
|
2026-07-04 12:21:14 +08:00
|
|
|
|
let routeMessageCount = 0;
|
|
|
|
|
|
if (config.sessionMessageRotateCount > 0) {
|
|
|
|
|
|
const counts = [];
|
|
|
|
|
|
if (typeof userAuth.countWechatAgentSessionMessages === 'function') {
|
|
|
|
|
|
counts.push(
|
|
|
|
|
|
userAuth
|
2026-07-04 12:10:22 +08:00
|
|
|
|
.countWechatAgentSessionMessages({
|
|
|
|
|
|
appId: config.appId,
|
|
|
|
|
|
openid,
|
|
|
|
|
|
agentSessionId: existingRoute.agentSessionId,
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
|
logger.warn?.('WeChat MP route message count lookup failed:', err);
|
|
|
|
|
|
return 0;
|
2026-07-04 12:21:14 +08:00
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-04 12:10:22 +08:00
|
|
|
|
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,
|
2026-06-27 22:28:41 +08:00
|
|
|
|
sessionPolicy,
|
2026-07-04 12:10:22 +08:00
|
|
|
|
).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);
|
2026-06-27 22:28:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else if (existingRoute?.status === 'disabled') {
|
|
|
|
|
|
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const gate = await userAuth.canUseChat(userId);
|
|
|
|
|
|
if (!gate.ok) {
|
|
|
|
|
|
throw new Error(gate.message || '当前用户无法使用聊天能力');
|
|
|
|
|
|
}
|
2026-06-29 06:59:37 +08:00
|
|
|
|
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 }
|
|
|
|
|
|
: {}),
|
|
|
|
|
|
}),
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const sessionId = started?.id;
|
|
|
|
|
|
if (!sessionId) {
|
|
|
|
|
|
throw new Error('公众号专属 Agent 会话创建失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 18:33:53 +08:00
|
|
|
|
// `/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.
|
2026-07-06 14:19:48 +08:00
|
|
|
|
if (!startAgentSession) {
|
|
|
|
|
|
await sessionStore.registerSession({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
target: 0,
|
|
|
|
|
|
origin: 'wechat',
|
|
|
|
|
|
});
|
2026-07-01 18:36:46 +08:00
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
await reconcileAgentSession(
|
|
|
|
|
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
{
|
2026-07-04 12:10:22 +08:00
|
|
|
|
workingDir,
|
|
|
|
|
|
sessionPolicy,
|
|
|
|
|
|
sandboxConstraints: publishLayout?.constraints ?? null,
|
|
|
|
|
|
userContext: publishLayout
|
|
|
|
|
|
? {
|
|
|
|
|
|
userId,
|
|
|
|
|
|
displayName: addressName || publishLayout.displayName,
|
|
|
|
|
|
username: addressName || null,
|
|
|
|
|
|
slug: null,
|
|
|
|
|
|
}
|
|
|
|
|
|
: null,
|
|
|
|
|
|
tolerateInvalidWorkingDir: true,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
await userAuth.upsertWechatAgentRoute({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
appId: config.appId,
|
|
|
|
|
|
openid,
|
|
|
|
|
|
agentSessionId: sessionId,
|
|
|
|
|
|
});
|
2026-07-04 12:10:22 +08:00
|
|
|
|
return { sessionId, isNewSession: true };
|
2026-06-19 23:06:43 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-29 06:59:37 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-04 12:21:14 +08:00
|
|
|
|
const scheduleWechatSessionSnapshotRefresh = (sessionId, userId) => {
|
|
|
|
|
|
void refreshWechatSessionSnapshot(sessionId, userId);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-04 12:10:22 +08:00
|
|
|
|
const rememberWechatUserContext = async (sessionId, user, { forceBootstrap = false } = {}) => {
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const addressName = resolveWechatAddressName(user);
|
|
|
|
|
|
if (!addressName) return;
|
2026-07-04 12:10:22 +08:00
|
|
|
|
const cacheKey = `${sessionId}:${addressName}`;
|
|
|
|
|
|
const alreadyRemembered = rememberedWechatContexts.get(sessionId) === cacheKey;
|
|
|
|
|
|
if (alreadyRemembered && !forceBootstrap) return;
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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',
|
2026-07-04 12:10:22 +08:00
|
|
|
|
body: JSON.stringify({ sessionId, force: Boolean(forceBootstrap) }),
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-07-04 12:10:22 +08:00
|
|
|
|
rememberedWechatContexts.set(sessionId, cacheKey);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
logger.warn?.('WeChat MP user context remember failed:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-01 21:07:25 +08:00
|
|
|
|
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 [];
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const runIntentMessage = async ({ inbound, intent, user }) => {
|
2026-07-04 05:21:13 +00:00
|
|
|
|
const wechatIntent = classifyWechatIntent(intent);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const resetCandidate =
|
|
|
|
|
|
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
2026-07-12 22:44:18 +08:00
|
|
|
|
// Page Data delivery owns persistent files, datasets and two publication
|
|
|
|
|
|
// policies. Reusing a conversational route here can make a new request
|
|
|
|
|
|
// inspect/retry unrelated historical pages from that session.
|
|
|
|
|
|
const isPageDataRequest = isPageDataIntent(resetCandidate);
|
|
|
|
|
|
const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate);
|
2026-07-04 12:10:22 +08:00
|
|
|
|
let route = await ensureWechatAgentSession({
|
2026-06-19 23:06:43 +08:00
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
openid: inbound.fromUserName,
|
|
|
|
|
|
userContext: user,
|
|
|
|
|
|
forceNew,
|
|
|
|
|
|
});
|
2026-07-04 12:10:22 +08:00
|
|
|
|
let sessionId = route.sessionId;
|
2026-06-27 22:28:41 +08:00
|
|
|
|
const publishLayout = await userAuth.getUserPublishLayout(user.userId);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
await ensureSessionProvider(sessionId);
|
2026-07-04 12:10:22 +08:00
|
|
|
|
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
2026-06-19 23:06:43 +08:00
|
|
|
|
let finished = false;
|
|
|
|
|
|
let progressTimer = null;
|
|
|
|
|
|
if (config.progressDelayMs > 0 && config.progressText) {
|
|
|
|
|
|
progressTimer = setTimeout(() => {
|
|
|
|
|
|
if (finished) return;
|
2026-06-26 15:19:03 +08:00
|
|
|
|
sendCustomerServiceText(inbound.fromUserName, config.progressText, user).catch((err) => {
|
2026-06-19 23:06:43 +08:00
|
|
|
|
logger.warn?.('WeChat MP progress reply failed:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, config.progressDelayMs);
|
|
|
|
|
|
progressTimer.unref?.();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const requestId = crypto.randomUUID();
|
2026-07-04 13:48:47 +08:00
|
|
|
|
const guardScheduleReply = (replyText) =>
|
|
|
|
|
|
guardScheduleConfirmationReply({
|
|
|
|
|
|
replyText,
|
|
|
|
|
|
scheduleService,
|
|
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
sourceMessageId: intent.msgId,
|
|
|
|
|
|
logger,
|
|
|
|
|
|
});
|
2026-06-19 23:06:43 +08:00
|
|
|
|
try {
|
2026-06-29 06:59:37 +08:00
|
|
|
|
const requestStartedAt = Date.now();
|
2026-07-04 05:21:13 +00:00
|
|
|
|
const agentPrompt =
|
|
|
|
|
|
wechatIntent.kind === 'page.generate'
|
|
|
|
|
|
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
|
|
|
|
|
|
: buildWechatAgentPrompt(intent);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const reply = await executeSessionReply(
|
|
|
|
|
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
requestId,
|
2026-07-04 05:21:13 +00:00
|
|
|
|
agentPrompt,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
buildIntentMetadata(intent),
|
2026-06-19 23:06:43 +08:00
|
|
|
|
);
|
2026-06-29 06:59:37 +08:00
|
|
|
|
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
|
|
|
|
|
|
const {
|
|
|
|
|
|
publishedArtifacts,
|
|
|
|
|
|
verifiedArtifacts,
|
|
|
|
|
|
expectedArtifacts,
|
|
|
|
|
|
recentArtifacts,
|
|
|
|
|
|
confirmedArtifacts,
|
|
|
|
|
|
} = resolveHtmlPublishArtifacts({
|
|
|
|
|
|
reply,
|
|
|
|
|
|
intent,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
workingDir,
|
|
|
|
|
|
publicBaseUrl: config.publicBaseUrl,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
requestStartedAt,
|
2026-07-16 21:06:28 +08:00
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
onPageGenerated,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
});
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
|
|
|
|
|
|
confirmedArtifacts,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
});
|
2026-07-04 12:52:59 +08:00
|
|
|
|
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);
|
2026-07-04 05:21:13 +00:00
|
|
|
|
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)) {
|
2026-07-04 12:52:59 +08:00
|
|
|
|
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) {
|
2026-06-27 22:28:41 +08:00
|
|
|
|
throw new Error('stale_session_poisoned_completion');
|
|
|
|
|
|
}
|
2026-07-12 13:54:24 +08:00
|
|
|
|
const pageDataOutcome = await enforcePageDataCollectDelivery({
|
|
|
|
|
|
reply,
|
|
|
|
|
|
intent,
|
|
|
|
|
|
workingDir,
|
|
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
pageDataFinishGuard,
|
|
|
|
|
|
publicBaseUrl: config.publicBaseUrl,
|
|
|
|
|
|
requestStartedAt,
|
|
|
|
|
|
notifyFailure: async (text) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sendCustomerServiceText(inbound.fromUserName, text, user);
|
|
|
|
|
|
} catch (sendErr) {
|
|
|
|
|
|
logger.error?.('WeChat MP page data failure notice failed:', sendErr);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
if (pageDataOutcome?.deliveryArtifacts?.length) {
|
|
|
|
|
|
publishArtifacts = uniqueArtifactsByUrl([
|
|
|
|
|
|
...pageDataOutcome.deliveryArtifacts,
|
|
|
|
|
|
...publishArtifacts,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (reply.tokenState) {
|
2026-07-11 00:23:01 +08:00
|
|
|
|
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
|
|
|
|
|
|
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, requestId);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}
|
2026-06-27 22:28:41 +08:00
|
|
|
|
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
2026-06-29 06:59:37 +08:00
|
|
|
|
workingDir,
|
2026-06-27 22:28:41 +08:00
|
|
|
|
publicBaseUrl: config.publicBaseUrl,
|
2026-07-04 12:52:59 +08:00
|
|
|
|
artifacts: publishArtifacts,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
});
|
2026-07-04 12:21:14 +08:00
|
|
|
|
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
|
2026-06-29 22:20:04 +08:00
|
|
|
|
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
2026-07-04 12:52:59 +08:00
|
|
|
|
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
|
2026-06-29 22:20:04 +08:00
|
|
|
|
linkExistsForRequest,
|
2026-06-27 22:28:41 +08:00
|
|
|
|
});
|
2026-06-19 23:06:43 +08:00
|
|
|
|
return { sessionId };
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
2026-07-12 22:44:18 +08:00
|
|
|
|
// A Page Data request must fail closed. Retrying a poisoned completion in
|
|
|
|
|
|
// another session while the finish guard is also active can turn one
|
|
|
|
|
|
// request into repairs against historical pages. Drop only this user's
|
|
|
|
|
|
// route so the next explicit request starts cleanly.
|
|
|
|
|
|
if (isPageDataRequest) {
|
|
|
|
|
|
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
|
|
|
|
|
logger.warn?.('WeChat MP page data route clear failed:', clearErr);
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!err?.wechatUserNotified) {
|
|
|
|
|
|
const text = buildPageDataCollectFailureText();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sendCustomerServiceText(inbound.fromUserName, text, user);
|
|
|
|
|
|
} catch (sendErr) {
|
|
|
|
|
|
logger.error?.('WeChat MP page data failure notice failed:', sendErr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
throw markWechatUserNotified(err instanceof Error ? err : new Error(message));
|
|
|
|
|
|
}
|
2026-07-04 09:53:59 +08:00
|
|
|
|
const mayBeStaleSession = sessionId && isRecoverableWechatAgentSessionError(message);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (mayBeStaleSession) {
|
2026-07-04 12:10:22 +08:00
|
|
|
|
route = await ensureWechatAgentSession({
|
2026-06-19 23:06:43 +08:00
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
openid: inbound.fromUserName,
|
|
|
|
|
|
forceNew: true,
|
|
|
|
|
|
userContext: user,
|
|
|
|
|
|
});
|
2026-07-04 12:10:22 +08:00
|
|
|
|
sessionId = route.sessionId;
|
2026-06-26 15:19:03 +08:00
|
|
|
|
await ensureSessionProvider(sessionId);
|
2026-07-04 12:10:22 +08:00
|
|
|
|
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const retryId = crypto.randomUUID();
|
2026-06-29 06:59:37 +08:00
|
|
|
|
const retryStartedAt = Date.now();
|
2026-07-04 05:21:13 +00:00
|
|
|
|
const retryPrompt =
|
|
|
|
|
|
wechatIntent.kind === 'page.generate'
|
|
|
|
|
|
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
|
|
|
|
|
|
: buildWechatAgentPrompt(intent);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const reply = await executeSessionReply(
|
|
|
|
|
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
retryId,
|
2026-07-04 05:21:13 +00:00
|
|
|
|
retryPrompt,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
buildIntentMetadata(intent),
|
2026-06-19 23:06:43 +08:00
|
|
|
|
);
|
2026-06-29 06:59:37 +08:00
|
|
|
|
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
|
|
|
|
|
|
const {
|
|
|
|
|
|
publishedArtifacts,
|
|
|
|
|
|
verifiedArtifacts,
|
|
|
|
|
|
expectedArtifacts,
|
|
|
|
|
|
recentArtifacts,
|
|
|
|
|
|
confirmedArtifacts,
|
|
|
|
|
|
} = resolveHtmlPublishArtifacts({
|
|
|
|
|
|
reply,
|
|
|
|
|
|
intent,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
workingDir,
|
|
|
|
|
|
publicBaseUrl: config.publicBaseUrl,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
requestStartedAt: retryStartedAt,
|
2026-07-16 21:06:28 +08:00
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
onPageGenerated,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
});
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
|
|
|
|
|
|
confirmedArtifacts,
|
|
|
|
|
|
});
|
2026-07-04 12:52:59 +08:00
|
|
|
|
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);
|
2026-07-04 05:21:13 +00:00
|
|
|
|
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)) {
|
2026-07-04 12:52:59 +08:00
|
|
|
|
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) {
|
2026-06-29 22:20:04 +08:00
|
|
|
|
throw new Error(buildHtmlPublishFailureText());
|
2026-06-27 22:28:41 +08:00
|
|
|
|
}
|
2026-07-12 13:54:24 +08:00
|
|
|
|
const pageDataOutcome = await enforcePageDataCollectDelivery({
|
|
|
|
|
|
reply,
|
|
|
|
|
|
intent,
|
|
|
|
|
|
workingDir,
|
|
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
pageDataFinishGuard,
|
|
|
|
|
|
publicBaseUrl: config.publicBaseUrl,
|
|
|
|
|
|
requestStartedAt: retryStartedAt,
|
|
|
|
|
|
notifyFailure: async (text) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sendCustomerServiceText(inbound.fromUserName, text, user);
|
|
|
|
|
|
} catch (sendErr) {
|
|
|
|
|
|
logger.error?.('WeChat MP page data retry failure notice failed:', sendErr);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
if (pageDataOutcome?.deliveryArtifacts?.length) {
|
|
|
|
|
|
publishArtifacts = uniqueArtifactsByUrl([
|
|
|
|
|
|
...pageDataOutcome.deliveryArtifacts,
|
|
|
|
|
|
...publishArtifacts,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (reply.tokenState) {
|
2026-07-11 00:23:01 +08:00
|
|
|
|
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
|
|
|
|
|
|
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, retryId);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}
|
2026-06-27 22:28:41 +08:00
|
|
|
|
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
2026-06-29 06:59:37 +08:00
|
|
|
|
workingDir,
|
2026-06-27 22:28:41 +08:00
|
|
|
|
publicBaseUrl: config.publicBaseUrl,
|
2026-07-04 12:52:59 +08:00
|
|
|
|
artifacts: publishArtifacts,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
});
|
2026-07-04 12:21:14 +08:00
|
|
|
|
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
|
2026-06-29 22:20:04 +08:00
|
|
|
|
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
2026-07-04 12:52:59 +08:00
|
|
|
|
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
|
2026-06-29 22:20:04 +08:00
|
|
|
|
linkExistsForRequest,
|
2026-06-27 22:28:41 +08:00
|
|
|
|
});
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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 ?? ''));
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const rawXmlHash = crypto.createHash('sha1').update(inbound.rawXml || '').digest('hex');
|
|
|
|
|
|
const intent = normalizeWechatInboundIntent(inbound);
|
|
|
|
|
|
intent.appId = config.appId;
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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: '当前公众号回调需使用明文模式接入,请联系管理员切换后再试。',
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if (inbound.msgType === 'event' && inbound.event === 'location') {
|
|
|
|
|
|
await persistIntentDetail({ intent, rawXmlHash });
|
|
|
|
|
|
return successResponse();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (inbound.msgType === 'event' && inbound.event === 'subscribe') {
|
2026-06-26 15:19:03 +08:00
|
|
|
|
await persistIntentDetail({ intent, rawXmlHash });
|
2026-06-19 23:06:43 +08:00
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
status: 200,
|
|
|
|
|
|
contentType: 'application/xml; charset=utf-8',
|
|
|
|
|
|
body: buildWechatTextReply({
|
|
|
|
|
|
toUserName: inbound.fromUserName,
|
|
|
|
|
|
fromUserName: inbound.toUserName,
|
|
|
|
|
|
content: `${config.unboundTextPrefix}\n${buildBindUrl()}`,
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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 });
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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) {
|
2026-06-26 15:19:03 +08:00
|
|
|
|
await persistIntentDetail({ intent, rawXmlHash });
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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') {
|
2026-06-26 15:19:03 +08:00
|
|
|
|
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
2026-06-19 23:06:43 +08:00
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
status: 200,
|
|
|
|
|
|
contentType: 'application/xml; charset=utf-8',
|
|
|
|
|
|
body: buildWechatTextReply({
|
|
|
|
|
|
toUserName: inbound.fromUserName,
|
|
|
|
|
|
fromUserName: inbound.toUserName,
|
|
|
|
|
|
content: '当前账号不可用,请联系管理员处理。',
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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: '已收到视频,当前先支持文本、语音、图片、定位和链接。',
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 13:48:47 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
|
|
|
|
|
|
|
|
|
|
|
const scheduleReply =
|
|
|
|
|
|
intent.msgType === 'text' || intent.msgType === 'voice'
|
2026-07-07 12:10:21 +08:00
|
|
|
|
? await handleWechatScheduleIntent({
|
|
|
|
|
|
intent,
|
|
|
|
|
|
user: boundUser,
|
|
|
|
|
|
scheduleService,
|
|
|
|
|
|
wechatScheduleLlmConfigService,
|
|
|
|
|
|
llmProviderService,
|
|
|
|
|
|
logger,
|
|
|
|
|
|
})
|
2026-06-26 15:19:03 +08:00
|
|
|
|
: null;
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const task = runIntentMessage({
|
2026-06-19 23:06:43 +08:00
|
|
|
|
inbound,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
intent,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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);
|
2026-07-04 12:52:59 +08:00
|
|
|
|
if (wasWechatUserNotified(err)) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sendCustomerServiceText(
|
|
|
|
|
|
inbound.fromUserName,
|
|
|
|
|
|
formatWechatAgentFailureMessage(err),
|
|
|
|
|
|
boundUser,
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (sendErr) {
|
|
|
|
|
|
logger.error?.('WeChat MP failure notice failed:', sendErr);
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
status: 200,
|
|
|
|
|
|
contentType: 'application/xml; charset=utf-8',
|
|
|
|
|
|
body: buildWechatTextReply({
|
|
|
|
|
|
toUserName: inbound.fromUserName,
|
|
|
|
|
|
fromUserName: inbound.toUserName,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
content: buildAckText({
|
|
|
|
|
|
intent,
|
|
|
|
|
|
nickname: resolveWechatAddressName(boundUser),
|
|
|
|
|
|
config,
|
|
|
|
|
|
fallbackText: config.ackText,
|
|
|
|
|
|
}),
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}),
|
|
|
|
|
|
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: '当前账号尚未绑定该服务号',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-07-04 12:10:22 +08:00
|
|
|
|
const { sessionId } = await ensureWechatAgentSession({
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
verifyUrlChallenge,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
handleInboundMessage,
|
|
|
|
|
|
getRouteStatusForUser,
|
|
|
|
|
|
recreateRouteForUser,
|
|
|
|
|
|
createJsSdkSignature,
|
|
|
|
|
|
sendTextToUser,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|