feat(wechat): add image generation delivery policy
Memind CI / Test, build, and release guards (pull_request) Successful in 2m45s
Memind CI / Test, build, and release guards (pull_request) Successful in 2m45s
This commit is contained in:
+316
-2
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
buildWechatAgentPrompt,
|
||||
buildWechatTextReply,
|
||||
@@ -62,8 +63,9 @@ function inboundXml({
|
||||
].join('');
|
||||
}
|
||||
|
||||
function previewReadyPageHtml({ title = 'Page', subtitle = '测试页面' } = {}) {
|
||||
return `<!doctype html><html><head><meta name="description" content="${subtitle}"><meta name="mindspace-cover" content='{"tag":"页面","accent":"#3366cc","accent2":"#112233","subtitle":"${subtitle}"}'><title>${title}</title></head><body><main>${'x'.repeat(600)}</main><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></body></html>`;
|
||||
function previewReadyPageHtml({ title = 'Page', subtitle = '测试页面', cover = '' } = {}) {
|
||||
const coverField = cover ? `,"cover":"${cover}"` : '';
|
||||
return `<!doctype html><html><head><meta name="description" content="${subtitle}"><meta name="mindspace-cover" content='{"tag":"页面","accent":"#3366cc","accent2":"#112233","subtitle":"${subtitle}"${coverField}}'><title>${title}</title></head><body><main>${'x'.repeat(600)}</main><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></body></html>`;
|
||||
}
|
||||
|
||||
function jsonEscapedPreviewReadyPageHtml(options = {}) {
|
||||
@@ -95,6 +97,7 @@ function createBoundWechatService({
|
||||
unboundTextPrefix: '请先绑定',
|
||||
progressDelayMs: 0,
|
||||
maxImageBytes: 1024 * 1024,
|
||||
requireFreshPageThumbnail: false,
|
||||
...config,
|
||||
},
|
||||
userAuth: {
|
||||
@@ -859,6 +862,8 @@ test('loadWechatMpConfig requires full config and enable flag', () => {
|
||||
});
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login');
|
||||
assert.equal(config.requireFreshPageThumbnail, true);
|
||||
assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false);
|
||||
});
|
||||
|
||||
test('verifyWechatMpSignature accepts valid signature', () => {
|
||||
@@ -4932,3 +4937,312 @@ test('wechat mp service exposes route status and can recreate route for bound us
|
||||
assert.equal(calls.some((item) => item === 'register:session-new'), true);
|
||||
assert.equal(calls.includes('upserted'), true);
|
||||
});
|
||||
|
||||
test('wechat mp page delivery requires and verifies a current-run fresh thumbnail', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-');
|
||||
const htmlPath = path.join(workspaceRoot, 'public', 'fresh.html');
|
||||
const wechatPayloads = [];
|
||||
const generated = {
|
||||
ok: true,
|
||||
jobId: 'job-fresh-page',
|
||||
source: { mimeType: 'image/webp', width: 1280, height: 720 },
|
||||
asset: {
|
||||
id: 'asset-fresh-page',
|
||||
htmlSrc: 'images/fresh-page.webp',
|
||||
publicUrl: 'https://example.com/MindSpace/user-1/public/images/fresh-page.webp',
|
||||
workspaceRelativePath: 'public/images/fresh-page.webp',
|
||||
},
|
||||
};
|
||||
const pageHtml = previewReadyPageHtml({
|
||||
title: 'Fresh',
|
||||
subtitle: '本轮新缩略图',
|
||||
cover: generated.asset.htmlSrc,
|
||||
});
|
||||
const pageImage = await sharp({
|
||||
create: { width: 64, height: 64, channels: 3, background: '#3366cc' },
|
||||
}).webp().toBuffer();
|
||||
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: { requireFreshPageThumbnail: true },
|
||||
userAuth: {
|
||||
async resolveWorkingDir() {
|
||||
return workspaceRoot;
|
||||
},
|
||||
async getUserPublishLayout() {
|
||||
return {
|
||||
publishDir: workspaceRoot,
|
||||
displayName: 'John',
|
||||
username: 'john',
|
||||
slug: 'john',
|
||||
constraints: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
sessionApiFetch: async (sessionId, pathname, init = {}) => {
|
||||
assert.equal(sessionId, 'session-1');
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
return new Response(
|
||||
[
|
||||
eventFrame({
|
||||
type: 'Message',
|
||||
request_id: 'req-fresh-page',
|
||||
message: {
|
||||
id: 'assistant-tools',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [
|
||||
{
|
||||
id: 'call-page-skill',
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: { name: 'load_skill', arguments: { name: 'static-page-publish' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'call-generate',
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'sandbox-fs__generate_image',
|
||||
arguments: { purpose: 'hero' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'call-generate',
|
||||
type: 'toolResponse',
|
||||
toolResult: {
|
||||
status: 'success',
|
||||
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'call-write-page',
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'sandbox-fs__write_file',
|
||||
arguments: { path: 'public/fresh.html', content: pageHtml },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
eventFrame({
|
||||
type: 'Message',
|
||||
request_id: 'req-fresh-page',
|
||||
message: {
|
||||
id: 'assistant-final',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '页面已完成:https://example.com/MindSpace/user-1/public/fresh.html',
|
||||
}],
|
||||
},
|
||||
}),
|
||||
eventFrame({
|
||||
type: 'Finish',
|
||||
request_id: 'req-fresh-page',
|
||||
token_state: { inputTokens: 1, outputTokens: 2 },
|
||||
}),
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/sessions/session-1/reply') {
|
||||
const body = JSON.parse(init.body);
|
||||
assert.equal(body.user_message.metadata.memindRun.channel, 'wechat_mp');
|
||||
assert.equal(body.user_message.metadata.memindRun.pageThumbnailMode, 'required_fresh');
|
||||
assert.equal(body.user_message.metadata.memindRun.inlineImageMode, 'auto');
|
||||
assert.match(body.user_message.content[0].text, /服务号页面新缩略图硬要求/);
|
||||
assert.match(body.user_message.content[0].text, /本轮新生成资产/);
|
||||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
htmlPath,
|
||||
pageHtml,
|
||||
'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 === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error(`unexpected session path: ${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 = () => 'req-fresh-page';
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({ content: '请生成一个旅行 html 页面,文件名 public/fresh.html' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
await result.task;
|
||||
assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'fresh.thumbnail.svg')), true);
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
assert.equal(wechatPayloads.length, 1);
|
||||
assert.equal(wechatPayloads[0].msgtype, 'text');
|
||||
assert.match(wechatPayloads[0].text.content, /fresh\.html/);
|
||||
});
|
||||
|
||||
test('wechat mp standalone image intent sends a native image message and text', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const sourceImage = await sharp({
|
||||
create: { width: 48, height: 48, channels: 3, background: '#cc8844' },
|
||||
}).webp().toBuffer();
|
||||
const wechatPayloads = [];
|
||||
const generated = {
|
||||
ok: true,
|
||||
jobId: 'job-chat-image',
|
||||
source: { mimeType: 'image/webp', width: 1024, height: 1024 },
|
||||
asset: {
|
||||
id: 'asset-chat-image',
|
||||
htmlSrc: 'images/chat-image.webp',
|
||||
publicUrl: 'https://example.com/MindSpace/user-1/public/images/chat-image.webp',
|
||||
workspaceRelativePath: 'public/images/chat-image.webp',
|
||||
},
|
||||
};
|
||||
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
sessionApiFetch: async (sessionId, pathname, init = {}) => {
|
||||
assert.equal(sessionId, 'session-1');
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
return new Response(
|
||||
[
|
||||
eventFrame({
|
||||
type: 'Message',
|
||||
request_id: 'req-chat-image',
|
||||
message: {
|
||||
id: 'assistant-tools',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [
|
||||
{
|
||||
id: 'call-image',
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'sandbox-fs__generate_image',
|
||||
arguments: { purpose: 'inline_image' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'call-image',
|
||||
type: 'toolResponse',
|
||||
toolResult: {
|
||||
status: 'success',
|
||||
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
eventFrame({
|
||||
type: 'Message',
|
||||
request_id: 'req-chat-image',
|
||||
message: {
|
||||
id: 'assistant-final',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [{ type: 'text', text: '新图片已经生成。' }],
|
||||
},
|
||||
}),
|
||||
eventFrame({
|
||||
type: 'Finish',
|
||||
request_id: 'req-chat-image',
|
||||
token_state: { inputTokens: 1, outputTokens: 2 },
|
||||
}),
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/sessions/session-1/reply') {
|
||||
const body = JSON.parse(init.body);
|
||||
assert.equal(body.user_message.metadata.memindRun.imageGenerationMode, 'required');
|
||||
assert.equal(body.user_message.metadata.memindRun.pageThumbnailMode, 'auto');
|
||||
assert.match(body.user_message.content[0].text, /purpose=`inline_image`/);
|
||||
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 session path: ${pathname}`);
|
||||
},
|
||||
wechatFetch: async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
if (target.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 (target === generated.asset.publicUrl) {
|
||||
return new Response(sourceImage, { status: 200, headers: { 'Content-Type': 'image/webp' } });
|
||||
}
|
||||
if (target.includes('/cgi-bin/media/upload')) {
|
||||
assert.ok(init.body instanceof FormData);
|
||||
return new Response(JSON.stringify({ type: 'image', media_id: 'wx-generated-1' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (target.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 = () => 'req-chat-image';
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({ content: '帮我生成一张雨夜橘猫图片' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
await result.task;
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
}
|
||||
assert.deepEqual(wechatPayloads.map((payload) => payload.msgtype), ['image', 'text']);
|
||||
assert.equal(wechatPayloads[0].image.media_id, 'wx-generated-1');
|
||||
assert.match(wechatPayloads[1].text.content, /新图片已经生成/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user