229805a070
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout. Co-authored-by: Cursor <cursoragent@cursor.com>
1023 lines
33 KiB
JavaScript
1023 lines
33 KiB
JavaScript
import crypto from 'node:crypto';
|
||
import { fetch as undiciFetch } from 'undici';
|
||
import { mergeMessageContent } from './message-stream.mjs';
|
||
import { reconcileAgentSession } from './session-reconcile.mjs';
|
||
import { isScheduleIntent, parseScheduleIntent } from './schedule-intent.mjs';
|
||
|
||
const DEFAULT_WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
|
||
const DEFAULT_WECHAT_CUSTOMER_SERVICE_URL =
|
||
'https://api.weixin.qq.com/cgi-bin/message/custom/send';
|
||
const DEFAULT_WECHAT_JSAPI_TICKET_URL = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket';
|
||
const DEFAULT_ACK_TEXT = '已收到,正在处理,完成后发到这里。';
|
||
const DEFAULT_PROGRESS_TEXT = '还在处理,请稍等片刻。';
|
||
const DEFAULT_STATUS_TEXT =
|
||
'我在这边。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。';
|
||
const DEFAULT_UNSUPPORTED_TEXT = '当前先支持文字消息,你可以直接发文字给我。';
|
||
const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agent 对话:';
|
||
const DEFAULT_PROGRESS_DELAY_MS = 8000;
|
||
|
||
function parseXmlField(xml, field) {
|
||
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[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'),
|
||
event: parseXmlField(xml, 'Event').toLowerCase(),
|
||
eventKey: parseXmlField(xml, 'EventKey'),
|
||
encrypt: parseXmlField(xml, 'Encrypt'),
|
||
};
|
||
}
|
||
|
||
function readJsonResponse(response) {
|
||
return response.text().then((text) => {
|
||
if (!response.ok) {
|
||
throw new Error(text || `upstream ${response.status}`);
|
||
}
|
||
return text ? JSON.parse(text) : null;
|
||
});
|
||
}
|
||
|
||
function deriveWechatEndpointFromUrl(sourceUrl, pathname) {
|
||
if (!sourceUrl) return '';
|
||
try {
|
||
const url = new URL(sourceUrl);
|
||
url.pathname = pathname;
|
||
url.search = '';
|
||
return url.toString();
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function createUserMessage(text) {
|
||
return {
|
||
id: crypto.randomUUID(),
|
||
role: 'user',
|
||
created: Math.floor(Date.now() / 1000),
|
||
content: [{ type: 'text', text }],
|
||
metadata: {
|
||
userVisible: true,
|
||
agentVisible: true,
|
||
},
|
||
};
|
||
}
|
||
|
||
function messageVisibleText(message) {
|
||
if (!message?.content) return '';
|
||
return message.content
|
||
.filter((item) => item.type === 'text' && typeof item.text === 'string')
|
||
.map((item) => item.text)
|
||
.join('');
|
||
}
|
||
|
||
function pushMessage(messages, incoming) {
|
||
const last = messages[messages.length - 1];
|
||
if (last?.id && incoming?.id && last.id === incoming.id) {
|
||
return [
|
||
...messages.slice(0, -1),
|
||
{
|
||
...last,
|
||
content: mergeMessageContent(last.content, incoming.content),
|
||
},
|
||
];
|
||
}
|
||
return [...messages, incoming];
|
||
}
|
||
|
||
async function executeSessionReply(apiFetch, sessionId, requestId, prompt) {
|
||
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
|
||
method: 'GET',
|
||
headers: { Accept: 'text/event-stream' },
|
||
});
|
||
if (!eventsResponse.ok || !eventsResponse.body) {
|
||
const text = await eventsResponse.text().catch(() => '');
|
||
throw new Error(text || '无法建立公众号消息事件流');
|
||
}
|
||
|
||
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
request_id: requestId,
|
||
user_message: createUserMessage(prompt),
|
||
}),
|
||
});
|
||
if (!replyResponse.ok) {
|
||
const text = await replyResponse.text().catch(() => '');
|
||
throw new Error(text || 'Agent reply 请求失败');
|
||
}
|
||
replyResponse.body?.cancel().catch?.(() => {});
|
||
|
||
const reader = eventsResponse.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let messages = [];
|
||
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const frames = buffer.split('\n\n');
|
||
buffer = frames.pop() ?? '';
|
||
for (const frame of frames) {
|
||
let data = '';
|
||
for (const line of frame.split('\n')) {
|
||
if (line.startsWith('data:')) data += line.slice(5).trim();
|
||
}
|
||
if (!data) continue;
|
||
let event;
|
||
try {
|
||
event = JSON.parse(data);
|
||
} catch {
|
||
continue;
|
||
}
|
||
const routingId = event.chat_request_id ?? event.request_id;
|
||
if (routingId && routingId !== requestId) continue;
|
||
|
||
if (event.type === 'Message' && event.message?.metadata?.userVisible) {
|
||
const hasActionRequired = event.message.content?.some((item) => item.type === 'actionRequired');
|
||
if (hasActionRequired) {
|
||
throw new Error('当前回复需要人工确认,公众号通道暂不支持');
|
||
}
|
||
messages = pushMessage(messages, event.message);
|
||
} else if (event.type === 'UpdateConversation') {
|
||
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
|
||
} else if (event.type === 'Error') {
|
||
throw new Error(event.error || '任务执行失败');
|
||
} else if (event.type === 'Finish') {
|
||
const assistant = [...messages].reverse().find((item) => item.role === 'assistant');
|
||
return {
|
||
text: messageVisibleText(assistant),
|
||
tokenState: event.token_state ?? null,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
throw new Error('公众号消息事件流提前结束');
|
||
}
|
||
|
||
const WECHAT_CUSTOMER_TEXT_MAX_BYTES = 2048;
|
||
|
||
function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) {
|
||
const normalized = String(text ?? '').trim();
|
||
if (!normalized) return ['我先收到了,但这次没有生成可发送的文本结果。'];
|
||
|
||
const chunks = [];
|
||
let offset = 0;
|
||
while (offset < normalized.length) {
|
||
let byteCount = 0;
|
||
let end = offset;
|
||
while (end < normalized.length) {
|
||
const charBytes = Buffer.byteLength(normalized[end], 'utf8');
|
||
if (byteCount + charBytes > maxBytes) break;
|
||
byteCount += charBytes;
|
||
end += 1;
|
||
}
|
||
if (end === offset) break;
|
||
chunks.push(normalized.slice(offset, end));
|
||
offset = end;
|
||
}
|
||
return chunks.length > 0 ? chunks : ['我先收到了,但这次没有生成可发送的文本结果。'];
|
||
}
|
||
|
||
export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES };
|
||
|
||
function stripInternalWechatUsername(text) {
|
||
return String(text ?? '').replace(/^\s*wx_[a-z0-9_]{4,64}\s*[,,、::]\s*/i, '');
|
||
}
|
||
|
||
function convertMarkdownLinks(text) {
|
||
return String(text ?? '').replace(/\[([^\]\n]{1,160})\]\((https?:\/\/[^\s)]+)\)/g, (_match, label, url) => {
|
||
const cleanLabel = String(label ?? '').trim();
|
||
const cleanUrl = String(url ?? '').trim();
|
||
return cleanLabel ? `${cleanLabel}\n${cleanUrl}` : cleanUrl;
|
||
});
|
||
}
|
||
|
||
function splitMarkdownTableRow(line) {
|
||
const trimmed = String(line ?? '').trim();
|
||
if (!trimmed.includes('|')) return null;
|
||
return trimmed
|
||
.replace(/^\|/, '')
|
||
.replace(/\|$/, '')
|
||
.split('|')
|
||
.map((cell) => cell.trim());
|
||
}
|
||
|
||
function isMarkdownTableSeparator(line) {
|
||
const cells = splitMarkdownTableRow(line);
|
||
return Boolean(cells?.length) && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
|
||
}
|
||
|
||
function convertMarkdownTables(text) {
|
||
const lines = String(text ?? '').split('\n');
|
||
const output = [];
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const headers = splitMarkdownTableRow(lines[index]);
|
||
if (!headers || !isMarkdownTableSeparator(lines[index + 1])) {
|
||
output.push(lines[index]);
|
||
continue;
|
||
}
|
||
|
||
const readableRows = [];
|
||
index += 2;
|
||
while (index < lines.length) {
|
||
const cells = splitMarkdownTableRow(lines[index]);
|
||
if (!cells) break;
|
||
const parts = cells
|
||
.map((cell, cellIndex) => {
|
||
const header = headers[cellIndex] || `列${cellIndex + 1}`;
|
||
return cell ? `${header}:${cell}` : '';
|
||
})
|
||
.filter(Boolean);
|
||
if (parts.length) readableRows.push(`- ${parts.join(';')}`);
|
||
index += 1;
|
||
}
|
||
index -= 1;
|
||
output.push(...readableRows);
|
||
}
|
||
return output.join('\n');
|
||
}
|
||
|
||
function formatWechatOutboundText(text) {
|
||
return stripInternalWechatUsername(
|
||
convertMarkdownTables(convertMarkdownLinks(text))
|
||
.replace(/^\s*#{1,6}\s+/gm, '')
|
||
.replace(/^\s*---+\s*$/gm, '')
|
||
.replace(/\n{3,}/g, '\n\n'),
|
||
);
|
||
}
|
||
|
||
function isQuestionStatusProbe(text) {
|
||
return /^[??]+$/.test(String(text ?? '').trim());
|
||
}
|
||
|
||
function isSimpleGreeting(text) {
|
||
const normalized = String(text ?? '')
|
||
.trim()
|
||
.replace(/[!!。.\s]+$/g, '')
|
||
.toLowerCase();
|
||
return /^(你好|您好|在吗|在不在|嗨|hi|hello|hey)$/.test(normalized);
|
||
}
|
||
|
||
function isConnectivityTest(text) {
|
||
const normalized = String(text ?? '')
|
||
.trim()
|
||
.replace(/[!!。.\s]+$/g, '');
|
||
return /^(测试\s*\d*|test\s*\d*)$/i.test(normalized);
|
||
}
|
||
|
||
function isTopicResetIntent(text) {
|
||
const normalized = String(text ?? '').trim();
|
||
return (
|
||
/^(换(个)?话题|新问题|忽略之前|不管之前|重新开始|reset)$/i.test(normalized) ||
|
||
/忽略.*之前|不要管.*之前|别管.*之前/.test(normalized)
|
||
);
|
||
}
|
||
|
||
function buildWechatAgentPrompt(text) {
|
||
const content = String(text ?? '').trim();
|
||
return [
|
||
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
|
||
'若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。',
|
||
'',
|
||
`用户消息:${content}`,
|
||
].join('\n');
|
||
}
|
||
|
||
function buildConnectivityTestReply(user) {
|
||
const name = resolveWechatAddressName(user);
|
||
return name
|
||
? `${name},公众号消息通道正常,我收到你的测试了。`
|
||
: '公众号消息通道正常,我收到你的测试了。';
|
||
}
|
||
|
||
function normalizeWechatName(value) {
|
||
const name = String(value ?? '').trim();
|
||
if (!name || /^wx_[a-z0-9_]{4,64}$/i.test(name)) return '';
|
||
return name;
|
||
}
|
||
|
||
function resolveWechatAddressName(user) {
|
||
return (
|
||
normalizeWechatName(user?.nickname) ||
|
||
normalizeWechatName(user?.displayName) ||
|
||
normalizeWechatName(user?.username) ||
|
||
''
|
||
);
|
||
}
|
||
|
||
function buildGreetingText(user) {
|
||
const name = resolveWechatAddressName(user);
|
||
return name ? `你好,${name}!我在呢,有什么需要?` : '你好!我在呢,有什么需要?';
|
||
}
|
||
|
||
function buildStatusText(user, fallbackText) {
|
||
const name = resolveWechatAddressName(user);
|
||
if (!name) return fallbackText;
|
||
return `我在这边,${name}。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。`;
|
||
}
|
||
|
||
function successResponse(task = null) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'text/plain; charset=utf-8',
|
||
body: 'success',
|
||
...(task ? { task } : {}),
|
||
};
|
||
}
|
||
|
||
export function loadWechatMpConfig(env = process.env) {
|
||
const appId = env.H5_WECHAT_MP_APP_ID?.trim() ?? env.H5_WECHAT_APP_ID?.trim() ?? '';
|
||
const appSecret =
|
||
env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? '';
|
||
const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? '';
|
||
const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? '';
|
||
const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1';
|
||
const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login';
|
||
return {
|
||
enabled: enabledFlag && Boolean(appId && appSecret && token && publicBaseUrl),
|
||
appId,
|
||
appSecret,
|
||
token,
|
||
publicBaseUrl,
|
||
bindPath,
|
||
ackText: env.H5_WECHAT_MP_ACK_TEXT?.trim() || DEFAULT_ACK_TEXT,
|
||
progressText: env.H5_WECHAT_MP_PROGRESS_TEXT?.trim() || DEFAULT_PROGRESS_TEXT,
|
||
progressDelayMs: Math.max(
|
||
0,
|
||
Number(env.H5_WECHAT_MP_PROGRESS_DELAY_MS ?? DEFAULT_PROGRESS_DELAY_MS),
|
||
),
|
||
statusText: env.H5_WECHAT_MP_STATUS_TEXT?.trim() || DEFAULT_STATUS_TEXT,
|
||
unsupportedText: env.H5_WECHAT_MP_UNSUPPORTED_TEXT?.trim() || DEFAULT_UNSUPPORTED_TEXT,
|
||
unboundTextPrefix: env.H5_WECHAT_MP_UNBOUND_TEXT_PREFIX?.trim() || DEFAULT_UNBOUND_TEXT,
|
||
tokenUrl: env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_WECHAT_TOKEN_URL,
|
||
customerServiceUrl:
|
||
env.H5_WECHAT_MP_CUSTOMER_SERVICE_URL?.trim() || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
||
jsapiTicketUrl:
|
||
env.H5_WECHAT_MP_JSAPI_TICKET_URL?.trim() ||
|
||
deriveWechatEndpointFromUrl(env.H5_WECHAT_MP_TOKEN_URL?.trim(), '/cgi-bin/ticket/getticket') ||
|
||
DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
||
};
|
||
}
|
||
|
||
export function verifyWechatMpSignature({ token, timestamp, nonce, signature }) {
|
||
const digest = crypto
|
||
.createHash('sha1')
|
||
.update([token, timestamp, nonce].sort().join(''))
|
||
.digest('hex');
|
||
return digest === String(signature ?? '');
|
||
}
|
||
|
||
export function buildWechatTextReply({
|
||
toUserName,
|
||
fromUserName,
|
||
content,
|
||
createTime = Math.floor(Date.now() / 1000),
|
||
}) {
|
||
return [
|
||
'<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,
|
||
sessionApiFetch = null,
|
||
scheduleService = null,
|
||
wechatFetch = undiciFetch,
|
||
logger = console,
|
||
}) {
|
||
if (!config?.enabled) {
|
||
return {
|
||
enabled: false,
|
||
};
|
||
}
|
||
config = {
|
||
...config,
|
||
tokenUrl: config.tokenUrl || DEFAULT_WECHAT_TOKEN_URL,
|
||
customerServiceUrl: config.customerServiceUrl || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
||
jsapiTicketUrl: config.jsapiTicketUrl || DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
||
progressText: config.progressText ?? DEFAULT_PROGRESS_TEXT,
|
||
progressDelayMs: Math.max(0, Number(config.progressDelayMs ?? DEFAULT_PROGRESS_DELAY_MS)),
|
||
statusText: config.statusText || DEFAULT_STATUS_TEXT,
|
||
};
|
||
|
||
let accessTokenCache = {
|
||
token: null,
|
||
expiresAt: 0,
|
||
};
|
||
let jsapiTicketCache = {
|
||
ticket: null,
|
||
expiresAt: 0,
|
||
};
|
||
|
||
const fetchForSession = (sessionId, pathname, init) =>
|
||
sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch(pathname, init);
|
||
|
||
const buildBindUrl = () => {
|
||
if (/^https?:\/\//i.test(config.bindPath)) return config.bindPath;
|
||
const path = config.bindPath.startsWith('/') ? config.bindPath : `/${config.bindPath}`;
|
||
return `${config.publicBaseUrl}${path}`;
|
||
};
|
||
|
||
const verifyRequest = (query = {}) =>
|
||
verifyWechatMpSignature({
|
||
token: config.token,
|
||
timestamp: String(query.timestamp ?? ''),
|
||
nonce: String(query.nonce ?? ''),
|
||
signature: String(query.signature ?? ''),
|
||
});
|
||
|
||
const getStableAccessToken = async () => {
|
||
if (accessTokenCache.token && accessTokenCache.expiresAt > Date.now() + 60_000) {
|
||
return accessTokenCache.token;
|
||
}
|
||
const payload = await readJsonResponse(
|
||
await wechatFetch(config.tokenUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
grant_type: 'client_credential',
|
||
appid: config.appId,
|
||
secret: config.appSecret,
|
||
force_refresh: false,
|
||
}),
|
||
}),
|
||
);
|
||
if (!payload?.access_token) {
|
||
throw new Error(payload?.errmsg || '获取微信 access_token 失败');
|
||
}
|
||
const expiresIn = Number(payload.expires_in ?? 7200);
|
||
accessTokenCache = {
|
||
token: payload.access_token,
|
||
expiresAt: Date.now() + expiresIn * 1000,
|
||
};
|
||
return accessTokenCache.token;
|
||
};
|
||
|
||
const getJsapiTicket = async () => {
|
||
if (jsapiTicketCache.ticket && jsapiTicketCache.expiresAt > Date.now() + 60_000) {
|
||
return jsapiTicketCache.ticket;
|
||
}
|
||
const accessToken = await getStableAccessToken();
|
||
const url = new URL(config.jsapiTicketUrl);
|
||
url.searchParams.set('access_token', accessToken);
|
||
url.searchParams.set('type', 'jsapi');
|
||
const payload = await readJsonResponse(
|
||
await wechatFetch(url.toString(), {
|
||
method: 'GET',
|
||
headers: { Accept: 'application/json' },
|
||
}),
|
||
);
|
||
if (Number(payload?.errcode ?? 0) !== 0 || !payload?.ticket) {
|
||
throw new Error(payload?.errmsg || `获取微信 jsapi_ticket 失败 (${payload?.errcode})`);
|
||
}
|
||
const expiresIn = Number(payload.expires_in ?? 7200);
|
||
jsapiTicketCache = {
|
||
ticket: payload.ticket,
|
||
expiresAt: Date.now() + expiresIn * 1000,
|
||
};
|
||
return jsapiTicketCache.ticket;
|
||
};
|
||
|
||
const createJsSdkSignature = async (pageUrl) => {
|
||
const normalizedUrl = String(pageUrl ?? '').split('#')[0];
|
||
if (!/^https?:\/\//i.test(normalizedUrl)) {
|
||
throw new Error('签名 URL 必须是完整 http(s) 地址');
|
||
}
|
||
const ticket = await getJsapiTicket();
|
||
const nonceStr = crypto.randomBytes(12).toString('hex');
|
||
const timestamp = Math.floor(Date.now() / 1000);
|
||
const signature = crypto
|
||
.createHash('sha1')
|
||
.update(
|
||
[
|
||
`jsapi_ticket=${ticket}`,
|
||
`noncestr=${nonceStr}`,
|
||
`timestamp=${timestamp}`,
|
||
`url=${normalizedUrl}`,
|
||
].join('&'),
|
||
)
|
||
.digest('hex');
|
||
return {
|
||
appId: config.appId,
|
||
timestamp,
|
||
nonceStr,
|
||
signature,
|
||
url: normalizedUrl,
|
||
jsApiList: ['startRecord', 'stopRecord', 'onVoiceRecordEnd', 'translateVoice'],
|
||
};
|
||
};
|
||
|
||
const sendCustomerServiceText = async (openid, content) => {
|
||
const chunks = splitWechatText(formatWechatOutboundText(content));
|
||
const accessToken = await getStableAccessToken();
|
||
for (const chunk of chunks) {
|
||
const payload = await readJsonResponse(
|
||
await wechatFetch(
|
||
`${config.customerServiceUrl}?access_token=${encodeURIComponent(accessToken)}`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
touser: openid,
|
||
msgtype: 'text',
|
||
text: {
|
||
content: chunk,
|
||
},
|
||
}),
|
||
},
|
||
),
|
||
);
|
||
if (Number(payload?.errcode ?? 0) !== 0) {
|
||
throw new Error(payload?.errmsg || `微信客服消息发送失败 (${payload?.errcode})`);
|
||
}
|
||
}
|
||
};
|
||
|
||
const sendTextToUser = async (userId, content) => {
|
||
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
|
||
if (!openid) {
|
||
throw new Error('用户尚未绑定服务号,无法推送提醒');
|
||
}
|
||
await sendCustomerServiceText(openid, content);
|
||
};
|
||
|
||
const ensureWechatAgentSession = async ({
|
||
userId,
|
||
openid,
|
||
forceNew = false,
|
||
userContext = null,
|
||
}) => {
|
||
if (forceNew) {
|
||
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
||
}
|
||
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
|
||
if (existingRoute?.agentSessionId) {
|
||
return existingRoute.agentSessionId;
|
||
}
|
||
|
||
const gate = await userAuth.canUseChat(userId);
|
||
if (!gate.ok) {
|
||
throw new Error(gate.message || '当前用户无法使用聊天能力');
|
||
}
|
||
|
||
const workingDir = await userAuth.resolveWorkingDir(userId);
|
||
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
|
||
const publishLayout = await userAuth.getUserPublishLayout(userId);
|
||
const started = await readJsonResponse(
|
||
await apiFetch('/agent/start', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
working_dir: workingDir,
|
||
enable_context_memory: sessionPolicy.enableContextMemory,
|
||
...(sessionPolicy.extensionOverrides
|
||
? { extension_overrides: sessionPolicy.extensionOverrides }
|
||
: {}),
|
||
}),
|
||
}),
|
||
);
|
||
const sessionId = started?.id;
|
||
if (!sessionId) {
|
||
throw new Error('公众号专属 Agent 会话创建失败');
|
||
}
|
||
|
||
await userAuth.registerAgentSession(userId, sessionId);
|
||
const addressName = resolveWechatAddressName(userContext);
|
||
await reconcileAgentSession(
|
||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||
sessionId,
|
||
{
|
||
workingDir,
|
||
sessionPolicy,
|
||
sandboxConstraints: publishLayout?.constraints ?? null,
|
||
userContext: publishLayout
|
||
? {
|
||
userId,
|
||
displayName: addressName || publishLayout.displayName,
|
||
username: publishLayout.username,
|
||
slug: publishLayout.slug,
|
||
}
|
||
: null,
|
||
tolerateInvalidWorkingDir: true,
|
||
},
|
||
);
|
||
await userAuth.upsertWechatAgentRoute({
|
||
userId,
|
||
appId: config.appId,
|
||
openid,
|
||
agentSessionId: sessionId,
|
||
});
|
||
return sessionId;
|
||
};
|
||
|
||
const rememberWechatUserContext = async (sessionId, user) => {
|
||
const addressName = resolveWechatAddressName(user);
|
||
if (!addressName) return;
|
||
try {
|
||
await readJsonResponse(
|
||
await fetchForSession(sessionId, '/agent/harness_remember', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
sessionId,
|
||
title: '微信服务号用户',
|
||
content: [
|
||
`当前服务号用户名称:${addressName}`,
|
||
'这是通过微信 openid 在 H5 绑定库中查询到的用户名称。',
|
||
'回复时可以自然使用该名称称呼用户,不要使用 openid 或 wx_ 开头的内部用户名。',
|
||
].join('\n'),
|
||
}),
|
||
}),
|
||
);
|
||
await readJsonResponse(
|
||
await fetchForSession(sessionId, '/agent/harness_bootstrap', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ sessionId, force: true }),
|
||
}),
|
||
);
|
||
} catch (err) {
|
||
logger.warn?.('WeChat MP user context remember failed:', err);
|
||
}
|
||
};
|
||
|
||
const runTextMessage = async ({ inbound, user }) => {
|
||
const forceNew = isTopicResetIntent(inbound.content);
|
||
let sessionId = await ensureWechatAgentSession({
|
||
userId: user.userId,
|
||
openid: inbound.fromUserName,
|
||
userContext: user,
|
||
forceNew,
|
||
});
|
||
await rememberWechatUserContext(sessionId, user);
|
||
let finished = false;
|
||
let progressTimer = null;
|
||
if (config.progressDelayMs > 0 && config.progressText) {
|
||
progressTimer = setTimeout(() => {
|
||
if (finished) return;
|
||
sendCustomerServiceText(inbound.fromUserName, config.progressText).catch((err) => {
|
||
logger.warn?.('WeChat MP progress reply failed:', err);
|
||
});
|
||
}, config.progressDelayMs);
|
||
progressTimer.unref?.();
|
||
}
|
||
|
||
const requestId = crypto.randomUUID();
|
||
try {
|
||
const reply = await executeSessionReply(
|
||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||
sessionId,
|
||
requestId,
|
||
buildWechatAgentPrompt(inbound.content),
|
||
);
|
||
if (reply.tokenState) {
|
||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
|
||
}
|
||
await sendCustomerServiceText(inbound.fromUserName, reply.text);
|
||
return { sessionId };
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
const mayBeStaleSession =
|
||
/403|404|not found|无权访问|session/i.test(message) && sessionId;
|
||
if (mayBeStaleSession) {
|
||
sessionId = await ensureWechatAgentSession({
|
||
userId: user.userId,
|
||
openid: inbound.fromUserName,
|
||
forceNew: true,
|
||
userContext: user,
|
||
});
|
||
await rememberWechatUserContext(sessionId, user);
|
||
const retryId = crypto.randomUUID();
|
||
const reply = await executeSessionReply(
|
||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||
sessionId,
|
||
retryId,
|
||
buildWechatAgentPrompt(inbound.content),
|
||
);
|
||
if (reply.tokenState) {
|
||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
|
||
}
|
||
await sendCustomerServiceText(inbound.fromUserName, reply.text);
|
||
return { sessionId };
|
||
}
|
||
throw err;
|
||
} finally {
|
||
finished = true;
|
||
if (progressTimer) clearTimeout(progressTimer);
|
||
}
|
||
};
|
||
|
||
const handleScheduleIntent = async ({ inbound, user }) => {
|
||
if (!scheduleService) return null;
|
||
const intent = parseScheduleIntent(inbound.content);
|
||
if (!isScheduleIntent(intent)) return null;
|
||
|
||
if (intent.action === 'create_daily_todo_digest') {
|
||
if (intent.needsClarification?.includes('digest_time')) {
|
||
return '可以。你想每天几点收到当天待办记录?比如“每天早上 7 点发给我”。';
|
||
}
|
||
const subscription = await scheduleService.createDailyTodoDigest({
|
||
userId: user.userId,
|
||
hour: intent.hour,
|
||
minute: intent.minute,
|
||
timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
||
channel: 'wechat',
|
||
sourceChannel: 'wechat',
|
||
sourceMessageId: inbound.msgId || null,
|
||
sourceText: inbound.content,
|
||
});
|
||
const minuteText = subscription.minute === 0 ? '' : `${String(subscription.minute).padStart(2, '0')}分`;
|
||
return `已设置:我会每天早上 ${subscription.hour}点${minuteText} 通过服务号把当天待办记录发给你。`;
|
||
}
|
||
|
||
if (intent.action === 'query_schedule') {
|
||
const text = await scheduleService.buildTodoDigestText({
|
||
userId: user.userId,
|
||
timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
||
});
|
||
return text;
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const handleInboundMessage = async (bodyText, query = {}) => {
|
||
if (!verifyRequest(query)) {
|
||
return { ok: false, status: 403, body: 'invalid signature' };
|
||
}
|
||
const inbound = parseWechatMessage(String(bodyText ?? ''));
|
||
if (!inbound.fromUserName || !inbound.toUserName || !inbound.msgType) {
|
||
return { ok: false, status: 400, body: 'invalid xml' };
|
||
}
|
||
if (String(query.encrypt_type ?? '').toLowerCase() === 'aes' || inbound.encrypt) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: '当前公众号回调需使用明文模式接入,请联系管理员切换后再试。',
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (inbound.msgType === 'event' && inbound.event === 'subscribe') {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: `${config.unboundTextPrefix}\n${buildBindUrl()}`,
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (inbound.msgType !== 'text' || !inbound.content.trim()) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: config.unsupportedText,
|
||
}),
|
||
};
|
||
}
|
||
|
||
const boundUser = await userAuth.findWechatUserByOpenid(config.appId, inbound.fromUserName);
|
||
if (!boundUser) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: `${config.unboundTextPrefix}\n${buildBindUrl()}`,
|
||
}),
|
||
};
|
||
}
|
||
if (boundUser.status === 'disabled') {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: '当前账号不可用,请联系管理员处理。',
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (isQuestionStatusProbe(inbound.content)) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: buildStatusText(boundUser, config.statusText),
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (isSimpleGreeting(inbound.content)) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: buildGreetingText(boundUser),
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (isConnectivityTest(inbound.content)) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: buildConnectivityTestReply(boundUser),
|
||
}),
|
||
};
|
||
}
|
||
|
||
if (inbound.msgId && typeof userAuth.recordWechatMpMessage === 'function') {
|
||
const recorded = await userAuth.recordWechatMpMessage({
|
||
appId: config.appId,
|
||
openid: inbound.fromUserName,
|
||
msgId: inbound.msgId,
|
||
});
|
||
if (!recorded?.inserted) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: buildStatusText(boundUser, config.statusText),
|
||
}),
|
||
};
|
||
}
|
||
}
|
||
|
||
const scheduleReply = await handleScheduleIntent({ inbound, user: boundUser });
|
||
if (scheduleReply) {
|
||
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
|
||
await userAuth.finishWechatMpMessage({
|
||
appId: config.appId,
|
||
openid: inbound.fromUserName,
|
||
msgId: inbound.msgId,
|
||
status: 'done',
|
||
agentSessionId: null,
|
||
});
|
||
}
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: scheduleReply,
|
||
}),
|
||
};
|
||
}
|
||
|
||
const task = runTextMessage({
|
||
inbound,
|
||
user: boundUser,
|
||
})
|
||
.then(async ({ sessionId } = {}) => {
|
||
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
|
||
await userAuth.finishWechatMpMessage({
|
||
appId: config.appId,
|
||
openid: inbound.fromUserName,
|
||
msgId: inbound.msgId,
|
||
status: 'done',
|
||
agentSessionId: sessionId,
|
||
});
|
||
}
|
||
})
|
||
.catch(async (err) => {
|
||
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
|
||
await userAuth.finishWechatMpMessage({
|
||
appId: config.appId,
|
||
openid: inbound.fromUserName,
|
||
msgId: inbound.msgId,
|
||
status: 'failed',
|
||
}).catch(() => {});
|
||
}
|
||
logger.error?.('WeChat MP background reply failed:', err);
|
||
return sendCustomerServiceText(
|
||
inbound.fromUserName,
|
||
`这次转发到专属 Agent 失败了:${err instanceof Error ? err.message : String(err)}`,
|
||
).catch(() => {});
|
||
});
|
||
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
contentType: 'application/xml; charset=utf-8',
|
||
body: buildWechatTextReply({
|
||
toUserName: inbound.fromUserName,
|
||
fromUserName: inbound.toUserName,
|
||
content: config.ackText,
|
||
}),
|
||
task,
|
||
};
|
||
};
|
||
|
||
const getRouteStatusForUser = async (userId) => {
|
||
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
|
||
if (!openid) {
|
||
return {
|
||
enabled: true,
|
||
bound: false,
|
||
appId: config.appId,
|
||
openid: null,
|
||
agentSessionId: null,
|
||
routeStatus: null,
|
||
updatedAt: null,
|
||
};
|
||
}
|
||
const route = await userAuth.getWechatAgentRoute(config.appId, openid);
|
||
return {
|
||
enabled: true,
|
||
bound: true,
|
||
appId: config.appId,
|
||
openid,
|
||
agentSessionId: route?.agentSessionId ?? null,
|
||
routeStatus: route?.status ?? null,
|
||
updatedAt: route?.updatedAt ?? null,
|
||
};
|
||
};
|
||
|
||
const recreateRouteForUser = async (userId) => {
|
||
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
|
||
if (!openid) {
|
||
return {
|
||
ok: false,
|
||
message: '当前账号尚未绑定该服务号',
|
||
};
|
||
}
|
||
const sessionId = await ensureWechatAgentSession({
|
||
userId,
|
||
openid,
|
||
forceNew: true,
|
||
});
|
||
const route = await userAuth.getWechatAgentRoute(config.appId, openid);
|
||
return {
|
||
ok: true,
|
||
route: {
|
||
enabled: true,
|
||
bound: true,
|
||
appId: config.appId,
|
||
openid,
|
||
agentSessionId: sessionId,
|
||
routeStatus: route?.status ?? 'active',
|
||
updatedAt: route?.updatedAt ?? null,
|
||
},
|
||
};
|
||
};
|
||
|
||
return {
|
||
enabled: true,
|
||
verifyRequest,
|
||
handleInboundMessage,
|
||
getRouteStatusForUser,
|
||
recreateRouteForUser,
|
||
createJsSdkSignature,
|
||
sendTextToUser,
|
||
};
|
||
}
|