Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b3acb6813 | |||
| aafda0cf64 | |||
| c6ae7cd21c | |||
| 1765cac65a |
+12
-2
@@ -131,6 +131,7 @@ export async function uploadWechatGeneratedImage(
|
||||
{
|
||||
wechatFetch = undiciFetch,
|
||||
publicBaseUrl = '',
|
||||
allowedPublicBaseUrls = [],
|
||||
uploadUrl = DEFAULT_WECHAT_MEDIA_UPLOAD_URL,
|
||||
maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES,
|
||||
} = {},
|
||||
@@ -138,8 +139,17 @@ export async function uploadWechatGeneratedImage(
|
||||
if (!accessToken) throw new Error('缺少微信 access_token');
|
||||
if (!publicUrl) throw new Error('缺少生成图片公网地址');
|
||||
const resolvedUrl = new URL(String(publicUrl), publicBaseUrl || undefined).toString();
|
||||
if (publicBaseUrl && new URL(resolvedUrl).origin !== new URL(publicBaseUrl).origin) {
|
||||
throw new Error('生成图片地址不属于当前 MindSpace 公网域名');
|
||||
const allowedOrigins = new Set();
|
||||
for (const baseUrl of [publicBaseUrl, ...allowedPublicBaseUrls]) {
|
||||
if (!baseUrl) continue;
|
||||
try {
|
||||
allowedOrigins.add(new URL(String(baseUrl)).origin);
|
||||
} catch {
|
||||
// Ignore invalid optional bases; at least one valid configured origin is required below.
|
||||
}
|
||||
}
|
||||
if (allowedOrigins.size > 0 && !allowedOrigins.has(new URL(resolvedUrl).origin)) {
|
||||
throw new Error('生成图片地址不属于当前 MindSpace 可信公网域名');
|
||||
}
|
||||
const sourceResponse = await wechatFetch(resolvedUrl, {
|
||||
method: 'GET',
|
||||
|
||||
@@ -36,3 +36,38 @@ test('uploadWechatGeneratedImage converts a generated asset and uploads WeChat i
|
||||
assert.match(calls[1].url, /access_token=access-1/);
|
||||
assert.match(calls[1].url, /type=image/);
|
||||
});
|
||||
|
||||
test('uploadWechatGeneratedImage accepts configured imgproxy origin and rejects unknown origins', async () => {
|
||||
const source = await sharp({
|
||||
create: { width: 16, height: 16, channels: 3, background: '#884422' },
|
||||
}).png().toBuffer();
|
||||
const wechatFetch = async (url) => {
|
||||
if (String(url).startsWith('https://img.example.com/')) {
|
||||
return new Response(source, { status: 200, headers: { 'Content-Type': 'image/png' } });
|
||||
}
|
||||
return new Response(JSON.stringify({ type: 'image', media_id: 'wx-media-imgproxy' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
const accepted = await uploadWechatGeneratedImage(
|
||||
'access-2',
|
||||
'https://img.example.com/signed/generated.webp',
|
||||
{
|
||||
publicBaseUrl: 'https://app.example.com',
|
||||
allowedPublicBaseUrls: ['https://img.example.com'],
|
||||
wechatFetch,
|
||||
},
|
||||
);
|
||||
assert.equal(accepted.mediaId, 'wx-media-imgproxy');
|
||||
|
||||
await assert.rejects(
|
||||
uploadWechatGeneratedImage('access-2', 'https://untrusted.example.net/generated.webp', {
|
||||
publicBaseUrl: 'https://app.example.com',
|
||||
allowedPublicBaseUrls: ['https://img.example.com'],
|
||||
wechatFetch,
|
||||
}),
|
||||
/可信公网域名/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,11 @@ export function loadWechatMpConfig(env = process.env) {
|
||||
env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? '';
|
||||
const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? '';
|
||||
const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? '';
|
||||
const generatedImagePublicBaseUrls = [...new Set([
|
||||
publicBaseUrl,
|
||||
env.IMGPROXY_BASE_URL?.trim()?.replace(/\/$/, '') ?? '',
|
||||
...parseCsvList(env.H5_WECHAT_MP_GENERATED_IMAGE_BASE_URLS).map((value) => value.replace(/\/$/, '')),
|
||||
].filter(Boolean))];
|
||||
const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1';
|
||||
const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login';
|
||||
return {
|
||||
@@ -73,6 +78,7 @@ export function loadWechatMpConfig(env = process.env) {
|
||||
DEFAULT_WECHAT_JSAPI_TICKET_URL,
|
||||
mediaPublicBaseUrl:
|
||||
env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') || publicBaseUrl,
|
||||
generatedImagePublicBaseUrls,
|
||||
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',
|
||||
|
||||
+11
-5
@@ -1030,6 +1030,7 @@ export function isRecoverableWechatAgentSessionError(message) {
|
||||
const normalized = String(message ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
if (/stale_session_poisoned_completion/i.test(normalized)) return true;
|
||||
if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true;
|
||||
if (/403|404|not found|无权访问/i.test(normalized)) return true;
|
||||
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
|
||||
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
|
||||
@@ -1801,6 +1802,7 @@ export function createWechatMpService({
|
||||
const uploaded = await uploadWechatGeneratedImage(accessToken, publicUrl, {
|
||||
wechatFetch,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
allowedPublicBaseUrls: config.generatedImagePublicBaseUrls,
|
||||
});
|
||||
const payload = await readJsonResponse(
|
||||
await wechatFetch(
|
||||
@@ -1839,6 +1841,7 @@ export function createWechatMpService({
|
||||
user,
|
||||
imagePolicy,
|
||||
publishDir,
|
||||
notifyFailure = true,
|
||||
}) => {
|
||||
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
|
||||
const images = collectWechatGeneratedImages(reply?.messages ?? []);
|
||||
@@ -1859,14 +1862,16 @@ export function createWechatMpService({
|
||||
}
|
||||
}
|
||||
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
|
||||
try {
|
||||
await sendCustomerServiceText(openid, text, user);
|
||||
} catch (sendErr) {
|
||||
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
|
||||
if (notifyFailure) {
|
||||
try {
|
||||
await sendCustomerServiceText(openid, text, user);
|
||||
} catch (sendErr) {
|
||||
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
|
||||
}
|
||||
}
|
||||
const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`);
|
||||
error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED';
|
||||
throw markWechatUserNotified(error);
|
||||
throw notifyFailure ? markWechatUserNotified(error) : error;
|
||||
};
|
||||
|
||||
const ensureSessionProvider = async (sessionId) => {
|
||||
@@ -2350,6 +2355,7 @@ export function createWechatMpService({
|
||||
user,
|
||||
imagePolicy,
|
||||
publishDir: workingDir,
|
||||
notifyFailure: false,
|
||||
});
|
||||
}
|
||||
const pageDataOutcome = await enforcePageDataCollectDelivery({
|
||||
|
||||
@@ -859,10 +859,15 @@ test('loadWechatMpConfig requires full config and enable flag', () => {
|
||||
H5_WECHAT_MP_APP_SECRET: 'secret',
|
||||
H5_WECHAT_MP_TOKEN: 'token',
|
||||
H5_PUBLIC_BASE_URL: 'https://example.com',
|
||||
IMGPROXY_BASE_URL: 'https://img.example.com',
|
||||
});
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login');
|
||||
assert.equal(config.requireFreshPageThumbnail, true);
|
||||
assert.deepEqual(config.generatedImagePublicBaseUrls, [
|
||||
'https://example.com',
|
||||
'https://img.example.com',
|
||||
]);
|
||||
assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false);
|
||||
});
|
||||
|
||||
@@ -2718,6 +2723,12 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
|
||||
true,
|
||||
);
|
||||
assert.equal(isRecoverableWechatAgentSessionError('stale_session_poisoned_completion'), true);
|
||||
assert.equal(
|
||||
isRecoverableWechatAgentSessionError(
|
||||
'wechat_page_fresh_thumbnail_required:fresh_image_not_generated',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isRecoverableWechatAgentSessionError('无权访问该会话'), true);
|
||||
assert.equal(
|
||||
isRecoverableWechatAgentSessionError('Session already has an active request. Cancel it first.'),
|
||||
@@ -5114,6 +5125,210 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
|
||||
assert.match(wechatPayloads[0].text.content, /fresh\.html/);
|
||||
});
|
||||
|
||||
test('wechat mp retries a page in a new session before reporting a missing fresh thumbnail', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-retry-');
|
||||
const htmlPath = path.join(workspaceRoot, 'public', 'retry.html');
|
||||
const generated = {
|
||||
ok: true,
|
||||
jobId: 'job-fresh-page-retry',
|
||||
source: { mimeType: 'image/webp', width: 1280, height: 720 },
|
||||
asset: {
|
||||
id: 'asset-fresh-page-retry',
|
||||
htmlSrc: 'images/retry-cover.webp',
|
||||
publicUrl: 'https://example.com/MindSpace/user-1/public/images/retry-cover.webp',
|
||||
workspaceRelativePath: 'public/images/retry-cover.webp',
|
||||
},
|
||||
};
|
||||
const firstHtml = previewReadyPageHtml({
|
||||
title: 'Retry',
|
||||
subtitle: '首次没有新缩略图',
|
||||
cover: 'images/missing-cover.webp',
|
||||
});
|
||||
const retryHtml = previewReadyPageHtml({
|
||||
title: 'Retry',
|
||||
subtitle: '重试生成新缩略图',
|
||||
cover: generated.asset.htmlSrc,
|
||||
});
|
||||
const pageImage = await sharp({
|
||||
create: { width: 64, height: 64, channels: 3, background: '#cc6633' },
|
||||
}).webp().toBuffer();
|
||||
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
|
||||
const wechatPayloads = [];
|
||||
let routeCleared = false;
|
||||
let startedSessions = 0;
|
||||
|
||||
const replyEvents = ({ requestId, html, includeImage = false }) => [
|
||||
eventFrame({
|
||||
type: 'Message',
|
||||
request_id: requestId,
|
||||
message: {
|
||||
id: `${requestId}-tools`,
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [
|
||||
{
|
||||
id: `${requestId}-page-skill`,
|
||||
type: 'toolRequest',
|
||||
toolCall: { value: { name: 'load_skill', arguments: { name: 'static-page-publish' } } },
|
||||
},
|
||||
...(includeImage
|
||||
? [
|
||||
{
|
||||
id: `${requestId}-image`,
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `${requestId}-image`,
|
||||
type: 'toolResponse',
|
||||
toolResult: {
|
||||
status: 'success',
|
||||
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: `${requestId}-write`,
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'sandbox-fs__write_file',
|
||||
arguments: { path: 'public/retry.html', content: html },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
eventFrame({
|
||||
type: 'Message',
|
||||
request_id: requestId,
|
||||
message: {
|
||||
id: `${requestId}-final`,
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '页面已完成:https://example.com/MindSpace/user-1/public/retry.html',
|
||||
}],
|
||||
},
|
||||
}),
|
||||
eventFrame({
|
||||
type: 'Finish',
|
||||
request_id: requestId,
|
||||
token_state: { inputTokens: 1, outputTokens: 2 },
|
||||
}),
|
||||
].join('');
|
||||
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: { requireFreshPageThumbnail: true },
|
||||
startAgentSession: async () => {
|
||||
startedSessions += 1;
|
||||
return { id: 'session-2' };
|
||||
},
|
||||
userAuth: {
|
||||
async getWechatAgentRoute() {
|
||||
return routeCleared ? null : { agentSessionId: 'session-1' };
|
||||
},
|
||||
async clearWechatAgentRoute() {
|
||||
routeCleared = true;
|
||||
},
|
||||
async upsertWechatAgentRoute({ agentSessionId }) {
|
||||
assert.equal(agentSessionId, 'session-2');
|
||||
},
|
||||
async resolveWorkingDir() {
|
||||
return workspaceRoot;
|
||||
},
|
||||
async getUserPublishLayout() {
|
||||
return {
|
||||
publishDir: workspaceRoot,
|
||||
displayName: 'John',
|
||||
username: 'john',
|
||||
slug: 'john',
|
||||
constraints: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
sessionApiFetch: async (sessionId, pathname) => {
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
if (pathname === `/sessions/${sessionId}/reply`) {
|
||||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||
if (sessionId === 'session-1') {
|
||||
fs.writeFileSync(htmlPath, firstHtml, 'utf8');
|
||||
} else {
|
||||
fs.writeFileSync(htmlPath, retryHtml, 'utf8');
|
||||
fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspaceRoot, 'public', generated.asset.htmlSrc), pageImage);
|
||||
}
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
return new Response(
|
||||
replyEvents({ requestId: 'req-thumbnail-first', html: firstHtml }),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/sessions/session-2/events') {
|
||||
return new Response(
|
||||
replyEvents({ requestId: 'req-thumbnail-retry', html: retryHtml, includeImage: true }),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected session path: ${sessionId} ${pathname}`);
|
||||
},
|
||||
wechatFetch: async (url, init = {}) => {
|
||||
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/message/custom/send')) {
|
||||
wechatPayloads.push(JSON.parse(init.body));
|
||||
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 = (() => {
|
||||
const ids = ['req-thumbnail-first', 'req-thumbnail-retry'];
|
||||
return () => ids.shift() ?? 'req-thumbnail-retry';
|
||||
})();
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({ content: '生成一个活动页面,只要文字,不要正文图片' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
await result.task;
|
||||
assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'retry.thumbnail.svg')), true);
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.equal(routeCleared, true);
|
||||
assert.equal(startedSessions, 1);
|
||||
assert.equal(wechatPayloads.length, 1);
|
||||
assert.equal(wechatPayloads[0].msgtype, 'text');
|
||||
assert.match(wechatPayloads[0].text.content, /retry\.html/);
|
||||
assert.doesNotMatch(wechatPayloads[0].text.content, /没有完成服务号要求的本轮新缩略图/);
|
||||
});
|
||||
|
||||
test('wechat mp standalone image intent sends a native image message and text', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
|
||||
Reference in New Issue
Block a user