merge: add WeChat media analysis gray rollout
Memind CI / Test, build, and release guards (push) Successful in 2m35s
Memind CI / Test, build, and release guards (push) Successful in 2m35s
This commit is contained in:
@@ -9,12 +9,19 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const DEFAULT_WECHAT_MEDIA_URL = 'https://api.weixin.qq.com/cgi-bin/media/get';
|
||||
const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024;
|
||||
const ALLOWED_IMAGE_MIME_TYPES = new Map([
|
||||
['image/jpeg', 'jpg'],
|
||||
['image/png', 'png'],
|
||||
['image/webp', 'webp'],
|
||||
['image/gif', 'gif'],
|
||||
]);
|
||||
const ALLOWED_ATTACHMENT_EXTENSIONS = new Map([
|
||||
['.doc', 'application/msword'],
|
||||
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
['.xls', 'application/vnd.ms-excel'],
|
||||
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
]);
|
||||
|
||||
function resolveImageExtension(contentType = '', fallbackUrl = '') {
|
||||
const normalized = String(contentType ?? '')
|
||||
@@ -44,6 +51,32 @@ function ensureImageWithinLimit(buffer, maxBytes) {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAttachmentFilename(filename = '') {
|
||||
const basename = path.basename(String(filename ?? '').trim()).replace(/[\u0000-\u001f\u007f]/g, '');
|
||||
if (!basename || basename === '.' || basename === '..') {
|
||||
throw new Error('微信文件缺少有效文件名');
|
||||
}
|
||||
const extension = path.extname(basename).toLowerCase();
|
||||
const mimeType = ALLOWED_ATTACHMENT_EXTENSIONS.get(extension);
|
||||
if (!mimeType) {
|
||||
throw new Error('当前服务号文件仅支持 Word(doc/docx)和 Excel(xls/xlsx)');
|
||||
}
|
||||
return {
|
||||
filename: basename.slice(0, 160),
|
||||
extension,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureAttachmentWithinLimit(buffer, maxBytes) {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
||||
throw new Error('微信文件内容为空');
|
||||
}
|
||||
if (buffer.length > maxBytes) {
|
||||
throw new Error(`文件超过大小限制(${maxBytes} bytes)`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadTemporaryMedia(accessToken, mediaId, { wechatFetch = undiciFetch } = {}) {
|
||||
if (!accessToken) throw new Error('缺少微信 access_token');
|
||||
if (!mediaId) throw new Error('缺少微信 mediaId');
|
||||
@@ -154,3 +187,58 @@ export async function persistWechatImage(
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistWechatAttachment(
|
||||
{
|
||||
userId,
|
||||
appId,
|
||||
openid,
|
||||
msgId,
|
||||
mediaId,
|
||||
filename,
|
||||
publicBaseUrl,
|
||||
maxFileBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
|
||||
},
|
||||
{
|
||||
wechatFetch = undiciFetch,
|
||||
accessToken,
|
||||
h5Root = __dirname,
|
||||
} = {},
|
||||
) {
|
||||
if (!userId) throw new Error('缺少 userId');
|
||||
const resolved = sanitizeAttachmentFilename(filename);
|
||||
const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch });
|
||||
ensureAttachmentWithinLimit(downloaded.buffer, maxFileBytes);
|
||||
|
||||
const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, 'wechat-mp');
|
||||
fs.mkdirSync(publishDir, { recursive: true });
|
||||
|
||||
const timestamp = Date.now();
|
||||
const hash = crypto.createHash('sha1').update(downloaded.buffer).digest('hex').slice(0, 12);
|
||||
const originalStem = path.basename(resolved.filename, resolved.extension)
|
||||
.replace(/[^\p{L}\p{N}._-]+/gu, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 80) || 'attachment';
|
||||
const identity = [appId || 'wx', openid || 'openid', msgId || timestamp, mediaId || hash]
|
||||
.filter(Boolean)
|
||||
.join('-')
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
||||
.slice(0, 100);
|
||||
const publicFilename = `${originalStem}-${identity}-${hash}${resolved.extension}`;
|
||||
const absolutePath = path.join(publishDir, publicFilename);
|
||||
fs.writeFileSync(absolutePath, downloaded.buffer);
|
||||
|
||||
return {
|
||||
absolutePath,
|
||||
bytes: downloaded.buffer.length,
|
||||
contentType: resolved.mimeType,
|
||||
filename: resolved.filename,
|
||||
publicFilename,
|
||||
publicUrl: buildWechatImagePublicUrl({
|
||||
publicBaseUrl,
|
||||
publishKey: String(userId),
|
||||
filename: publicFilename,
|
||||
}),
|
||||
source: 'wechat_media',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ function deriveWechatEndpointFromUrl(baseUrl, suffix) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseCsvList(value = '') {
|
||||
return String(value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function loadWechatMpConfig(env = process.env) {
|
||||
const appId = env.H5_WECHAT_MP_APP_ID?.trim() ?? env.H5_WECHAT_APP_ID?.trim() ?? '';
|
||||
const appSecret =
|
||||
@@ -67,10 +74,13 @@ export function loadWechatMpConfig(env = process.env) {
|
||||
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)),
|
||||
maxFileBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_FILE_BYTES ?? 30 * 1024 * 1024)),
|
||||
acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0',
|
||||
acceptImage: env.H5_WECHAT_MP_ACCEPT_IMAGE !== '0',
|
||||
acceptFile: env.H5_WECHAT_MP_ACCEPT_FILE !== '0',
|
||||
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
||||
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
|
||||
mediaAnalysisGrayUsers: parseCsvList(env.H5_WECHAT_MP_MEDIA_GRAY_USERS),
|
||||
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
+144
-13
@@ -9,7 +9,11 @@ import { resolveSessionAccess } from './session-broker.mjs';
|
||||
import { isStubPublicHtmlContent, materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs';
|
||||
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
|
||||
import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
||||
import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs';
|
||||
import {
|
||||
downloadTemporaryMedia,
|
||||
persistWechatAttachment,
|
||||
persistWechatImage,
|
||||
} from './wechat-media.mjs';
|
||||
import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs';
|
||||
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
||||
import { guardScheduleConfirmationReply } from './wechat/handlers/schedule-guard.mjs';
|
||||
@@ -100,6 +104,8 @@ function parseWechatMessage(xml) {
|
||||
description: parseXmlField(xml, 'Description'),
|
||||
url: parseXmlField(xml, 'Url'),
|
||||
thumbMediaId: parseXmlField(xml, 'ThumbMediaId'),
|
||||
fileName: parseXmlField(xml, 'FileName') || parseXmlField(xml, 'Filename'),
|
||||
fileSize: parseXmlField(xml, 'FileSize'),
|
||||
event: parseXmlField(xml, 'Event').toLowerCase(),
|
||||
eventKey: parseXmlField(xml, 'EventKey'),
|
||||
latitude: parseXmlField(xml, 'Latitude'),
|
||||
@@ -1164,6 +1170,23 @@ function normalizeNumber(value) {
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function isWechatMediaGrayUser(user, configuredUsers = []) {
|
||||
const allowlist = Array.isArray(configuredUsers)
|
||||
? configuredUsers.map((value) => String(value ?? '').trim().toLowerCase()).filter(Boolean)
|
||||
: [];
|
||||
if (allowlist.length === 0) return false;
|
||||
const identities = [
|
||||
user?.userId,
|
||||
user?.username,
|
||||
user?.slug,
|
||||
user?.displayName,
|
||||
user?.nickname,
|
||||
]
|
||||
.map((value) => String(value ?? '').trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return identities.some((identity) => allowlist.includes(identity));
|
||||
}
|
||||
|
||||
function normalizeWechatInboundIntent(inbound) {
|
||||
const msgType = String(inbound?.msgType ?? '').toLowerCase();
|
||||
const base = {
|
||||
@@ -1210,6 +1233,22 @@ function normalizeWechatInboundIntent(inbound) {
|
||||
};
|
||||
}
|
||||
|
||||
if (msgType === 'file') {
|
||||
const filename = String(inbound?.fileName ?? '').trim();
|
||||
return {
|
||||
...base,
|
||||
displayText: filename ? `收到文件:${filename}` : '收到文件',
|
||||
agentText: '',
|
||||
media: {
|
||||
mediaId: inbound?.mediaId || '',
|
||||
},
|
||||
attachment: {
|
||||
filename,
|
||||
sizeBytes: normalizeNumber(inbound?.fileSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (msgType === 'location') {
|
||||
const latitude = normalizeNumber(inbound?.locationX);
|
||||
const longitude = normalizeNumber(inbound?.locationY);
|
||||
@@ -1434,10 +1473,15 @@ export function createWechatMpService({
|
||||
jsapiTicketUrl: config.jsapiTicketUrl || DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
||||
mediaPublicBaseUrl: config.mediaPublicBaseUrl || config.publicBaseUrl,
|
||||
maxImageBytes: Math.max(1, Number(config.maxImageBytes ?? 10 * 1024 * 1024)),
|
||||
maxFileBytes: Math.max(1, Number(config.maxFileBytes ?? 30 * 1024 * 1024)),
|
||||
acceptVoice: config.acceptVoice !== false,
|
||||
acceptImage: config.acceptImage !== false,
|
||||
acceptFile: config.acceptFile !== false,
|
||||
acceptLocation: config.acceptLocation !== false,
|
||||
acceptLink: config.acceptLink !== false,
|
||||
mediaAnalysisGrayUsers: Array.isArray(config.mediaAnalysisGrayUsers)
|
||||
? config.mediaAnalysisGrayUsers
|
||||
: [],
|
||||
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
|
||||
};
|
||||
|
||||
@@ -1872,16 +1916,32 @@ export function createWechatMpService({
|
||||
}
|
||||
};
|
||||
|
||||
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 buildIntentMetadata = (intent, { mediaAnalysisEnabled = false } = {}) => {
|
||||
const mediaPublicUrl = intent.media?.publicUrl || null;
|
||||
const fileAttachment =
|
||||
intent.msgType === 'file' && mediaPublicUrl && intent.attachment?.filename
|
||||
? {
|
||||
assetId: '',
|
||||
downloadUrl: mediaPublicUrl,
|
||||
filename: intent.attachment.filename,
|
||||
mimeType: intent.attachment.mimeType || 'application/octet-stream',
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
source: 'wechat_mp',
|
||||
msgType: intent.msgType,
|
||||
originalMsgId: intent.msgId || null,
|
||||
displayText: intent.displayText || '',
|
||||
mediaPublicUrl,
|
||||
...(mediaAnalysisEnabled && intent.msgType === 'image' && mediaPublicUrl
|
||||
? { imageUrls: [mediaPublicUrl] }
|
||||
: {}),
|
||||
...(mediaAnalysisEnabled && fileAttachment ? { fileAttachments: [fileAttachment] } : {}),
|
||||
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;
|
||||
@@ -1921,6 +1981,7 @@ export function createWechatMpService({
|
||||
|
||||
const runIntentMessage = async ({ inbound, intent, user }) => {
|
||||
const wechatIntent = classifyWechatIntent(intent);
|
||||
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
|
||||
const resetCandidate =
|
||||
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
||||
// Page Data delivery owns persistent files, datasets and two publication
|
||||
@@ -1970,7 +2031,7 @@ export function createWechatMpService({
|
||||
sessionId,
|
||||
requestId,
|
||||
agentPrompt,
|
||||
buildIntentMetadata(intent),
|
||||
buildIntentMetadata(intent, { mediaAnalysisEnabled }),
|
||||
);
|
||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
|
||||
@@ -2129,7 +2190,7 @@ export function createWechatMpService({
|
||||
sessionId,
|
||||
retryId,
|
||||
retryPrompt,
|
||||
buildIntentMetadata(intent),
|
||||
buildIntentMetadata(intent, { mediaAnalysisEnabled }),
|
||||
);
|
||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
|
||||
@@ -2302,6 +2363,7 @@ export function createWechatMpService({
|
||||
const supportedByConfig =
|
||||
(intent.msgType === 'voice' && config.acceptVoice) ||
|
||||
(intent.msgType === 'image' && config.acceptImage) ||
|
||||
(intent.msgType === 'file' && config.acceptFile) ||
|
||||
(intent.msgType === 'location' && config.acceptLocation) ||
|
||||
(intent.msgType === 'link' && config.acceptLink) ||
|
||||
intent.msgType === 'text' ||
|
||||
@@ -2350,6 +2412,25 @@ export function createWechatMpService({
|
||||
};
|
||||
}
|
||||
|
||||
const mediaAnalysisEnabled = isWechatMediaGrayUser(
|
||||
boundUser,
|
||||
config.mediaAnalysisGrayUsers,
|
||||
);
|
||||
|
||||
if (intent.msgType === 'file' && !mediaAnalysisEnabled) {
|
||||
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: '当前账号尚未开启服务号文件分析灰度,请先通过 H5 上传文件。',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (intent.msgType === 'voice' && !intent.agentText.trim() && intent.media?.mediaId) {
|
||||
try {
|
||||
const fallbackText = await transcribeWechatVoiceMedia(intent.media.mediaId, intent.media.format);
|
||||
@@ -2433,6 +2514,56 @@ export function createWechatMpService({
|
||||
}
|
||||
}
|
||||
|
||||
if (intent.msgType === 'file') {
|
||||
try {
|
||||
const accessToken = await getStableAccessToken();
|
||||
const persisted = await persistWechatAttachment(
|
||||
{
|
||||
userId: boundUser.userId,
|
||||
appId: config.appId,
|
||||
openid: inbound.fromUserName,
|
||||
msgId: inbound.msgId,
|
||||
mediaId: inbound.mediaId,
|
||||
filename: inbound.fileName,
|
||||
publicBaseUrl: config.mediaPublicBaseUrl,
|
||||
maxFileBytes: config.maxFileBytes,
|
||||
},
|
||||
{
|
||||
wechatFetch,
|
||||
accessToken,
|
||||
},
|
||||
);
|
||||
intent.media = {
|
||||
...intent.media,
|
||||
mediaId: inbound.mediaId || intent.media?.mediaId || '',
|
||||
publicUrl: persisted.publicUrl,
|
||||
format: persisted.contentType,
|
||||
source: persisted.source,
|
||||
};
|
||||
intent.attachment = {
|
||||
...intent.attachment,
|
||||
filename: persisted.filename,
|
||||
publicUrl: persisted.publicUrl,
|
||||
mimeType: persisted.contentType,
|
||||
sizeBytes: persisted.bytes,
|
||||
};
|
||||
intent.agentText = `[文件1: ${persisted.filename}]: ${persisted.publicUrl}`;
|
||||
intent.displayText = `文件:${persisted.filename}`;
|
||||
} 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: error instanceof Error ? error.message : '文件处理失败,请稍后重试。',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
intent.msgType === 'location' &&
|
||||
(intent.location?.latitude == null || intent.location?.longitude == null)
|
||||
|
||||
@@ -3686,6 +3686,7 @@ test('wechat mp service persists image and routes image url into agent prompt',
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-image';
|
||||
const prompts = [];
|
||||
const metadataCalls = [];
|
||||
const detailCalls = [];
|
||||
const service = createWechatMpService({
|
||||
config: {
|
||||
@@ -3701,6 +3702,7 @@ test('wechat mp service persists image and routes image url into agent prompt',
|
||||
unboundTextPrefix: '请先绑定',
|
||||
progressDelayMs: 0,
|
||||
maxImageBytes: 1024 * 1024,
|
||||
mediaAnalysisGrayUsers: [testUserId],
|
||||
},
|
||||
userAuth: {
|
||||
async findWechatUserByOpenid() {
|
||||
@@ -3747,6 +3749,7 @@ test('wechat mp service persists image and routes image url into agent prompt',
|
||||
if (pathname === '/sessions/session-1/reply') {
|
||||
const body = JSON.parse(init.body);
|
||||
prompts.push(body.user_message.content[0].text);
|
||||
metadataCalls.push(body.user_message.metadata);
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
@@ -3809,10 +3812,208 @@ test('wechat mp service persists image and routes image url into agent prompt',
|
||||
prompts[0],
|
||||
/\[图片1\]: https:\/\/example\.com\/MindSpace\/test-user-image\/public\/wechat-mp\//,
|
||||
);
|
||||
assert.equal(metadataCalls.length, 1);
|
||||
assert.equal(metadataCalls[0].source, 'wechat_mp');
|
||||
assert.equal(metadataCalls[0].msgType, 'image');
|
||||
assert.equal(metadataCalls[0].imageUrls.length, 1);
|
||||
assert.match(metadataCalls[0].imageUrls[0], /\/public\/wechat-mp\//);
|
||||
assert.equal(detailCalls.length, 1);
|
||||
assert.match(detailCalls[0].mediaPublicUrl, /\/wechat-mp\//);
|
||||
});
|
||||
|
||||
test('wechat mp service persists Word and Excel files in user public area and reuses H5 attachment metadata', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-wechat-file';
|
||||
const userMessages = [];
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
maxFileBytes: 1024 * 1024,
|
||||
acceptFile: true,
|
||||
mediaAnalysisGrayUsers: [testUserId],
|
||||
},
|
||||
userAuth: {
|
||||
async findWechatUserByOpenid() {
|
||||
return { userId: testUserId, status: 'active', nickname: '毕升' };
|
||||
},
|
||||
},
|
||||
sessionApiFetch: async (_sessionId, pathname, init = {}) => {
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","request_id":"req-file","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"文件已解析。"}]}}\n\n',
|
||||
'data: {"type":"Finish","request_id":"req-file","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/sessions/session-1/reply') {
|
||||
const body = JSON.parse(init.body);
|
||||
userMessages.push(body.user_message);
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error(`unexpected api path: ${pathname}`);
|
||||
},
|
||||
wechatFetch: async (url) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/media/get')) {
|
||||
return new Response(Buffer.from('fake-docx-content'), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
crypto.randomUUID = () => 'req-file';
|
||||
try {
|
||||
for (const [index, filename] of ['季度分析.docx', '销售数据.xlsx'].entries()) {
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'file',
|
||||
content: '',
|
||||
extraFields: {
|
||||
MediaId: `file-media-${index + 1}`,
|
||||
FileName: filename,
|
||||
FileSize: 17,
|
||||
},
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
await result.task;
|
||||
const attachment = userMessages.at(-1)?.metadata?.fileAttachments?.[0];
|
||||
const publicFilename = decodeURIComponent(new URL(attachment.downloadUrl).pathname.split('/').at(-1));
|
||||
assert.equal(
|
||||
fs.existsSync(
|
||||
path.join(process.cwd(), 'MindSpace', testUserId, 'public', 'wechat-mp', publicFilename),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(userMessages.length, 2);
|
||||
assert.match(userMessages[0].content[0].text, /【微信服务号文件消息】/);
|
||||
assert.match(userMessages[0].content[0].text, /\[文件1: 季度分析\.docx\]:/);
|
||||
assert.equal(userMessages[0].metadata.msgType, 'file');
|
||||
assert.equal(userMessages[0].metadata.fileAttachments.length, 1);
|
||||
assert.equal(userMessages[0].metadata.fileAttachments[0].filename, '季度分析.docx');
|
||||
assert.equal(
|
||||
userMessages[0].metadata.fileAttachments[0].mimeType,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
);
|
||||
assert.match(userMessages[0].metadata.fileAttachments[0].downloadUrl, /\/public\/wechat-mp\//);
|
||||
assert.match(userMessages[1].content[0].text, /\[文件1: 销售数据\.xlsx\]:/);
|
||||
assert.equal(userMessages[1].metadata.fileAttachments[0].filename, '销售数据.xlsx');
|
||||
assert.equal(
|
||||
userMessages[1].metadata.fileAttachments[0].mimeType,
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
assert.match(userMessages[1].metadata.fileAttachments[0].downloadUrl, /\/public\/wechat-mp\//);
|
||||
});
|
||||
|
||||
test('wechat mp service rejects unsupported public file types without entering the agent flow', async () => {
|
||||
let mediaDownloads = 0;
|
||||
let sessionCalls = 0;
|
||||
const service = createBoundWechatService({
|
||||
config: { acceptFile: true, mediaAnalysisGrayUsers: ['user-1'] },
|
||||
sessionApiFetch: async () => {
|
||||
sessionCalls += 1;
|
||||
throw new Error('session should not be called');
|
||||
},
|
||||
wechatFetch: async (url) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/media/get')) mediaDownloads += 1;
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'file',
|
||||
content: '',
|
||||
extraFields: { MediaId: 'file-media-2', FileName: 'payload.exe' },
|
||||
}),
|
||||
{
|
||||
timestamp: '1710000000',
|
||||
nonce: 'nonce',
|
||||
signature: signatureFor('token', '1710000000', 'nonce'),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 200);
|
||||
assert.match(result.body, /仅支持 Word/);
|
||||
assert.equal(mediaDownloads, 0);
|
||||
assert.equal(sessionCalls, 0);
|
||||
});
|
||||
|
||||
test('wechat mp service keeps file analysis disabled outside the media gray allowlist', async () => {
|
||||
let mediaDownloads = 0;
|
||||
let sessionCalls = 0;
|
||||
const service = createBoundWechatService({
|
||||
config: {
|
||||
acceptFile: true,
|
||||
mediaAnalysisGrayUsers: ['唐'],
|
||||
},
|
||||
sessionApiFetch: async () => {
|
||||
sessionCalls += 1;
|
||||
throw new Error('session should not be called');
|
||||
},
|
||||
wechatFetch: async (url) => {
|
||||
if (String(url).includes('/cgi-bin/media/get')) mediaDownloads += 1;
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'file',
|
||||
content: '',
|
||||
extraFields: { MediaId: 'file-media-gray', FileName: '测试.docx' },
|
||||
}),
|
||||
{
|
||||
timestamp: '1710000000',
|
||||
nonce: 'nonce',
|
||||
signature: signatureFor('token', '1710000000', 'nonce'),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 200);
|
||||
assert.match(result.body, /尚未开启服务号文件分析灰度/);
|
||||
assert.equal(mediaDownloads, 0);
|
||||
assert.equal(sessionCalls, 0);
|
||||
});
|
||||
|
||||
test('wechat mp service accepts full voice xml payload and routes recognition text into agent', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
|
||||
@@ -21,6 +21,12 @@ const ImageBuilder = {
|
||||
build: (ctx) => buildText(TEMPLATES.image, ctx),
|
||||
};
|
||||
|
||||
const FileBuilder = {
|
||||
priority: 80,
|
||||
support: (ctx) => ctx.msgType === 'file',
|
||||
build: (ctx) => buildText(TEMPLATES.file, ctx),
|
||||
};
|
||||
|
||||
const VoiceBuilder = {
|
||||
priority: 80,
|
||||
support: (ctx) => ctx.msgType === 'voice',
|
||||
@@ -54,7 +60,7 @@ const DefaultBuilder = {
|
||||
build: (ctx) => buildText(TEMPLATES.default, ctx),
|
||||
};
|
||||
|
||||
const BUILDERS = [ImageBuilder, VoiceBuilder, LocationBuilder, LinkBuilder, IntentBuilder, DefaultBuilder]
|
||||
const BUILDERS = [ImageBuilder, FileBuilder, VoiceBuilder, LocationBuilder, LinkBuilder, IntentBuilder, DefaultBuilder]
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
export function selectBuilder(ctx) {
|
||||
|
||||
@@ -13,6 +13,7 @@ const INTENT_RULES = [
|
||||
|
||||
export function resolveIntent(msgType, text) {
|
||||
if (msgType === 'image') return { task: 'image_analysis' };
|
||||
if (msgType === 'file') return { task: 'file_analysis' };
|
||||
if (msgType === 'voice') return { task: 'voice_analysis' };
|
||||
if (msgType === 'location') return { task: 'location' };
|
||||
if (msgType === 'link') return { task: 'link' };
|
||||
|
||||
@@ -26,6 +26,11 @@ describe('buildAckText', () => {
|
||||
assert.equal(result, '图片收到,我先看看。');
|
||||
});
|
||||
|
||||
it('file → file template', () => {
|
||||
const result = buildAckText({ intent: intent('file'), nickname: '', config: cfg, fallbackText: '' });
|
||||
assert.equal(result, '文件收到,我先读取分析。');
|
||||
});
|
||||
|
||||
it('voice → voice template', () => {
|
||||
const result = buildAckText({ intent: intent('voice'), nickname: '', config: cfg, fallbackText: '' });
|
||||
assert.equal(result, '收到语音,我先听一下。');
|
||||
|
||||
@@ -12,6 +12,7 @@ export const TEMPLATES = {
|
||||
'让我看看这张图片。',
|
||||
'图片已收到,我来处理。',
|
||||
],
|
||||
file: ['文件收到,我先读取分析。'],
|
||||
voice: [
|
||||
'收到语音,我先听一下。',
|
||||
'听到啦,我马上处理。',
|
||||
|
||||
@@ -81,6 +81,20 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
if (msgType === 'file') {
|
||||
const attachment = intent?.attachment ?? {};
|
||||
return [
|
||||
currentTimeHint,
|
||||
'【微信服务号文件消息】用户发送了需要解析的 Office 文件。',
|
||||
attachment.filename ? `文件名:${attachment.filename}` : '',
|
||||
attachment.publicUrl ? `文件链接:${attachment.publicUrl}` : '',
|
||||
'请复用 H5 附件分析结果回答;Excel 在专用工具可用时必须优先使用 Excel 工具读取完整工作簿。',
|
||||
'',
|
||||
String(agentText).trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
if (msgType === 'location') {
|
||||
const location = intent?.location ?? {};
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user