feat: gate WeChat media analysis by user
This commit is contained in:
@@ -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) {
|
export function loadWechatMpConfig(env = process.env) {
|
||||||
const appId = env.H5_WECHAT_MP_APP_ID?.trim() ?? env.H5_WECHAT_APP_ID?.trim() ?? '';
|
const appId = env.H5_WECHAT_MP_APP_ID?.trim() ?? env.H5_WECHAT_APP_ID?.trim() ?? '';
|
||||||
const appSecret =
|
const appSecret =
|
||||||
@@ -73,6 +80,7 @@ export function loadWechatMpConfig(env = process.env) {
|
|||||||
acceptFile: env.H5_WECHAT_MP_ACCEPT_FILE !== '0',
|
acceptFile: env.H5_WECHAT_MP_ACCEPT_FILE !== '0',
|
||||||
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
||||||
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '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() ?? '',
|
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-5
@@ -1170,6 +1170,23 @@ function normalizeNumber(value) {
|
|||||||
return Number.isFinite(num) ? num : null;
|
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) {
|
function normalizeWechatInboundIntent(inbound) {
|
||||||
const msgType = String(inbound?.msgType ?? '').toLowerCase();
|
const msgType = String(inbound?.msgType ?? '').toLowerCase();
|
||||||
const base = {
|
const base = {
|
||||||
@@ -1462,6 +1479,9 @@ export function createWechatMpService({
|
|||||||
acceptFile: config.acceptFile !== false,
|
acceptFile: config.acceptFile !== false,
|
||||||
acceptLocation: config.acceptLocation !== false,
|
acceptLocation: config.acceptLocation !== false,
|
||||||
acceptLink: config.acceptLink !== false,
|
acceptLink: config.acceptLink !== false,
|
||||||
|
mediaAnalysisGrayUsers: Array.isArray(config.mediaAnalysisGrayUsers)
|
||||||
|
? config.mediaAnalysisGrayUsers
|
||||||
|
: [],
|
||||||
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
|
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 mediaPublicUrl = intent.media?.publicUrl || null;
|
||||||
const fileAttachment =
|
const fileAttachment =
|
||||||
intent.msgType === 'file' && mediaPublicUrl && intent.attachment?.filename
|
intent.msgType === 'file' && mediaPublicUrl && intent.attachment?.filename
|
||||||
@@ -1913,8 +1933,10 @@ export function createWechatMpService({
|
|||||||
originalMsgId: intent.msgId || null,
|
originalMsgId: intent.msgId || null,
|
||||||
displayText: intent.displayText || '',
|
displayText: intent.displayText || '',
|
||||||
mediaPublicUrl,
|
mediaPublicUrl,
|
||||||
...(intent.msgType === 'image' && mediaPublicUrl ? { imageUrls: [mediaPublicUrl] } : {}),
|
...(mediaAnalysisEnabled && intent.msgType === 'image' && mediaPublicUrl
|
||||||
...(fileAttachment ? { fileAttachments: [fileAttachment] } : {}),
|
? { imageUrls: [mediaPublicUrl] }
|
||||||
|
: {}),
|
||||||
|
...(mediaAnalysisEnabled && fileAttachment ? { fileAttachments: [fileAttachment] } : {}),
|
||||||
recognition: intent.msgType === 'voice' ? intent.agentText || null : null,
|
recognition: intent.msgType === 'voice' ? intent.agentText || null : null,
|
||||||
location: intent.location || null,
|
location: intent.location || null,
|
||||||
link: intent.link || null,
|
link: intent.link || null,
|
||||||
@@ -1959,6 +1981,7 @@ export function createWechatMpService({
|
|||||||
|
|
||||||
const runIntentMessage = async ({ inbound, intent, user }) => {
|
const runIntentMessage = async ({ inbound, intent, user }) => {
|
||||||
const wechatIntent = classifyWechatIntent(intent);
|
const wechatIntent = classifyWechatIntent(intent);
|
||||||
|
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
|
||||||
const resetCandidate =
|
const resetCandidate =
|
||||||
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
||||||
// Page Data delivery owns persistent files, datasets and two publication
|
// Page Data delivery owns persistent files, datasets and two publication
|
||||||
@@ -2008,7 +2031,7 @@ export function createWechatMpService({
|
|||||||
sessionId,
|
sessionId,
|
||||||
requestId,
|
requestId,
|
||||||
agentPrompt,
|
agentPrompt,
|
||||||
buildIntentMetadata(intent),
|
buildIntentMetadata(intent, { mediaAnalysisEnabled }),
|
||||||
);
|
);
|
||||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||||
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
|
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
|
||||||
@@ -2167,7 +2190,7 @@ export function createWechatMpService({
|
|||||||
sessionId,
|
sessionId,
|
||||||
retryId,
|
retryId,
|
||||||
retryPrompt,
|
retryPrompt,
|
||||||
buildIntentMetadata(intent),
|
buildIntentMetadata(intent, { mediaAnalysisEnabled }),
|
||||||
);
|
);
|
||||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||||
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
|
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) {
|
if (intent.msgType === 'voice' && !intent.agentText.trim() && intent.media?.mediaId) {
|
||||||
try {
|
try {
|
||||||
const fallbackText = await transcribeWechatVoiceMedia(intent.media.mediaId, intent.media.format);
|
const fallbackText = await transcribeWechatVoiceMedia(intent.media.mediaId, intent.media.format);
|
||||||
|
|||||||
+40
-1
@@ -3702,6 +3702,7 @@ test('wechat mp service persists image and routes image url into agent prompt',
|
|||||||
unboundTextPrefix: '请先绑定',
|
unboundTextPrefix: '请先绑定',
|
||||||
progressDelayMs: 0,
|
progressDelayMs: 0,
|
||||||
maxImageBytes: 1024 * 1024,
|
maxImageBytes: 1024 * 1024,
|
||||||
|
mediaAnalysisGrayUsers: [testUserId],
|
||||||
},
|
},
|
||||||
userAuth: {
|
userAuth: {
|
||||||
async findWechatUserByOpenid() {
|
async findWechatUserByOpenid() {
|
||||||
@@ -3831,6 +3832,7 @@ test('wechat mp service persists Word and Excel files in user public area and re
|
|||||||
config: {
|
config: {
|
||||||
maxFileBytes: 1024 * 1024,
|
maxFileBytes: 1024 * 1024,
|
||||||
acceptFile: true,
|
acceptFile: true,
|
||||||
|
mediaAnalysisGrayUsers: [testUserId],
|
||||||
},
|
},
|
||||||
userAuth: {
|
userAuth: {
|
||||||
async findWechatUserByOpenid() {
|
async findWechatUserByOpenid() {
|
||||||
@@ -3939,7 +3941,7 @@ test('wechat mp service rejects unsupported public file types without entering t
|
|||||||
let mediaDownloads = 0;
|
let mediaDownloads = 0;
|
||||||
let sessionCalls = 0;
|
let sessionCalls = 0;
|
||||||
const service = createBoundWechatService({
|
const service = createBoundWechatService({
|
||||||
config: { acceptFile: true },
|
config: { acceptFile: true, mediaAnalysisGrayUsers: ['user-1'] },
|
||||||
sessionApiFetch: async () => {
|
sessionApiFetch: async () => {
|
||||||
sessionCalls += 1;
|
sessionCalls += 1;
|
||||||
throw new Error('session should not be called');
|
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);
|
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 () => {
|
test('wechat mp service accepts full voice xml payload and routes recognition text into agent', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
|
|||||||
Reference in New Issue
Block a user