From fd3904fdee118b3ac18d4283975da57b92065ce2 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 18 Jul 2026 17:41:32 +0800 Subject: [PATCH 1/2] feat: add WeChat media analysis adapters --- wechat-media.mjs | 88 +++++++++++++++++ wechat-mp-config.mjs | 2 + wechat-mp.mjs | 111 ++++++++++++++++++--- wechat-mp.test.mjs | 162 +++++++++++++++++++++++++++++++ wechat/ack/ack-builders.mjs | 8 +- wechat/ack/ack-intent.mjs | 1 + wechat/ack/ack-provider.test.mjs | 5 + wechat/ack/ack-templates.mjs | 1 + wechat/prompts/chat-general.mjs | 14 +++ 9 files changed, 380 insertions(+), 12 deletions(-) diff --git a/wechat-media.mjs b/wechat-media.mjs index af6616c..3fdef3b 100644 --- a/wechat-media.mjs +++ b/wechat-media.mjs @@ -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', + }; +} diff --git a/wechat-mp-config.mjs b/wechat-mp-config.mjs index 97cf248..68d5192 100644 --- a/wechat-mp-config.mjs +++ b/wechat-mp-config.mjs @@ -67,8 +67,10 @@ 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', encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '', diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 747b24b..67f3684 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -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'), @@ -1210,6 +1216,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,8 +1456,10 @@ 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, asrTarget: config.asrTarget || DEFAULT_ASR_TARGET, @@ -1872,16 +1896,30 @@ 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) => { + 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, + ...(intent.msgType === 'image' && mediaPublicUrl ? { imageUrls: [mediaPublicUrl] } : {}), + ...(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; @@ -2302,6 +2340,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' || @@ -2433,6 +2472,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) diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index e3b6fe0..526ad52 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -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: { @@ -3747,6 +3748,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 +3811,170 @@ 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, + }, + 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 }, + 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 accepts full voice xml payload and routes recognition text into agent', async () => { const token = 'token'; const timestamp = '1710000000'; diff --git a/wechat/ack/ack-builders.mjs b/wechat/ack/ack-builders.mjs index 730b6d5..8bcea9c 100644 --- a/wechat/ack/ack-builders.mjs +++ b/wechat/ack/ack-builders.mjs @@ -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) { diff --git a/wechat/ack/ack-intent.mjs b/wechat/ack/ack-intent.mjs index 8e11e56..c391ce7 100644 --- a/wechat/ack/ack-intent.mjs +++ b/wechat/ack/ack-intent.mjs @@ -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' }; diff --git a/wechat/ack/ack-provider.test.mjs b/wechat/ack/ack-provider.test.mjs index 1bf4520..ff75d88 100644 --- a/wechat/ack/ack-provider.test.mjs +++ b/wechat/ack/ack-provider.test.mjs @@ -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, '收到语音,我先听一下。'); diff --git a/wechat/ack/ack-templates.mjs b/wechat/ack/ack-templates.mjs index 7684106..3954a61 100644 --- a/wechat/ack/ack-templates.mjs +++ b/wechat/ack/ack-templates.mjs @@ -12,6 +12,7 @@ export const TEMPLATES = { '让我看看这张图片。', '图片已收到,我来处理。', ], + file: ['文件收到,我先读取分析。'], voice: [ '收到语音,我先听一下。', '听到啦,我马上处理。', diff --git a/wechat/prompts/chat-general.mjs b/wechat/prompts/chat-general.mjs index 1edb69f..89a4295 100644 --- a/wechat/prompts/chat-general.mjs +++ b/wechat/prompts/chat-general.mjs @@ -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 [ From 94f347398d66964468e992bcdfb9c50bd10c5093 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 18 Jul 2026 17:46:20 +0800 Subject: [PATCH 2/2] feat: gate WeChat media analysis by user --- wechat-mp-config.mjs | 8 +++++++ wechat-mp.mjs | 52 +++++++++++++++++++++++++++++++++++++++----- wechat-mp.test.mjs | 41 +++++++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 6 deletions(-) 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';