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-06-26 15:19:03 +08:00
|
|
|
|
import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
|
2026-06-29 22:20:04 +08:00
|
|
|
|
import { materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs';
|
2026-07-01 08:40:27 +08:00
|
|
|
|
import { buildCurrentTimeAgentPrefix } from './user-memory-profile.mjs';
|
2026-06-26 15:19:03 +08:00
|
|
|
|
import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
2026-07-01 21:07:25 +08:00
|
|
|
|
import { buildAutoChatSkillPrefix } from './chat-skills.mjs';
|
2026-06-26 15:19:03 +08:00
|
|
|
|
import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs';
|
|
|
|
|
|
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
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-06-19 23:06:43 +08:00
|
|
|
|
const DEFAULT_ACK_TEXT = '已收到,正在处理,完成后发到这里。';
|
|
|
|
|
|
const DEFAULT_PROGRESS_TEXT = '还在处理,请稍等片刻。';
|
|
|
|
|
|
const DEFAULT_STATUS_TEXT =
|
|
|
|
|
|
'我在这边。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。';
|
|
|
|
|
|
const DEFAULT_UNSUPPORTED_TEXT = '当前先支持文字消息,你可以直接发文字给我。';
|
|
|
|
|
|
const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agent 对话:';
|
|
|
|
|
|
const DEFAULT_PROGRESS_DELAY_MS = 8000;
|
2026-07-01 21:10:56 +08:00
|
|
|
|
const DEFAULT_SESSION_IDLE_ROTATE_MS = 30 * 60 * 1000;
|
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-06-19 23:06:43 +08:00
|
|
|
|
return {
|
|
|
|
|
|
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
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
|
const normalized = String(text ?? '').trim().toLowerCase();
|
|
|
|
|
|
if (!normalized) return false;
|
|
|
|
|
|
return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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('/');
|
|
|
|
|
|
return `${String(publicBaseUrl ?? '').replace(/\/+$/, '')}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(owner)}/${normalized}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-06-29 22:20:04 +08:00
|
|
|
|
function buildHtmlPublishFailureText() {
|
2026-06-29 06:59:37 +08:00
|
|
|
|
return [
|
2026-06-29 22:20:04 +08:00
|
|
|
|
'这次页面没有按 H5 里的页面技能真正生成成功,所以我先不发不一致的简版页。',
|
|
|
|
|
|
'请直接重发一次你的页面需求,我会按和 H5 相同的 `static-page-publish` 技能链路重做。',
|
|
|
|
|
|
].join('\n');
|
2026-06-29 06:59:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
materializeMissingPublicHtmlWrites({
|
|
|
|
|
|
messages: reply?.messages ?? [],
|
|
|
|
|
|
publishDir: workingDir,
|
|
|
|
|
|
});
|
|
|
|
|
|
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
|
|
|
|
|
|
workingDir,
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
});
|
|
|
|
|
|
const expectedArtifacts = collectExpectedHtmlArtifacts(intent, {
|
|
|
|
|
|
workingDir,
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
});
|
|
|
|
|
|
const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, {
|
|
|
|
|
|
workingDir,
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
replyText: reply?.text,
|
|
|
|
|
|
sinceMs: requestStartedAt,
|
|
|
|
|
|
});
|
|
|
|
|
|
const confirmedArtifacts = allExistingHtmlArtifacts({
|
|
|
|
|
|
publishedArtifacts,
|
|
|
|
|
|
expectedArtifacts,
|
|
|
|
|
|
recentArtifacts,
|
|
|
|
|
|
});
|
|
|
|
|
|
const verifiedArtifacts = buildVerifiedHtmlArtifacts({
|
|
|
|
|
|
publishedArtifacts,
|
|
|
|
|
|
expectedArtifacts,
|
|
|
|
|
|
recentArtifacts,
|
|
|
|
|
|
replyText: reply?.text,
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
publishedArtifacts,
|
|
|
|
|
|
expectedArtifacts,
|
|
|
|
|
|
recentArtifacts,
|
|
|
|
|
|
confirmedArtifacts,
|
|
|
|
|
|
verifiedArtifacts,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function shouldRetryHtmlGenerationReply({
|
|
|
|
|
|
reply,
|
|
|
|
|
|
intent,
|
|
|
|
|
|
confirmedArtifacts = [],
|
|
|
|
|
|
hasValidLinkInReply = false,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
|
|
|
|
|
|
|
|
|
|
|
const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text);
|
|
|
|
|
|
if (replyHasPublicLinks) {
|
|
|
|
|
|
if (!hasValidLinkInReply) return true;
|
|
|
|
|
|
if (isMissingRequiredPublishSkill(reply, intent)) return true;
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (confirmedArtifacts.length > 0) return false;
|
|
|
|
|
|
if (isMissingRequiredPublishSkill(reply, intent) || isSuspiciousBareCompletionReply(reply, intent)) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!usedStaticPagePublishSkill(reply?.messages ?? [])) return true;
|
|
|
|
|
|
return !hasAnyUrl(reply?.text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
function isQuestionStatusProbe(text) {
|
|
|
|
|
|
return /^[??]+$/.test(String(text ?? '').trim());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isSimpleGreeting(text) {
|
|
|
|
|
|
const normalized = String(text ?? '')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.replace(/[!!。.\s]+$/g, '')
|
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
|
return /^(你好|您好|在吗|在不在|嗨|hi|hello|hey)$/.test(normalized);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isConnectivityTest(text) {
|
|
|
|
|
|
const normalized = String(text ?? '')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.replace(/[!!。.\s]+$/g, '');
|
|
|
|
|
|
return /^(测试\s*\d*|test\s*\d*)$/i.test(normalized);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isTopicResetIntent(text) {
|
|
|
|
|
|
const normalized = String(text ?? '').trim();
|
|
|
|
|
|
return (
|
|
|
|
|
|
/^(换(个)?话题|新问题|忽略之前|不管之前|重新开始|reset)$/i.test(normalized) ||
|
|
|
|
|
|
/忽略.*之前|不要管.*之前|别管.*之前/.test(normalized)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-07-01 21:07:25 +08:00
|
|
|
|
export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const msgType = String(intent?.msgType ?? 'text');
|
2026-07-01 21:07:25 +08:00
|
|
|
|
const autoSkillPrefix =
|
|
|
|
|
|
msgType === 'text' || msgType === 'voice'
|
|
|
|
|
|
? buildAutoChatSkillPrefix(intent?.agentText ?? intent?.content, grantedSkills)
|
|
|
|
|
|
: '';
|
2026-07-01 12:07:46 +08:00
|
|
|
|
const docxDownloadHint =
|
|
|
|
|
|
looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content) &&
|
|
|
|
|
|
looksLikeDocxDownloadIntent(intent?.agentText ?? intent?.content)
|
|
|
|
|
|
? [
|
|
|
|
|
|
'【Word 下载要求】用户明确要求页面里可下载 Word / docx。',
|
|
|
|
|
|
'开始前必须先调用 `load_skill` → `docx-generate`,并按技能说明生成目标 `public/*.docx`。',
|
|
|
|
|
|
'生成后必须确认目标 `.docx` 已存在,再调用 `load_skill` → `static-page-publish` 写 `public/*.html`。',
|
|
|
|
|
|
'HTML 中只能用同目录相对路径链接该文档;禁止只写下载按钮却没有先把 `.docx` 落盘。',
|
|
|
|
|
|
'',
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
: '';
|
2026-06-29 06:59:37 +08:00
|
|
|
|
const pagePublishHint = looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content)
|
|
|
|
|
|
? [
|
|
|
|
|
|
'【页面发布技能要求】这条消息是在生成可访问 HTML 页面。',
|
|
|
|
|
|
'开始前必须先调用 `load_skill` → `static-page-publish`,不能省略,也不能写完页面后再补调。',
|
2026-06-29 22:20:04 +08:00
|
|
|
|
'随后必须用 sandbox-fs 的 `write_file` / `edit_file` 写入 `public/*.html`。',
|
|
|
|
|
|
'禁止用 shell / cat / heredoc / echo / cp 写入 HTML:shell 在容器内执行,文件不会出现在公网 MindSpace 路径,用户会收到「文件不存在」。',
|
|
|
|
|
|
'在回复用户前,确认 `public/` 下目标 HTML 已通过 write_file 落盘;没有落盘就不要发送链接或说「已发布」。',
|
2026-06-29 06:59:37 +08:00
|
|
|
|
'最终只给用户一个正式域名的唯一正确链接;不要输出错误域名、备用链接或让用户手动保存文件。',
|
|
|
|
|
|
'',
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
: '';
|
2026-06-30 02:02:58 +08:00
|
|
|
|
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
|
2026-07-01 08:40:27 +08:00
|
|
|
|
const currentTimeHint = buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone });
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content)
|
|
|
|
|
|
? [
|
|
|
|
|
|
'【日程技能要求】这条消息涉及待办、提醒或日程。',
|
|
|
|
|
|
'开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。',
|
2026-06-30 02:02:58 +08:00
|
|
|
|
'写入工具时优先使用 startLocal / endLocal / remindLocal(YYYY-MM-DD HH:mm),不要自行估算 Unix 毫秒。',
|
2026-06-26 15:19:03 +08:00
|
|
|
|
'只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。',
|
|
|
|
|
|
intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '',
|
|
|
|
|
|
'',
|
|
|
|
|
|
].filter(Boolean).join('\n')
|
|
|
|
|
|
: '';
|
|
|
|
|
|
if (msgType === 'voice') {
|
|
|
|
|
|
return [
|
2026-07-01 12:07:46 +08:00
|
|
|
|
docxDownloadHint,
|
2026-07-01 08:40:27 +08:00
|
|
|
|
currentTimeHint,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
scheduleAssistantHint,
|
|
|
|
|
|
'【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。',
|
|
|
|
|
|
'',
|
2026-07-01 21:07:25 +08:00
|
|
|
|
`用户语音识别文本:${autoSkillPrefix}${String(intent?.agentText ?? '').trim()}`,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (msgType === 'image') {
|
|
|
|
|
|
return [
|
2026-07-01 12:07:46 +08:00
|
|
|
|
docxDownloadHint,
|
2026-07-01 08:40:27 +08:00
|
|
|
|
currentTimeHint,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
scheduleAssistantHint,
|
|
|
|
|
|
'【微信服务号图片消息】用户发送了图片。',
|
|
|
|
|
|
'如用户没有明确要求,请先根据图片内容给出简短理解,并询问下一步。',
|
|
|
|
|
|
'',
|
|
|
|
|
|
String(intent?.agentText ?? '').trim(),
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (msgType === 'location') {
|
|
|
|
|
|
const location = intent?.location ?? {};
|
|
|
|
|
|
return [
|
2026-07-01 08:40:27 +08:00
|
|
|
|
currentTimeHint,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
scheduleAssistantHint,
|
|
|
|
|
|
'【微信服务号位置消息】用户发送了当前位置。',
|
|
|
|
|
|
location.label ? `地址:${location.label}` : '',
|
|
|
|
|
|
location.latitude !== null && location.latitude !== undefined
|
|
|
|
|
|
? `纬度:${location.latitude}`
|
|
|
|
|
|
: '',
|
|
|
|
|
|
location.longitude !== null && location.longitude !== undefined
|
|
|
|
|
|
? `经度:${location.longitude}`
|
|
|
|
|
|
: '',
|
|
|
|
|
|
'请结合位置回答用户可能的路线、附近、行程或提醒需求;如果意图不明确,先简短询问。',
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (msgType === 'link') {
|
|
|
|
|
|
const link = intent?.link ?? {};
|
|
|
|
|
|
return [
|
2026-07-01 08:40:27 +08:00
|
|
|
|
currentTimeHint,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
scheduleAssistantHint,
|
|
|
|
|
|
'【微信服务号链接消息】用户分享了链接。',
|
|
|
|
|
|
link.title ? `标题:${link.title}` : '',
|
|
|
|
|
|
link.description ? `描述:${link.description}` : '',
|
|
|
|
|
|
link.url ? `URL:${link.url}` : '',
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
const content = String(intent?.agentText ?? intent?.content ?? '').trim();
|
2026-07-01 08:40:27 +08:00
|
|
|
|
const lines = [currentTimeHint];
|
2026-07-01 12:07:46 +08:00
|
|
|
|
if (docxDownloadHint) lines.push(docxDownloadHint);
|
2026-06-29 06:59:37 +08:00
|
|
|
|
if (pagePublishHint) lines.push(pagePublishHint);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
|
|
|
|
|
|
lines.push(
|
2026-06-19 23:06:43 +08:00
|
|
|
|
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
|
|
|
|
|
|
'若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。',
|
|
|
|
|
|
'',
|
2026-07-01 21:07:25 +08:00
|
|
|
|
`用户消息:${autoSkillPrefix}${content}`,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
);
|
|
|
|
|
|
return lines.join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function looksLikeScheduleConfirmation(text) {
|
|
|
|
|
|
const compact = String(text ?? '').replace(/\s+/g, '');
|
|
|
|
|
|
if (!compact) return false;
|
|
|
|
|
|
if (/(没有|没能|未能|无法|不能|失败|需要你|请补充|请告诉)/.test(compact)) return false;
|
|
|
|
|
|
return /(已经|已|现在).{0,12}(设置|安排|记录|加上|添加|创建).{0,12}(待办|提醒|日程|安排|闹钟)|设置好了|已经设置好了|已经加上/.test(
|
|
|
|
|
|
compact,
|
|
|
|
|
|
);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildConnectivityTestReply(user) {
|
|
|
|
|
|
const name = resolveWechatAddressName(user);
|
|
|
|
|
|
return name
|
|
|
|
|
|
? `${name},公众号消息通道正常,我收到你的测试了。`
|
|
|
|
|
|
: '公众号消息通道正常,我收到你的测试了。';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeWechatName(value) {
|
|
|
|
|
|
const name = String(value ?? '').trim();
|
|
|
|
|
|
if (!name || /^wx_[a-z0-9_]{4,64}$/i.test(name)) return '';
|
|
|
|
|
|
return name;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveWechatAddressName(user) {
|
2026-06-26 15:19:03 +08:00
|
|
|
|
return normalizeWechatName(user?.nickname) || normalizeWechatName(user?.displayName) || '';
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildGreetingText(user) {
|
|
|
|
|
|
const name = resolveWechatAddressName(user);
|
|
|
|
|
|
return name ? `你好,${name}!我在呢,有什么需要?` : '你好!我在呢,有什么需要?';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildStatusText(user, fallbackText) {
|
|
|
|
|
|
const name = resolveWechatAddressName(user);
|
|
|
|
|
|
if (!name) return fallbackText;
|
|
|
|
|
|
return `我在这边,${name}。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function successResponse(task = null) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
status: 200,
|
|
|
|
|
|
contentType: 'text/plain; charset=utf-8',
|
|
|
|
|
|
body: 'success',
|
|
|
|
|
|
...(task ? { task } : {}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function loadWechatMpConfig(env = process.env) {
|
|
|
|
|
|
const appId = env.H5_WECHAT_MP_APP_ID?.trim() ?? env.H5_WECHAT_APP_ID?.trim() ?? '';
|
|
|
|
|
|
const appSecret =
|
|
|
|
|
|
env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? '';
|
|
|
|
|
|
const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? '';
|
|
|
|
|
|
const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? '';
|
|
|
|
|
|
const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1';
|
|
|
|
|
|
const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login';
|
|
|
|
|
|
return {
|
|
|
|
|
|
enabled: enabledFlag && Boolean(appId && appSecret && token && publicBaseUrl),
|
|
|
|
|
|
appId,
|
|
|
|
|
|
appSecret,
|
|
|
|
|
|
token,
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
bindPath,
|
|
|
|
|
|
ackText: env.H5_WECHAT_MP_ACK_TEXT?.trim() || DEFAULT_ACK_TEXT,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
ackRandomEnabled: env.H5_WECHAT_MP_ACK_RANDOM !== '0',
|
|
|
|
|
|
ackNicknameEnabled: env.H5_WECHAT_MP_ACK_NICKNAME !== '0',
|
2026-06-19 23:06:43 +08:00
|
|
|
|
progressText: env.H5_WECHAT_MP_PROGRESS_TEXT?.trim() || DEFAULT_PROGRESS_TEXT,
|
|
|
|
|
|
progressDelayMs: Math.max(
|
|
|
|
|
|
0,
|
|
|
|
|
|
Number(env.H5_WECHAT_MP_PROGRESS_DELAY_MS ?? DEFAULT_PROGRESS_DELAY_MS),
|
|
|
|
|
|
),
|
2026-07-01 21:10:56 +08:00
|
|
|
|
sessionIdleRotateMs: Math.max(
|
|
|
|
|
|
0,
|
|
|
|
|
|
Number(env.H5_WECHAT_MP_SESSION_IDLE_ROTATE_MS ?? DEFAULT_SESSION_IDLE_ROTATE_MS),
|
|
|
|
|
|
),
|
2026-06-19 23:06:43 +08:00
|
|
|
|
statusText: env.H5_WECHAT_MP_STATUS_TEXT?.trim() || DEFAULT_STATUS_TEXT,
|
|
|
|
|
|
unsupportedText: env.H5_WECHAT_MP_UNSUPPORTED_TEXT?.trim() || DEFAULT_UNSUPPORTED_TEXT,
|
|
|
|
|
|
unboundTextPrefix: env.H5_WECHAT_MP_UNBOUND_TEXT_PREFIX?.trim() || DEFAULT_UNBOUND_TEXT,
|
|
|
|
|
|
tokenUrl: env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_WECHAT_TOKEN_URL,
|
|
|
|
|
|
customerServiceUrl:
|
|
|
|
|
|
env.H5_WECHAT_MP_CUSTOMER_SERVICE_URL?.trim() || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
|
|
|
|
|
jsapiTicketUrl:
|
|
|
|
|
|
env.H5_WECHAT_MP_JSAPI_TICKET_URL?.trim() ||
|
|
|
|
|
|
deriveWechatEndpointFromUrl(env.H5_WECHAT_MP_TOKEN_URL?.trim(), '/cgi-bin/ticket/getticket') ||
|
|
|
|
|
|
DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
mediaPublicBaseUrl:
|
|
|
|
|
|
env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') || publicBaseUrl,
|
|
|
|
|
|
maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)),
|
|
|
|
|
|
acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0',
|
|
|
|
|
|
acceptImage: env.H5_WECHAT_MP_ACCEPT_IMAGE !== '0',
|
|
|
|
|
|
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
|
|
|
|
|
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
|
|
|
|
|
|
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
|
2026-06-19 23:06:43 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
apiFetch,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
startAgentSession = null,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
sessionApiFetch = null,
|
|
|
|
|
|
scheduleService = null,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
applySessionLlmProvider = null,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
refreshSessionSnapshot = 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,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (!config?.enabled) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
enabled: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
config = {
|
|
|
|
|
|
...config,
|
|
|
|
|
|
tokenUrl: config.tokenUrl || DEFAULT_WECHAT_TOKEN_URL,
|
|
|
|
|
|
customerServiceUrl: config.customerServiceUrl || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
|
|
|
|
|
jsapiTicketUrl: config.jsapiTicketUrl || DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
|
|
|
|
|
progressText: config.progressText ?? DEFAULT_PROGRESS_TEXT,
|
|
|
|
|
|
progressDelayMs: Math.max(0, Number(config.progressDelayMs ?? DEFAULT_PROGRESS_DELAY_MS)),
|
|
|
|
|
|
statusText: config.statusText || DEFAULT_STATUS_TEXT,
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
let jsapiTicketCache = {
|
|
|
|
|
|
ticket: null,
|
|
|
|
|
|
expiresAt: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const fetchForSession = (sessionId, pathname, init) =>
|
|
|
|
|
|
sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch(pathname, init);
|
|
|
|
|
|
|
|
|
|
|
|
const buildBindUrl = () => {
|
|
|
|
|
|
if (/^https?:\/\//i.test(config.bindPath)) return config.bindPath;
|
|
|
|
|
|
const path = config.bindPath.startsWith('/') ? config.bindPath : `/${config.bindPath}`;
|
|
|
|
|
|
return `${config.publicBaseUrl}${path}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const verifyRequest = (query = {}) =>
|
|
|
|
|
|
verifyWechatMpSignature({
|
|
|
|
|
|
token: config.token,
|
|
|
|
|
|
timestamp: String(query.timestamp ?? ''),
|
|
|
|
|
|
nonce: String(query.nonce ?? ''),
|
|
|
|
|
|
signature: String(query.signature ?? ''),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
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-06-26 15:19:03 +08:00
|
|
|
|
const formatted = formatWechatOutboundText(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-06-27 22:28:41 +08:00
|
|
|
|
await reconcileAgentSession(
|
|
|
|
|
|
(pathname, init) => fetchForSession(existingRoute.agentSessionId, pathname, init),
|
|
|
|
|
|
existingRoute.agentSessionId,
|
|
|
|
|
|
{
|
|
|
|
|
|
workingDir,
|
|
|
|
|
|
sessionPolicy,
|
|
|
|
|
|
sandboxConstraints: publishLayout?.constraints ?? null,
|
|
|
|
|
|
userContext: publishLayout
|
|
|
|
|
|
? {
|
|
|
|
|
|
userId,
|
|
|
|
|
|
displayName: addressName || publishLayout.displayName,
|
|
|
|
|
|
username: addressName || null,
|
|
|
|
|
|
slug: null,
|
|
|
|
|
|
}
|
|
|
|
|
|
: null,
|
|
|
|
|
|
tolerateInvalidWorkingDir: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
const routeHasTools = await sessionHasRequiredTools(
|
|
|
|
|
|
fetchForSession,
|
|
|
|
|
|
existingRoute.agentSessionId,
|
|
|
|
|
|
sessionPolicy,
|
|
|
|
|
|
).catch(() => false);
|
|
|
|
|
|
if (routeHasTools) {
|
|
|
|
|
|
return existingRoute.agentSessionId;
|
|
|
|
|
|
}
|
|
|
|
|
|
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
|
|
|
|
|
} else if (existingRoute?.status === 'disabled') {
|
|
|
|
|
|
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
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-01 18:36:46 +08:00
|
|
|
|
if (!startAgentSession && typeof userAuth.registerAgentSession === 'function') {
|
|
|
|
|
|
await userAuth.registerAgentSession(userId, sessionId);
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
await reconcileAgentSession(
|
|
|
|
|
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
{
|
|
|
|
|
|
workingDir,
|
|
|
|
|
|
sessionPolicy,
|
|
|
|
|
|
sandboxConstraints: publishLayout?.constraints ?? null,
|
|
|
|
|
|
userContext: publishLayout
|
|
|
|
|
|
? {
|
|
|
|
|
|
userId,
|
|
|
|
|
|
displayName: addressName || publishLayout.displayName,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
username: addressName || null,
|
|
|
|
|
|
slug: null,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
: null,
|
|
|
|
|
|
tolerateInvalidWorkingDir: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
await userAuth.upsertWechatAgentRoute({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
appId: config.appId,
|
|
|
|
|
|
openid,
|
|
|
|
|
|
agentSessionId: sessionId,
|
|
|
|
|
|
});
|
|
|
|
|
|
return sessionId;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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-06-19 23:06:43 +08:00
|
|
|
|
const rememberWechatUserContext = async (sessionId, user) => {
|
|
|
|
|
|
const addressName = resolveWechatAddressName(user);
|
|
|
|
|
|
if (!addressName) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await readJsonResponse(
|
|
|
|
|
|
await fetchForSession(sessionId, '/agent/harness_remember', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
title: '微信服务号用户',
|
|
|
|
|
|
content: [
|
|
|
|
|
|
`当前服务号用户名称:${addressName}`,
|
|
|
|
|
|
'这是通过微信 openid 在 H5 绑定库中查询到的用户名称。',
|
|
|
|
|
|
'回复时可以自然使用该名称称呼用户,不要使用 openid 或 wx_ 开头的内部用户名。',
|
|
|
|
|
|
].join('\n'),
|
|
|
|
|
|
}),
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
await readJsonResponse(
|
|
|
|
|
|
await fetchForSession(sessionId, '/agent/harness_bootstrap', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify({ sessionId, force: true }),
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
logger.warn?.('WeChat MP user context remember failed:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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 }) => {
|
|
|
|
|
|
const resetCandidate =
|
|
|
|
|
|
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
|
|
|
|
|
const forceNew = isTopicResetIntent(resetCandidate);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
let sessionId = await ensureWechatAgentSession({
|
|
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
openid: inbound.fromUserName,
|
|
|
|
|
|
userContext: user,
|
|
|
|
|
|
forceNew,
|
|
|
|
|
|
});
|
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-06-19 23:06:43 +08:00
|
|
|
|
await rememberWechatUserContext(sessionId, user);
|
|
|
|
|
|
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-06-26 15:19:03 +08:00
|
|
|
|
const guardScheduleReply = async (replyText) => {
|
|
|
|
|
|
if (!scheduleService || !looksLikeScheduleConfirmation(replyText)) return replyText;
|
|
|
|
|
|
const sourceMessageId = String(intent.msgId ?? '').trim();
|
|
|
|
|
|
if (!sourceMessageId || typeof scheduleService.listItemsBySourceMessage !== 'function') {
|
|
|
|
|
|
return replyText;
|
|
|
|
|
|
}
|
|
|
|
|
|
const items = await scheduleService
|
|
|
|
|
|
.listItemsBySourceMessage({
|
|
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
sourceMessageId,
|
|
|
|
|
|
limit: 5,
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
|
logger.warn?.('Schedule confirmation guard failed:', err);
|
|
|
|
|
|
return [];
|
|
|
|
|
|
});
|
|
|
|
|
|
if (items.length > 0) return replyText;
|
|
|
|
|
|
return [
|
|
|
|
|
|
'我刚才没有确认到待办/提醒已经写入系统,所以这次不能算设置成功。',
|
|
|
|
|
|
'请再发一次完整安排,比如“明天早上 6 点跑步,5 点半提醒我”。我会在工具写入成功后再确认。',
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
};
|
2026-06-19 23:06:43 +08:00
|
|
|
|
try {
|
2026-06-29 06:59:37 +08:00
|
|
|
|
const requestStartedAt = Date.now();
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const reply = await executeSessionReply(
|
|
|
|
|
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
requestId,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
buildWechatAgentPrompt(intent),
|
|
|
|
|
|
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-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
|
|
|
|
});
|
|
|
|
|
|
if (
|
2026-06-29 22:20:04 +08:00
|
|
|
|
shouldRetryHtmlGenerationReply({
|
|
|
|
|
|
reply,
|
|
|
|
|
|
intent,
|
|
|
|
|
|
confirmedArtifacts,
|
|
|
|
|
|
hasValidLinkInReply,
|
|
|
|
|
|
}) ||
|
2026-06-29 06:59:37 +08:00
|
|
|
|
(expectedArtifacts.length === 0 &&
|
|
|
|
|
|
recentArtifacts.length === 0 &&
|
2026-06-29 22:20:04 +08:00
|
|
|
|
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })))
|
2026-06-29 06:59:37 +08:00
|
|
|
|
) {
|
2026-06-27 22:28:41 +08:00
|
|
|
|
throw new Error('stale_session_poisoned_completion');
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (reply.tokenState) {
|
|
|
|
|
|
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (looksLikeHtmlGenerationIntent(intent?.agentText) && verifiedArtifacts.length === 0 && confirmedArtifacts.length === 0) {
|
|
|
|
|
|
throw new Error(buildHtmlPublishFailureText());
|
|
|
|
|
|
}
|
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-06-29 22:20:04 +08:00
|
|
|
|
artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
});
|
|
|
|
|
|
await refreshWechatSessionSnapshot(sessionId, user.userId);
|
2026-06-29 22:20:04 +08:00
|
|
|
|
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
|
|
|
|
|
verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map(
|
|
|
|
|
|
(artifact) => artifact.url,
|
|
|
|
|
|
),
|
|
|
|
|
|
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);
|
|
|
|
|
|
const mayBeStaleSession =
|
2026-06-27 22:28:41 +08:00
|
|
|
|
/403|404|not found|无权访问|session|stale_session_poisoned_completion/i.test(message) && sessionId;
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (mayBeStaleSession) {
|
|
|
|
|
|
sessionId = await ensureWechatAgentSession({
|
|
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
openid: inbound.fromUserName,
|
|
|
|
|
|
forceNew: true,
|
|
|
|
|
|
userContext: user,
|
|
|
|
|
|
});
|
2026-06-26 15:19:03 +08:00
|
|
|
|
await ensureSessionProvider(sessionId);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
await rememberWechatUserContext(sessionId, user);
|
|
|
|
|
|
const retryId = crypto.randomUUID();
|
2026-06-29 06:59:37 +08:00
|
|
|
|
const retryStartedAt = Date.now();
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const reply = await executeSessionReply(
|
|
|
|
|
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
retryId,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
buildWechatAgentPrompt(intent),
|
|
|
|
|
|
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-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
|
|
|
|
if (
|
2026-06-29 22:20:04 +08:00
|
|
|
|
shouldRetryHtmlGenerationReply({
|
|
|
|
|
|
reply,
|
|
|
|
|
|
intent,
|
|
|
|
|
|
confirmedArtifacts,
|
|
|
|
|
|
hasValidLinkInReply,
|
|
|
|
|
|
}) ||
|
2026-06-29 06:59:37 +08:00
|
|
|
|
(expectedArtifacts.length === 0 &&
|
|
|
|
|
|
recentArtifacts.length === 0 &&
|
2026-06-29 22:20:04 +08:00
|
|
|
|
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })))
|
2026-06-29 06:59:37 +08:00
|
|
|
|
) {
|
2026-06-29 22:20:04 +08:00
|
|
|
|
throw new Error(buildHtmlPublishFailureText());
|
2026-06-27 22:28:41 +08:00
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (reply.tokenState) {
|
|
|
|
|
|
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (looksLikeHtmlGenerationIntent(intent?.agentText) && verifiedArtifacts.length === 0 && confirmedArtifacts.length === 0) {
|
|
|
|
|
|
throw new Error(buildHtmlPublishFailureText());
|
|
|
|
|
|
}
|
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-06-29 22:20:04 +08:00
|
|
|
|
artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts,
|
2026-06-29 06:59:37 +08:00
|
|
|
|
});
|
|
|
|
|
|
await refreshWechatSessionSnapshot(sessionId, user.userId);
|
2026-06-29 22:20:04 +08:00
|
|
|
|
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
|
|
|
|
|
verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map(
|
|
|
|
|
|
(artifact) => artifact.url,
|
|
|
|
|
|
),
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const handleScheduleIntent = async ({ intent, user }) => {
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (!scheduleService) return null;
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const scheduleIntent = parseScheduleIntent(intent.agentText);
|
|
|
|
|
|
if (!isScheduleIntent(scheduleIntent)) return null;
|
2026-06-19 23:06:43 +08:00
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if (scheduleIntent.action === 'create_todo') {
|
|
|
|
|
|
if (scheduleIntent.needsClarification?.includes('todo_title')) {
|
|
|
|
|
|
return '可以。你想让我记哪一条待办?例如“帮我记一下 跟段吃饭”。';
|
|
|
|
|
|
}
|
|
|
|
|
|
const item = await scheduleService.createItem({
|
|
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
kind: 'task',
|
|
|
|
|
|
title: scheduleIntent.title,
|
|
|
|
|
|
timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
|
|
|
|
|
sourceChannel: 'wechat',
|
|
|
|
|
|
sourceMessageId: intent.msgId || null,
|
|
|
|
|
|
sourceText: intent.agentText,
|
|
|
|
|
|
metadata: {
|
|
|
|
|
|
source: 'wechat_mp',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
return `已记录到待办列表:${item.title},未设置提醒。`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (scheduleIntent.action === 'create_daily_todo_digest') {
|
|
|
|
|
|
if (scheduleIntent.needsClarification?.includes('digest_time')) {
|
2026-06-19 23:06:43 +08:00
|
|
|
|
return '可以。你想每天几点收到当天待办记录?比如“每天早上 7 点发给我”。';
|
|
|
|
|
|
}
|
|
|
|
|
|
const subscription = await scheduleService.createDailyTodoDigest({
|
|
|
|
|
|
userId: user.userId,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
hour: scheduleIntent.hour,
|
|
|
|
|
|
minute: scheduleIntent.minute,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
|
|
|
|
|
channel: 'wechat',
|
|
|
|
|
|
sourceChannel: 'wechat',
|
2026-06-26 15:19:03 +08:00
|
|
|
|
sourceMessageId: intent.msgId || null,
|
|
|
|
|
|
sourceText: intent.agentText,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
});
|
|
|
|
|
|
const minuteText = subscription.minute === 0 ? '' : `${String(subscription.minute).padStart(2, '0')}分`;
|
|
|
|
|
|
return `已设置:我会每天早上 ${subscription.hour}点${minuteText} 通过服务号把当天待办记录发给你。`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if (scheduleIntent.action === 'create_balance_alert') {
|
|
|
|
|
|
if (scheduleIntent.needsClarification?.includes('threshold')) {
|
|
|
|
|
|
return '可以。你想在余额低于多少时提醒我?例如“余额低于 20 元提醒我”。';
|
|
|
|
|
|
}
|
|
|
|
|
|
const subscription = await scheduleService.createBalanceLowAlert({
|
|
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
thresholdCents: scheduleIntent.thresholdCents,
|
|
|
|
|
|
channel: 'wechat',
|
|
|
|
|
|
sourceChannel: 'wechat',
|
|
|
|
|
|
sourceMessageId: intent.msgId || null,
|
|
|
|
|
|
sourceText: intent.agentText,
|
|
|
|
|
|
});
|
|
|
|
|
|
return `已设置:当余额低于 ${(subscription.thresholdCents / 100).toFixed(2)} 元时,我会通过服务号提醒你。`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (scheduleIntent.action === 'query_schedule') {
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const text = await scheduleService.buildTodoDigestText({
|
|
|
|
|
|
userId: user.userId,
|
|
|
|
|
|
timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
|
|
|
|
|
});
|
|
|
|
|
|
return text;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleInboundMessage = async (bodyText, query = {}) => {
|
|
|
|
|
|
if (!verifyRequest(query)) {
|
|
|
|
|
|
return { ok: false, status: 403, body: 'invalid signature' };
|
|
|
|
|
|
}
|
|
|
|
|
|
const inbound = parseWechatMessage(String(bodyText ?? ''));
|
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: '已收到视频,当前先支持文本、语音、图片、定位和链接。',
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (intent.msgType === 'text' && isQuestionStatusProbe(intent.agentText)) {
|
|
|
|
|
|
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: buildStatusText(boundUser, config.statusText),
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if (intent.msgType === 'text' && isSimpleGreeting(intent.agentText)) {
|
|
|
|
|
|
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: buildGreetingText(boundUser),
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if ((intent.msgType === 'text' || intent.msgType === 'voice') && isConnectivityTest(intent.agentText)) {
|
|
|
|
|
|
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: buildConnectivityTestReply(boundUser),
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (inbound.msgId && typeof userAuth.recordWechatMpMessage === 'function') {
|
|
|
|
|
|
const recorded = await userAuth.recordWechatMpMessage({
|
|
|
|
|
|
appId: config.appId,
|
|
|
|
|
|
openid: inbound.fromUserName,
|
|
|
|
|
|
msgId: inbound.msgId,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!recorded?.inserted) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
status: 200,
|
|
|
|
|
|
contentType: 'application/xml; charset=utf-8',
|
|
|
|
|
|
body: buildWechatTextReply({
|
|
|
|
|
|
toUserName: inbound.fromUserName,
|
|
|
|
|
|
fromUserName: inbound.toUserName,
|
|
|
|
|
|
content: buildStatusText(boundUser, config.statusText),
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
|
|
|
|
|
|
|
|
|
|
|
const scheduleReply =
|
|
|
|
|
|
intent.msgType === 'text' || intent.msgType === 'voice'
|
|
|
|
|
|
? await handleScheduleIntent({ intent, user: boundUser })
|
|
|
|
|
|
: 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);
|
|
|
|
|
|
return sendCustomerServiceText(
|
|
|
|
|
|
inbound.fromUserName,
|
|
|
|
|
|
`这次转发到专属 Agent 失败了:${err instanceof Error ? err.message : String(err)}`,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
boundUser,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
).catch(() => {});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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: '当前账号尚未绑定该服务号',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
const sessionId = await ensureWechatAgentSession({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
openid,
|
|
|
|
|
|
forceNew: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
const route = await userAuth.getWechatAgentRoute(config.appId, openid);
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
route: {
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
bound: true,
|
|
|
|
|
|
appId: config.appId,
|
|
|
|
|
|
openid,
|
|
|
|
|
|
agentSessionId: sessionId,
|
|
|
|
|
|
routeStatus: route?.status ?? 'active',
|
|
|
|
|
|
updatedAt: route?.updatedAt ?? null,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
verifyRequest,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
verifyUrlChallenge,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
handleInboundMessage,
|
|
|
|
|
|
getRouteStatusForUser,
|
|
|
|
|
|
recreateRouteForUser,
|
|
|
|
|
|
createJsSdkSignature,
|
|
|
|
|
|
sendTextToUser,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|