Files
memind/wechat-mp.mjs
T

1023 lines
33 KiB
JavaScript
Raw Normal View History

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,
};
}