diff --git a/wechat-mp-config.mjs b/wechat-mp-config.mjs index 68d5192..02bb0d2 100644 --- a/wechat-mp-config.mjs +++ b/wechat-mp-config.mjs @@ -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 = @@ -73,6 +80,7 @@ export function loadWechatMpConfig(env = process.env) { 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() ?? '', }; } diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 67f3684..0ac0077 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -1170,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 = { @@ -1462,6 +1479,9 @@ export function createWechatMpService({ 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, }; @@ -1896,7 +1916,7 @@ export function createWechatMpService({ } }; - const buildIntentMetadata = (intent) => { + const buildIntentMetadata = (intent, { mediaAnalysisEnabled = false } = {}) => { const mediaPublicUrl = intent.media?.publicUrl || null; const fileAttachment = intent.msgType === 'file' && mediaPublicUrl && intent.attachment?.filename @@ -1913,8 +1933,10 @@ export function createWechatMpService({ originalMsgId: intent.msgId || null, displayText: intent.displayText || '', mediaPublicUrl, - ...(intent.msgType === 'image' && mediaPublicUrl ? { imageUrls: [mediaPublicUrl] } : {}), - ...(fileAttachment ? { fileAttachments: [fileAttachment] } : {}), + ...(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, @@ -1959,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 @@ -2008,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); @@ -2167,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); @@ -2389,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); diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 526ad52..2518c6f 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -3702,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() { @@ -3831,6 +3832,7 @@ test('wechat mp service persists Word and Excel files in user public area and re config: { maxFileBytes: 1024 * 1024, acceptFile: true, + mediaAnalysisGrayUsers: [testUserId], }, userAuth: { async findWechatUserByOpenid() { @@ -3939,7 +3941,7 @@ test('wechat mp service rejects unsupported public file types without entering t let mediaDownloads = 0; let sessionCalls = 0; const service = createBoundWechatService({ - config: { acceptFile: true }, + config: { acceptFile: true, mediaAnalysisGrayUsers: ['user-1'] }, sessionApiFetch: async () => { sessionCalls += 1; throw new Error('session should not be called'); @@ -3975,6 +3977,43 @@ test('wechat mp service rejects unsupported public file types without entering t 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';