fix: persist wechat session history and page links
This commit is contained in:
+10
@@ -537,12 +537,22 @@ async function bootstrapUserAuth() {
|
||||
config: WECHAT_MP_CONFIG,
|
||||
userAuth,
|
||||
apiFetch: tkmindProxy.apiFetch,
|
||||
startAgentSession: ({ userId, workingDir, sessionPolicy }) =>
|
||||
tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }),
|
||||
sessionApiFetch: async (sessionId, pathname, init) => {
|
||||
const target = await tkmindProxy.resolveTarget(sessionId);
|
||||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||||
},
|
||||
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
|
||||
applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId),
|
||||
refreshSessionSnapshot:
|
||||
sessionSnapshotService?.isEnabled()
|
||||
? (sessionId, userId) =>
|
||||
sessionSnapshotService.refresh(sessionId, userId, async (pathname, init) => {
|
||||
const target = await tkmindProxy.resolveTarget(sessionId);
|
||||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||||
})
|
||||
: null,
|
||||
});
|
||||
userAuth.setRechargeNotifier(async ({ userId, title, body }) => {
|
||||
if (!wechatMpService?.enabled) return;
|
||||
|
||||
@@ -345,6 +345,51 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
|
||||
return primaryTarget;
|
||||
}
|
||||
|
||||
async function startSessionForUser(
|
||||
userId,
|
||||
{
|
||||
workingDir,
|
||||
sessionPolicy = null,
|
||||
recipe = null,
|
||||
} = {},
|
||||
) {
|
||||
const startTarget = await pickTarget();
|
||||
const upstream = await apiFetch(startTarget, apiSecret, '/agent/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...(workingDir ? { working_dir: workingDir } : {}),
|
||||
enable_context_memory: sessionPolicy?.enableContextMemory,
|
||||
...(sessionPolicy?.extensionOverrides
|
||||
? { extension_overrides: sessionPolicy.extensionOverrides }
|
||||
: {}),
|
||||
...(recipe ? { recipe } : {}),
|
||||
}),
|
||||
});
|
||||
const text = await upstream.text();
|
||||
if (!upstream.ok) {
|
||||
throw new Error(text || `创建会话失败 (${upstream.status})`);
|
||||
}
|
||||
const session = JSON.parse(text);
|
||||
if (!session?.id) {
|
||||
throw new Error('创建会话失败:缺少 session id');
|
||||
}
|
||||
await userAuth.registerAgentSession(userId, session.id, startTarget);
|
||||
if (sessionPolicy?.gooseMode) {
|
||||
const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_id: session.id,
|
||||
goose_mode: sessionPolicy.gooseMode,
|
||||
}),
|
||||
});
|
||||
if (!modeRes.ok) {
|
||||
const modeText = await modeRes.text().catch(() => '');
|
||||
throw new Error(modeText || `设置会话模式失败 (${modeRes.status})`);
|
||||
}
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async function resolveTarget(sessionId) {
|
||||
if (targets.length <= 1 || !sessionId) return primaryTarget;
|
||||
try {
|
||||
@@ -911,6 +956,7 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
|
||||
proxyFallback,
|
||||
proxySessionEvents,
|
||||
resolveTarget,
|
||||
startSessionForUser,
|
||||
apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init),
|
||||
apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init),
|
||||
};
|
||||
|
||||
+161
-30
@@ -35,6 +35,10 @@ const SESSION_WORKSPACE_TOOL_NAMES = new Set([
|
||||
'list_dir',
|
||||
]);
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function parseXmlField(xml, field) {
|
||||
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${field}>`));
|
||||
if (cdataMatch) return cdataMatch[1];
|
||||
@@ -310,7 +314,13 @@ function extractHtmlWriteTargets(messages = []) {
|
||||
const args = toolCall?.arguments ?? {};
|
||||
const action = String(args.action ?? '').toLowerCase();
|
||||
const writeLikeDeveloper = name === 'developer' && action === 'write';
|
||||
const writeLikeSandbox = (name === 'write_file' || name === 'edit_file') && typeof args.path === 'string';
|
||||
const normalizedName = String(name ?? '').trim();
|
||||
const writeLikeSandbox =
|
||||
(normalizedName === 'write_file' ||
|
||||
normalizedName === 'edit_file' ||
|
||||
normalizedName.endsWith('__write_file') ||
|
||||
normalizedName.endsWith('__edit_file')) &&
|
||||
typeof args.path === 'string';
|
||||
if (!writeLikeDeveloper && !writeLikeSandbox) continue;
|
||||
const candidate = String(args.path ?? '').trim();
|
||||
if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate);
|
||||
@@ -343,6 +353,32 @@ function isSuspiciousBareCompletionReply(reply, intent) {
|
||||
return !hasAnyToolRequest(reply?.messages ?? []);
|
||||
}
|
||||
|
||||
function looksLikePublishSuccessClaim(text) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
return /(?:页面|网页|html).*(?:已创建|已生成|已发布|创建完毕|生成完成|发布成功)|(?:成功发布|已经发布|已创建完毕).*(?:页面|网页|html)/iu.test(normalized);
|
||||
}
|
||||
|
||||
async function hasAnyValidPublishedHtmlLink(text, linkExists) {
|
||||
const value = String(text ?? '');
|
||||
for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN)) {
|
||||
try {
|
||||
if (await linkExists(match[0])) return true;
|
||||
} catch {
|
||||
// treat lookup failures as missing links
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists = defaultPublicHtmlLinkExists } = {}) {
|
||||
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
||||
const text = String(reply?.text ?? '').trim();
|
||||
if (!looksLikePublishSuccessClaim(text)) return false;
|
||||
if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false;
|
||||
return !(await hasAnyValidPublishedHtmlLink(text, linkExists));
|
||||
}
|
||||
|
||||
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
|
||||
const owner = path.basename(path.resolve(workingDir));
|
||||
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
|
||||
@@ -351,7 +387,9 @@ function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
|
||||
|
||||
function ensurePublicHtmlArtifact(htmlPath, workingDir) {
|
||||
const workspaceRoot = path.resolve(workingDir);
|
||||
const source = path.resolve(htmlPath);
|
||||
const source = path.isAbsolute(String(htmlPath ?? ''))
|
||||
? path.resolve(String(htmlPath))
|
||||
: path.resolve(workspaceRoot, String(htmlPath ?? ''));
|
||||
if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null;
|
||||
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null;
|
||||
|
||||
@@ -371,13 +409,47 @@ function ensurePublicHtmlArtifact(htmlPath, workingDir) {
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) {
|
||||
const baseText = String(reply?.text ?? '').trim();
|
||||
function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) {
|
||||
const artifacts = [];
|
||||
const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []);
|
||||
for (const target of htmlTargets) {
|
||||
const artifact = ensurePublicHtmlArtifact(target, workingDir);
|
||||
if (!artifact) continue;
|
||||
const url = buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl);
|
||||
artifacts.push({
|
||||
...artifact,
|
||||
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
|
||||
});
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
function rewritePublishedHtmlLinks(text, artifacts = []) {
|
||||
const value = String(text ?? '');
|
||||
if (!value || artifacts.length === 0) return value;
|
||||
let next = value;
|
||||
for (const artifact of artifacts) {
|
||||
const canonicalUrl = String(artifact?.url ?? '').trim();
|
||||
const filename = String(artifact?.relativePath ?? '').replace(/\\/g, '/').split('/').pop();
|
||||
if (!canonicalUrl || !filename) continue;
|
||||
const wrongPublicLinkPattern = new RegExp(
|
||||
`https?:\\/\\/[^\\s)]+\\/(?:MindSpace\\/[^\\s/]+\\/)?public\\/${escapeRegExp(filename)}\\b`,
|
||||
'g',
|
||||
);
|
||||
next = next.replace(wrongPublicLinkPattern, canonicalUrl);
|
||||
next = next.replace(PUBLIC_HTML_LINK_PATTERN, (match) => {
|
||||
if (match === canonicalUrl) return match;
|
||||
const matchedFilename = String(match).split('/').pop();
|
||||
return matchedFilename === filename ? canonicalUrl : match;
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) {
|
||||
const artifacts = collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl });
|
||||
const baseText = rewritePublishedHtmlLinks(String(reply?.text ?? '').trim(), artifacts).trim();
|
||||
for (const artifact of artifacts) {
|
||||
const url = artifact.url;
|
||||
if (baseText.includes(url)) return baseText;
|
||||
return baseText
|
||||
? `${baseText}\n\n查看页面:\n${url}`
|
||||
@@ -520,6 +592,18 @@ export async function guardMissingPublicHtmlLinks(
|
||||
return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value);
|
||||
}
|
||||
|
||||
function downgradePrematurePublishClaims(text) {
|
||||
const value = String(text ?? '');
|
||||
if (!value.includes('页面生成未完成')) return value;
|
||||
return value
|
||||
.replace(/(^|\n)([^\n]*页面都已经成功发布了[^\n]*)(?=\n|$)/g, '$1页面暂未生成完成,这次我先不发送失效链接。')
|
||||
.replace(/(^|\n)([^\n]*主题页面已发布[^\n]*)(?=\n|$)/g, '$1🌴 夏日主题页面生成未完成')
|
||||
.replace(/页面已创建完毕/g, '页面生成未完成')
|
||||
.replace(/页面已创建/g, '页面生成未完成')
|
||||
.replace(/发布成功/g, '生成未完成')
|
||||
.replace(/已发布成功/g, '生成未完成');
|
||||
}
|
||||
|
||||
export { maybeAttachPublishedHtmlLink };
|
||||
|
||||
function isQuestionStatusProbe(text) {
|
||||
@@ -951,9 +1035,11 @@ export function createWechatMpService({
|
||||
config,
|
||||
userAuth,
|
||||
apiFetch,
|
||||
startAgentSession = null,
|
||||
sessionApiFetch = null,
|
||||
scheduleService = null,
|
||||
applySessionLlmProvider = null,
|
||||
refreshSessionSnapshot = null,
|
||||
wechatFetch = undiciFetch,
|
||||
linkExists = defaultPublicHtmlLinkExists,
|
||||
logger = console,
|
||||
@@ -1089,9 +1175,21 @@ export function createWechatMpService({
|
||||
};
|
||||
};
|
||||
|
||||
const sendCustomerServiceText = async (openid, content, user = null) => {
|
||||
const sendCustomerServiceText = async (openid, content, user = null, { verifiedHtmlUrls = [] } = {}) => {
|
||||
const formatted = formatWechatOutboundText(content, user);
|
||||
const guarded = await guardMissingPublicHtmlLinks(formatted, { linkExists });
|
||||
const verifiedUrlSet = new Set(
|
||||
verifiedHtmlUrls
|
||||
.map((url) => String(url ?? '').trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const guarded = downgradePrematurePublishClaims(
|
||||
await guardMissingPublicHtmlLinks(formatted, {
|
||||
linkExists: async (url) => {
|
||||
if (verifiedUrlSet.has(String(url ?? '').trim())) return true;
|
||||
return linkExists(url);
|
||||
},
|
||||
}),
|
||||
);
|
||||
const chunks = splitWechatText(guarded);
|
||||
const accessToken = await getStableAccessToken();
|
||||
for (const chunk of chunks) {
|
||||
@@ -1216,26 +1314,28 @@ export function createWechatMpService({
|
||||
if (!gate.ok) {
|
||||
throw new Error(gate.message || '当前用户无法使用聊天能力');
|
||||
}
|
||||
const started = await readJsonResponse(
|
||||
await apiFetch('/agent/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
working_dir: workingDir,
|
||||
enable_context_memory: sessionPolicy.enableContextMemory,
|
||||
...(sessionPolicy.extensionOverrides
|
||||
? { extension_overrides: sessionPolicy.extensionOverrides }
|
||||
: {}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const started = startAgentSession
|
||||
? await startAgentSession({ userId, workingDir, sessionPolicy })
|
||||
: await readJsonResponse(
|
||||
await apiFetch('/agent/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
working_dir: workingDir,
|
||||
enable_context_memory: sessionPolicy.enableContextMemory,
|
||||
...(sessionPolicy.extensionOverrides
|
||||
? { extension_overrides: sessionPolicy.extensionOverrides }
|
||||
: {}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const sessionId = started?.id;
|
||||
if (!sessionId) {
|
||||
throw new Error('公众号专属 Agent 会话创建失败');
|
||||
}
|
||||
|
||||
// `/agent/start` already persists the owning user and goosed node via the
|
||||
// portal proxy. Re-registering here without the node can overwrite the
|
||||
// correct mapping back to node 0 in multi-goosed production.
|
||||
if (!startAgentSession && typeof userAuth.registerAgentSession === 'function') {
|
||||
await userAuth.registerAgentSession(userId, sessionId);
|
||||
}
|
||||
await reconcileAgentSession(
|
||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||
sessionId,
|
||||
@@ -1263,6 +1363,15 @@ export function createWechatMpService({
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
const refreshWechatSessionSnapshot = async (sessionId, userId) => {
|
||||
if (!sessionId || !userId || typeof refreshSessionSnapshot !== 'function') return;
|
||||
try {
|
||||
await refreshSessionSnapshot(sessionId, userId);
|
||||
} catch (err) {
|
||||
logger.warn?.('WeChat MP snapshot refresh failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const rememberWechatUserContext = async (sessionId, user) => {
|
||||
const addressName = resolveWechatAddressName(user);
|
||||
if (!addressName) return;
|
||||
@@ -1383,17 +1492,28 @@ export function createWechatMpService({
|
||||
buildWechatAgentPrompt(intent),
|
||||
buildIntentMetadata(intent),
|
||||
);
|
||||
if (isSuspiciousBareCompletionReply(reply, intent)) {
|
||||
if (
|
||||
isSuspiciousBareCompletionReply(reply, intent) ||
|
||||
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists }))
|
||||
) {
|
||||
throw new Error('stale_session_poisoned_completion');
|
||||
}
|
||||
if (reply.tokenState) {
|
||||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
|
||||
}
|
||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
|
||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
|
||||
workingDir,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
});
|
||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
|
||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
workingDir,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
});
|
||||
await refreshWechatSessionSnapshot(sessionId, user.userId);
|
||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
||||
verifiedHtmlUrls: publishedArtifacts.map((artifact) => artifact.url),
|
||||
});
|
||||
return { sessionId };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -1416,17 +1536,28 @@ export function createWechatMpService({
|
||||
buildWechatAgentPrompt(intent),
|
||||
buildIntentMetadata(intent),
|
||||
);
|
||||
if (isSuspiciousBareCompletionReply(reply, intent)) {
|
||||
if (
|
||||
isSuspiciousBareCompletionReply(reply, intent) ||
|
||||
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists }))
|
||||
) {
|
||||
throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试');
|
||||
}
|
||||
if (reply.tokenState) {
|
||||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
|
||||
}
|
||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
|
||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
|
||||
workingDir,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
});
|
||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
|
||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
workingDir,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
});
|
||||
await refreshWechatSessionSnapshot(sessionId, user.userId);
|
||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
||||
verifiedHtmlUrls: publishedArtifacts.map((artifact) => artifact.url),
|
||||
});
|
||||
return { sessionId };
|
||||
}
|
||||
throw err;
|
||||
|
||||
+352
-1
@@ -138,6 +138,79 @@ test('guardMissingPublicHtmlLinks blocks missing MindSpace public html links', a
|
||||
assert.match(guarded, /missing\.html/);
|
||||
});
|
||||
|
||||
test('wechat mp service rejects premature publish success copy when page link is blocked', async () => {
|
||||
const wechatCalls = [];
|
||||
const service = createBoundWechatService({
|
||||
config: {
|
||||
publicBaseUrl: 'https://m.tkmind.cn',
|
||||
},
|
||||
sessionApiFetch: async (sessionId, pathname) => {
|
||||
assert.equal(sessionId, 'session-1');
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","request_id":"req-1","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"好消息!m.tkmind.cn 其实是可以访问的,页面都已经成功发布了!以下是您的夏日主题页面:\\n\\n🌴 夏日主题页面已发布\\n\\nhttps://m.tkmind.cn/MindSpace/john/public/summer-breeze-journal.html"}]}}\n\n',
|
||||
'data: {"type":"Finish","request_id":"req-1","token_state":{"inputTokens":2,"outputTokens":2}}\n\n',
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/sessions/session-1/reply') {
|
||||
return new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
|
||||
},
|
||||
wechatFetch: async (url, init = {}) => {
|
||||
wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
|
||||
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')) {
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
userAuth: {
|
||||
async resolveWorkingDir() {
|
||||
return '/tmp/user-1';
|
||||
},
|
||||
},
|
||||
});
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
crypto.randomUUID = () => 'req-1';
|
||||
try {
|
||||
const result = await service.handleInboundMessage(inboundXml({ content: '帮我生成一个夏日页面' }), {
|
||||
timestamp: '1710000000',
|
||||
nonce: 'nonce',
|
||||
signature: signatureFor('token', '1710000000', 'nonce'),
|
||||
});
|
||||
assert.equal(result.status, 200);
|
||||
await result.task;
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
}
|
||||
|
||||
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
||||
const payload = JSON.parse(sendCall[2]);
|
||||
assert.doesNotMatch(payload.text.content, /成功发布|主题页面已发布/);
|
||||
assert.match(payload.text.content, /旧指令污染|稍后重试|转发到专属 Agent 失败/);
|
||||
});
|
||||
|
||||
test('maybeAttachPublishedHtmlLink copies generated root html into public and returns link', async (t) => {
|
||||
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-public-');
|
||||
const htmlPath = `${workspaceRoot}/hello.html`;
|
||||
@@ -180,6 +253,89 @@ test('maybeAttachPublishedHtmlLink copies generated root html into public and re
|
||||
);
|
||||
});
|
||||
|
||||
test('maybeAttachPublishedHtmlLink recognizes sandbox-fs write_file html outputs', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-sandbox-fs-');
|
||||
const htmlPath = `${workspaceRoot}/public/codex-verify.html`;
|
||||
fs.mkdirSync(`${workspaceRoot}/public`, { recursive: true });
|
||||
fs.writeFileSync(htmlPath, '<!doctype html><title>Codex Verify</title>');
|
||||
|
||||
const text = await maybeAttachPublishedHtmlLink(
|
||||
{
|
||||
text: '页面已生成 ✅',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'sandbox-fs__write_file',
|
||||
arguments: {
|
||||
path: 'public/codex-verify.html',
|
||||
content: '<!doctype html><title>Codex Verify</title>',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
workingDir: workspaceRoot,
|
||||
publicBaseUrl: 'https://m.tkmind.cn',
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(
|
||||
text,
|
||||
/https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/codex-verify\.html/,
|
||||
);
|
||||
});
|
||||
|
||||
test('maybeAttachPublishedHtmlLink rewrites wrong public html links to the canonical published url', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-rewrite-link-');
|
||||
const htmlPath = `${workspaceRoot}/public/summer-breeze-journal.html`;
|
||||
fs.mkdirSync(`${workspaceRoot}/public`, { recursive: true });
|
||||
fs.writeFileSync(htmlPath, '<!doctype html><title>Summer</title>');
|
||||
|
||||
const text = await maybeAttachPublishedHtmlLink(
|
||||
{
|
||||
text: '页面已发布:https://m.tkmind.cn/public/summer-breeze-journal.html',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'sandbox-fs__write_file',
|
||||
arguments: {
|
||||
path: 'public/summer-breeze-journal.html',
|
||||
content: '<!doctype html><title>Summer</title>',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
workingDir: workspaceRoot,
|
||||
publicBaseUrl: 'https://m.tkmind.cn',
|
||||
},
|
||||
);
|
||||
|
||||
assert.doesNotMatch(text, /https:\/\/m\.tkmind\.cn\/public\/summer-breeze-journal\.html/);
|
||||
assert.match(
|
||||
text,
|
||||
/https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/summer-breeze-journal\.html/,
|
||||
);
|
||||
});
|
||||
|
||||
test('wechat mp service splits long agent replies into multiple customer messages', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
@@ -388,6 +544,10 @@ test('wechat mp service strips markdown emphasis around outbound links', async (
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
linkExists: async (urlText) => {
|
||||
if (!String(urlText).includes('/summer-breeze-journal.html')) return false;
|
||||
return fs.existsSync(htmlPath);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
@@ -452,6 +612,10 @@ test('wechat mp service does not send stale page links from unscoped conversatio
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
linkExists: async (urlText) => {
|
||||
if (!String(urlText).includes('/summer-breeze-journal.html')) return false;
|
||||
return fs.existsSync(htmlPath);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
@@ -696,6 +860,10 @@ test('wechat mp service routes text to dedicated session and sends customer serv
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
linkExists: async (urlText) => {
|
||||
if (!String(urlText).includes('/summer-breeze-journal.html')) return false;
|
||||
return fs.existsSync(htmlPath);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
@@ -808,6 +976,10 @@ test('wechat mp service rewrites internal wx username mentions that appear mid-m
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
linkExists: async (urlText) => {
|
||||
if (!String(urlText).includes('/summer-breeze-journal.html')) return false;
|
||||
return fs.existsSync(htmlPath);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
@@ -1284,6 +1456,10 @@ test('wechat mp service recreates poisoned dedicated session after bare completi
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
linkExists: async (urlText) => {
|
||||
if (!String(urlText).includes('/summer-breeze-journal.html')) return false;
|
||||
return fs.existsSync(htmlPath);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
@@ -1314,6 +1490,181 @@ test('wechat mp service recreates poisoned dedicated session after bare completi
|
||||
assert.match(payload.text.content, /页面生成未完成|hello\.html/);
|
||||
});
|
||||
|
||||
test('wechat mp service recreates poisoned dedicated session after publish-success claim without html evidence', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const wechatCalls = [];
|
||||
let routeCleared = false;
|
||||
let started = false;
|
||||
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-publish-claim-');
|
||||
const htmlPath = path.join(workspaceRoot, 'public', 'summer-breeze-journal.html');
|
||||
|
||||
const service = createWechatMpService({
|
||||
config: {
|
||||
enabled: true,
|
||||
appId: 'wx123',
|
||||
appSecret: 'secret',
|
||||
token,
|
||||
publicBaseUrl: 'https://m.tkmind.cn',
|
||||
bindPath: '/auth/wechat/authorize?intent=login',
|
||||
ackText: 'ack',
|
||||
unsupportedText: 'unsupported',
|
||||
unboundTextPrefix: '请先绑定',
|
||||
progressDelayMs: 0,
|
||||
},
|
||||
userAuth: {
|
||||
async findWechatUserByOpenid() {
|
||||
return { userId: 'user-1', status: 'active', nickname: '毕升' };
|
||||
},
|
||||
async getWechatAgentRoute() {
|
||||
return routeCleared ? null : { agentSessionId: 'session-1' };
|
||||
},
|
||||
async clearWechatAgentRoute() {
|
||||
routeCleared = true;
|
||||
},
|
||||
async canUseChat() {
|
||||
return { ok: true };
|
||||
},
|
||||
async resolveWorkingDir() {
|
||||
return workspaceRoot;
|
||||
},
|
||||
async getAgentSessionPolicy() {
|
||||
return {
|
||||
enableContextMemory: false,
|
||||
extensionOverrides: [
|
||||
{
|
||||
type: 'platform',
|
||||
name: 'developer',
|
||||
available_tools: ['write'],
|
||||
},
|
||||
],
|
||||
unrestricted: false,
|
||||
};
|
||||
},
|
||||
async getUserPublishLayout() {
|
||||
return { publishDir: workspaceRoot, displayName: 'John', username: 'john', slug: 'john', constraints: null };
|
||||
},
|
||||
async registerAgentSession() {},
|
||||
async upsertWechatAgentRoute({ agentSessionId }) {
|
||||
assert.equal(agentSessionId, 'session-2');
|
||||
},
|
||||
async billSessionUsage() {},
|
||||
async recordWechatMpMessage() {
|
||||
return { inserted: true };
|
||||
},
|
||||
async finishWechatMpMessage() {},
|
||||
async insertWechatMpMessageDetail() {},
|
||||
},
|
||||
apiFetch: async (pathname, init = {}) => {
|
||||
if (pathname === '/agent/start') {
|
||||
started = true;
|
||||
const body = JSON.parse(init.body);
|
||||
assert.equal(body.working_dir, workspaceRoot);
|
||||
return new Response(JSON.stringify({ id: 'session-2' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected api path: ${pathname}`);
|
||||
},
|
||||
sessionApiFetch: async (sessionId, pathname) => {
|
||||
if (pathname === `/sessions/${sessionId}/extensions`) {
|
||||
return new Response(JSON.stringify({ extensions: [{ name: 'developer', available_tools: ['write'] }] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (pathname === '/agent/update_working_dir' || pathname === '/agent/update_session' || pathname === '/agent/restart') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
if (pathname === '/agent/add_extension') {
|
||||
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' } });
|
||||
}
|
||||
if (pathname === `/sessions/${sessionId}`) {
|
||||
return new Response(JSON.stringify({ working_dir: workspaceRoot }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (pathname === `/sessions/${sessionId}/reply`) {
|
||||
if (sessionId === 'session-2') {
|
||||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||
fs.writeFileSync(htmlPath, '<!doctype html><title>Summer</title>');
|
||||
}
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
if (pathname === `/sessions/${sessionId}/events`) {
|
||||
if (sessionId === 'session-1') {
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","request_id":"req-claim","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"好消息!m.tkmind.cn 其实是可以访问的,页面都已经成功发布了!以下是您的夏日主题页面:\\n\\n🌴 夏日主题页面已发布\\n\\nhttps://m.tkmind.cn/MindSpace/john/public/summer-breeze-journal.html"}]}}\n\n',
|
||||
'data: {"type":"Finish","request_id":"req-claim","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
[
|
||||
`data: {"type":"Message","request_id":"req-claim-retry","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"developer","arguments":{"action":"write","path":"${htmlPath.replace(/\\/g, '\\\\')}","content":"<!doctype html><title>Summer</title>"}}}}]}}\n\n`,
|
||||
'data: {"type":"Message","request_id":"req-claim-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已完成"}]}}\n\n',
|
||||
'data: {"type":"Finish","request_id":"req-claim-retry","token_state":{"inputTokens":2,"outputTokens":2}}\n\n',
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
|
||||
},
|
||||
wechatFetch: async (url, init = {}) => {
|
||||
wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
|
||||
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')) {
|
||||
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-claim', 'req-claim-retry'];
|
||||
return () => ids.shift() ?? 'req-claim-retry';
|
||||
})();
|
||||
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.equal(routeCleared, true);
|
||||
assert.equal(started, true);
|
||||
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
||||
const payload = JSON.parse(sendCall[2]);
|
||||
assert.doesNotMatch(payload.text.content, /页面都已经成功发布了|主题页面已发布/);
|
||||
assert.match(payload.text.content, /summer-breeze-journal\.html/);
|
||||
assert.match(payload.text.content, /已完成|页面生成未完成/);
|
||||
});
|
||||
|
||||
test('wechat mp service blocks schedule confirmation when no schedule item was written', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
@@ -2433,6 +2784,6 @@ test('wechat mp service exposes route status and can recreate route for bound us
|
||||
assert.equal(recreated.ok, true);
|
||||
assert.equal(recreated.route.agentSessionId, 'session-new');
|
||||
assert.equal(calls.includes('cleared'), true);
|
||||
assert.equal(calls.some((item) => item === 'register:session-new'), false);
|
||||
assert.equal(calls.some((item) => item === 'register:session-new'), true);
|
||||
assert.equal(calls.includes('upserted'), true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user