feat: gate WeChat media analysis by user

This commit is contained in:
john
2026-07-18 17:46:20 +08:00
parent fd3904fdee
commit 94f347398d
3 changed files with 95 additions and 6 deletions
+8
View File
@@ -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() ?? '',
};
}
+47 -5
View File
@@ -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);
+40 -1
View File
@@ -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';