From 644f7fc632e8512f9a2e8c512166f30f4ef427ff Mon Sep 17 00:00:00 2001 From: john Date: Wed, 26 Aug 2026 21:14:36 +0800 Subject: [PATCH] fix(wechat): add cursor channel modules required by wechat-mp imports Ship the WeChat Cursor executor helpers referenced by the page delivery path so tests and runtime imports resolve consistently. Co-authored-by: Cursor --- wechat-cursor-agent-run.mjs | 117 ++++++++ wechat-cursor-channel.test.mjs | 341 ++++++++++++++++++++++++ wechat-cursor-executor-admin-config.mjs | 220 +++++++++++++++ wechat-cursor-executor-policy.mjs | 17 ++ wechat-cursor-executor-policy.test.mjs | 91 +++++++ wechat-cursor-page-delivery.mjs | 105 ++++++++ wechat-cursor-page-delivery.test.mjs | 38 +++ 7 files changed, 929 insertions(+) create mode 100644 wechat-cursor-agent-run.mjs create mode 100644 wechat-cursor-channel.test.mjs create mode 100644 wechat-cursor-executor-admin-config.mjs create mode 100644 wechat-cursor-executor-policy.mjs create mode 100644 wechat-cursor-executor-policy.test.mjs create mode 100644 wechat-cursor-page-delivery.mjs create mode 100644 wechat-cursor-page-delivery.test.mjs diff --git a/wechat-cursor-agent-run.mjs b/wechat-cursor-agent-run.mjs new file mode 100644 index 0000000..ef2e84a --- /dev/null +++ b/wechat-cursor-agent-run.mjs @@ -0,0 +1,117 @@ +import { buildCodeRunCompletionReply } from './agent-run-gateway.mjs'; +import { enforcePageGenerationCursorRuntime } from './cursor-page-routing.mjs'; + +const DEFAULT_POLL_MS = 2000; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function buildWechatCursorUserMessage({ + displayText, + intentKind, +}) { + const taskText = String(displayText ?? '').trim(); + return { + role: 'user', + content: [{ type: 'text', text: taskText }], + metadata: { + displayText: taskText, + memindRun: { + channel: 'wechat_mp', + wechatCursorChannel: true, + taskType: 'wechat_page_generate', + toolMode: 'code', + requiredExecutor: 'cursor', + intentKind: String(intentKind ?? '').trim() || null, + }, + }, + }; +} + +async function readCursorCompletionText(agentRunGateway, userId, runId) { + const eventsResult = await agentRunGateway.listRunEventsForUser(userId, runId, { limit: 200 }); + const events = Array.isArray(eventsResult?.events) ? eventsResult.events : []; + const resultEvent = [...events].reverse().find((item) => item.eventType === 'tool_gateway_result'); + if (!resultEvent?.data) { + return buildCodeRunCompletionReply({ executor: 'cursor', stdout: '' }); + } + return buildCodeRunCompletionReply({ + executor: resultEvent.data.executor ?? 'cursor', + stdout: resultEvent.data.stdoutTail ?? '', + }); +} + +export async function executeWechatCursorAgentRun({ + agentRunGateway, + userId, + sessionId = null, + requestId, + displayText, + agentPrompt, + intentKind = 'page.generate', + timeoutMs = 15 * 60 * 1000, + pollMs = DEFAULT_POLL_MS, + logger = console, +} = {}) { + if (!agentRunGateway?.createRun || !agentRunGateway?.dispatchRun || !agentRunGateway?.getRunForUser) { + throw Object.assign(new Error('WeChat Cursor 执行网关不可用'), { + code: 'WECHAT_CURSOR_GATEWAY_UNAVAILABLE', + }); + } + + let userMessage = buildWechatCursorUserMessage({ displayText, intentKind }); + const cursorRuntime = enforcePageGenerationCursorRuntime(userMessage, { + rawToolMode: 'code', + taskType: 'wechat_page_generate', + env: process.env, + }); + userMessage = cursorRuntime.userMessage; + + const run = await agentRunGateway.createRun(userId, { + sessionId, + requestId, + userMessage, + toolMode: 'code', + taskType: cursorRuntime.taskType ?? 'h5_chat_code_task', + }); + agentRunGateway.dispatchRun(run.id); + + const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0); + while (Date.now() <= deadline) { + const latest = await agentRunGateway.getRunForUser(userId, run.id); + if (!latest) { + throw Object.assign(new Error('WeChat Cursor 任务丢失'), { + code: 'WECHAT_CURSOR_RUN_MISSING', + }); + } + if (latest.status === 'succeeded') { + const text = await readCursorCompletionText(agentRunGateway, userId, run.id); + logger.info?.('[wechat-cursor] run succeeded', { + userId, + runId: run.id, + requestId, + }); + return { + text, + tokenState: null, + messages: [{ + role: 'assistant', + content: [{ type: 'text', text }], + metadata: { userVisible: true, source: 'wechat-cursor-agent-run' }, + }], + requestMessages: [], + }; + } + if (latest.status === 'failed') { + const error = new Error(latest.error || 'WeChat Cursor 执行失败'); + error.code = 'WECHAT_CURSOR_RUN_FAILED'; + throw error; + } + await sleep(Math.max(500, Number(pollMs) || DEFAULT_POLL_MS)); + } + + throw Object.assign(new Error('WeChat Cursor 执行超时'), { + code: 'WECHAT_CURSOR_RUN_TIMEOUT', + }); +} diff --git a/wechat-cursor-channel.test.mjs b/wechat-cursor-channel.test.mjs new file mode 100644 index 0000000..6807e38 --- /dev/null +++ b/wechat-cursor-channel.test.mjs @@ -0,0 +1,341 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createWechatMpService as createProductionWechatMpService } from './wechat-mp.mjs'; +import { prepareWechatHtmlDeliveryAtWorkspace } from './mindspace-wechat-html-delivery.mjs'; +import { + isWechatCursorChannelReply, + prepareWechatCursorPageDelivery, +} from './wechat-cursor-page-delivery.mjs'; +import { PUBLISH_ROOT_DIR } from './user-publish.mjs'; + +function sha1(parts) { + return crypto.createHash('sha1').update([...parts].sort().join('')).digest('hex'); +} + +function signatureFor(token, timestamp, nonce) { + return sha1([token, timestamp, nonce]); +} + +function inboundXml({ fromUser = 'openid-test-1', content = '帮我做个测试页面' } = {}) { + return [ + '', + '', + ``, + '1710000000', + '', + ``, + 'msg-cursor-test-1', + '', + ].join(''); +} + +function createWechatMpService(options) { + const resolvePublishDir = async (userId) => + options.userAuth?.resolveWorkingDir?.(userId) ?? `/tmp/${userId}`; + const buildCanonicalUrl = (userId, relativePath) => { + const base = String(options.config?.publicBaseUrl ?? 'https://example.com').replace(/\/+$/, ''); + const normalized = String(relativePath).replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/'); + return `${base}/MindSpace/${encodeURIComponent(userId)}/${normalized}`; + }; + const htmlDeliveryAuthority = options.htmlDeliveryAuthority ?? { + async prepareWechatHtmlDelivery(input) { + const publishDir = await resolvePublishDir(input.userId); + return prepareWechatHtmlDeliveryAtWorkspace({ + reply: input.reply, + intent: input.intent, + publishDir, + requestStartedAt: input.requestStartedAt, + allowRecentArtifacts: input.allowRecentArtifacts, + buildCanonicalUrl: (relativePath) => buildCanonicalUrl(input.userId, relativePath), + }); + }, + async ensureWechatFreshPageThumbnails() { + return { artifacts: [], images: [] }; + }, + }; + return createProductionWechatMpService({ + ...options, + htmlDeliveryAuthority, + }); +} + +function createBoundWechatService(overrides = {}) { + const apiCalls = []; + const wechatCalls = []; + const cursorCalls = []; + const token = overrides.token ?? 'test-token'; + const userId = overrides.userId ?? 'user-cursor-test'; + + const agentRunGateway = overrides.agentRunGateway ?? { + async createRun(runUserId, payload) { + cursorCalls.push(['createRun', runUserId, payload]); + const touchNow = Date.now(); + fs.utimesSync(htmlPath, touchNow / 1000, touchNow / 1000); + return { id: 'run-cursor-1', status: 'queued' }; + }, + dispatchRun(runId) { + cursorCalls.push(['dispatchRun', runId]); + }, + async getRunForUser(runUserId, runId) { + cursorCalls.push(['getRunForUser', runUserId, runId]); + return { id: runId, status: 'succeeded', error: null }; + }, + async listRunEventsForUser(runUserId, runId) { + cursorCalls.push(['listRunEventsForUser', runUserId, runId]); + return { + events: [{ + id: 'evt-1', + eventType: 'tool_gateway_result', + data: { + executor: 'cursor', + stdoutTail: '已生成 public/cursor-wechat-test-page.html', + }, + createdAt: Date.now(), + }], + }; + }, + }; + + const wechatCursorExecutorPolicyService = overrides.wechatCursorExecutorPolicyService ?? { + async getEffectivePolicy() { + return { + enabled: false, + userAllowed: false, + userAllowlist: [], + intentAllowlist: ['page.generate'], + fallbackToDeepseek: true, + }; + }, + }; + + const h5Root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-cursor-h5-')); + const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId); + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + const htmlPath = path.join(publishDir, 'public', 'cursor-wechat-test-page.html'); + fs.writeFileSync(htmlPath, 'Cursor Page
hi
'); + const now = Date.now(); + fs.utimesSync(htmlPath, now / 1000, now / 1000); + + const service = createWechatMpService({ + h5Root, + config: { + enabled: true, + appId: 'wx_local_test_only', + appSecret: 'local-secret', + token, + publicBaseUrl: 'https://example.com', + mediaPublicBaseUrl: 'https://example.com', + bindPath: '/auth/wechat/authorize?intent=login', + ackText: 'ack', + unsupportedText: 'unsupported', + unboundTextPrefix: '请先绑定', + progressDelayMs: 0, + requireFreshPageThumbnail: false, + ...(overrides.config ?? {}), + }, + userAuth: { + async findWechatUserByOpenid(_appId, openid) { + return { + userId, + status: 'active', + nickname: '测试用户', + username: 'test-user', + openid, + }; + }, + async getWechatAgentRoute() { + return { agentSessionId: 'session-cursor-1', isNewSession: false }; + }, + async clearWechatAgentRoute() {}, + async canUseChat() { + return { ok: true }; + }, + async resolveWorkingDir() { + return publishDir; + }, + async getAgentSessionPolicy() { + return { enableContextMemory: false, extensionOverrides: [], unrestricted: true }; + }, + async getUserPublishLayout() { + return { displayName: '测试用户', username: 'test-user', slug: 'test-user', constraints: null }; + }, + async registerAgentSession() {}, + async upsertWechatAgentRoute() {}, + async billSessionUsage() {}, + async recordWechatMpMessage() { + return { inserted: true }; + }, + async finishWechatMpMessage() {}, + async insertWechatMpMessageDetail() {}, + ...(overrides.userAuth ?? {}), + }, + agentRunGateway, + wechatCursorExecutorPolicyService, + sessionApiFetch: async (sessionId, pathname, init = {}) => { + apiCalls.push([pathname, init.method ?? 'GET']); + if (pathname.endsWith('/events')) { + return new Response( + [ + 'data: {"type":"Message","request_id":"req-fallback-1","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"DeepSeek 回退页面已完成\\nhttps://example.com/MindSpace/user-cursor-test/public/fallback-page.html"}]}}\n\n', + 'data: {"type":"Finish","request_id":"req-fallback-1","token_state":{"inputTokens":1,"outputTokens":2}}\n\n', + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + if (pathname.endsWith('/reply')) { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname.includes('/agent/harness_remember') || pathname.includes('/agent/session/reconcile') || pathname.includes('/agent/harness_bootstrap')) { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + throw new Error(`unexpected api path: ${pathname} session=${sessionId}`); + }, + wechatFetch: async (url, init = {}) => { + wechatCalls.push([String(url), init.method ?? 'GET', init]); + if (String(url).includes('/cgi-bin/stable_token')) { + return new Response(JSON.stringify({ access_token: 'local-access-token', 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}`); + }, + linkExists: async (urlText) => String(urlText).includes('cursor-wechat-test-page.html') + || String(urlText).includes('fallback-page.html'), + ...(overrides.extra ?? {}), + }); + + return { + service, + apiCalls, + wechatCalls, + cursorCalls, + publishDir, + token, + }; +} + +test('non-allowlisted wechat user keeps deepseek session reply path', async () => { + const { service, apiCalls, cursorCalls, token } = createBoundWechatService(); + const timestamp = '1710000000'; + const nonce = 'nonce-no-cursor'; + const result = await service.handleInboundMessage( + inboundXml({ content: '帮我做一个简单测试页面' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + assert.equal(result.status, 200); + await result.task; + assert.equal(cursorCalls.some(([kind]) => kind === 'createRun'), false); + assert.equal(apiCalls.some(([pathname]) => String(pathname).endsWith('/reply')), true); +}); + +test('allowlisted wechat user uses cursor agent-run path without session reply', async () => { + const { service, apiCalls, cursorCalls, wechatCalls, token } = createBoundWechatService({ + wechatCursorExecutorPolicyService: { + async getEffectivePolicy(_userId, user) { + return { + enabled: true, + userAllowed: true, + userAllowlist: [user?.userId ?? 'user-cursor-test'], + intentAllowlist: ['page.generate'], + fallbackToDeepseek: true, + }; + }, + }, + }); + const timestamp = '1710000001'; + const nonce = 'nonce-cursor'; + const result = await service.handleInboundMessage( + inboundXml({ content: '帮我做一个简单测试页面' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + assert.equal(result.status, 200); + await result.task; + assert.equal(cursorCalls.some(([kind]) => kind === 'createRun'), true); + assert.equal(apiCalls.some(([pathname]) => String(pathname).endsWith('/reply')), false); + const customSend = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); + assert.ok(customSend, 'expected wechat custom send'); + const sendBody = JSON.parse(String(customSend[2]?.body ?? '{}')); + const outboundText = String(sendBody?.text?.content ?? ''); + assert.doesNotMatch(outboundText, /没有按服务号页面技能真正生成成功/); + assert.match(outboundText, /cursor-wechat-test-page\.html/); +}); + +test('allowlisted user falls back to deepseek when cursor run fails', async () => { + const { service, apiCalls, cursorCalls, token } = createBoundWechatService({ + agentRunGateway: { + async createRun(runUserId, payload) { + cursorCalls.push(['createRun', runUserId, payload]); + return { id: 'run-fail-1', status: 'queued' }; + }, + dispatchRun(runId) { + cursorCalls.push(['dispatchRun', runId]); + }, + async getRunForUser(runUserId, runId) { + cursorCalls.push(['getRunForUser', runUserId, runId]); + return { id: runId, status: 'failed', error: 'simulated cursor failure' }; + }, + async listRunEventsForUser() { + return { events: [] }; + }, + }, + wechatCursorExecutorPolicyService: { + async getEffectivePolicy() { + return { + enabled: true, + userAllowed: true, + userAllowlist: ['user-cursor-test'], + intentAllowlist: ['page.generate'], + fallbackToDeepseek: true, + }; + }, + }, + }); + const timestamp = '1710000002'; + const nonce = 'nonce-fallback'; + const result = await service.handleInboundMessage( + inboundXml({ content: '帮我做一个简单测试页面' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + assert.equal(result.status, 200); + await result.task; + assert.equal(cursorCalls.some(([kind]) => kind === 'createRun'), true); + assert.equal(apiCalls.some(([pathname]) => String(pathname).endsWith('/reply')), true); +}); + +test('production-like openid in script default is not used when policy disabled', async () => { + const prodLikeOpenid = 'ooil-0VFj68QK1tkHl39uL610et8'; + const { service, cursorCalls, token } = createBoundWechatService({ + userId: '1c99b83b-0454-474f-a5d2-129d34506a32', + userAuth: { + async findWechatUserByOpenid(_appId, openid) { + assert.equal(openid, prodLikeOpenid); + return { + userId: '1c99b83b-0454-474f-a5d2-129d34506a32', + status: 'active', + nickname: 'John', + username: 'john', + }; + }, + }, + }); + const timestamp = '1710000003'; + const nonce = 'nonce-prod-openid'; + const result = await service.handleInboundMessage( + inboundXml({ fromUser: prodLikeOpenid, content: '你好' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + assert.equal(result.status, 200); + await result.task; + assert.equal(cursorCalls.length, 0); +}); diff --git a/wechat-cursor-executor-admin-config.mjs b/wechat-cursor-executor-admin-config.mjs new file mode 100644 index 0000000..41089f3 --- /dev/null +++ b/wechat-cursor-executor-admin-config.mjs @@ -0,0 +1,220 @@ +const CONFIG_TABLE = 'h5_wechat_cursor_executor_config'; +const CONFIG_SCOPE = 'global'; +const POLICY_SOURCE_DEFAULT = 'default'; +const POLICY_SOURCE_ADMIN_DB = 'admin-db'; + +const DEFAULT_INTENT_ALLOWLIST = Object.freeze(['page.generate']); + +function defaultConfigShape() { + return { + enabled: false, + userAllowlist: [], + intentAllowlist: [...DEFAULT_INTENT_ALLOWLIST], + fallbackToDeepseek: true, + meta: { + notes: '', + }, + }; +} + +function normalizeBoolean(value, fallback = false) { + if (value == null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function normalizeStringList(value) { + if (!Array.isArray(value)) { + return String(value ?? '') + .split(/[\n,]+/) + .map((item) => item.trim()) + .filter(Boolean); + } + return value.map((item) => String(item ?? '').trim()).filter(Boolean); +} + +function cloneConfig(config = null) { + return structuredClone?.(config ?? defaultConfigShape()) + ?? JSON.parse(JSON.stringify(config ?? defaultConfigShape())); +} + +function parseJsonLike(value, fallback) { + if (value == null || value === '') return fallback; + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return fallback; + } + } + if (typeof value === 'object') return value; + return fallback; +} + +function mergePatch(currentConfig, patch = {}) { + const next = cloneConfig(currentConfig); + if ('enabled' in patch) next.enabled = normalizeBoolean(patch.enabled, false); + if ('userAllowlist' in patch) next.userAllowlist = normalizeStringList(patch.userAllowlist); + if ('intentAllowlist' in patch) { + const intents = normalizeStringList(patch.intentAllowlist); + next.intentAllowlist = intents.length ? intents : [...DEFAULT_INTENT_ALLOWLIST]; + } + if ('fallbackToDeepseek' in patch) { + next.fallbackToDeepseek = normalizeBoolean(patch.fallbackToDeepseek, true); + } + if (patch?.meta && typeof patch.meta.notes === 'string') { + next.meta.notes = patch.meta.notes; + } + return next; +} + +function flattenPolicy(config, source) { + const intentAllowlist = normalizeStringList(config.intentAllowlist); + return { + source, + enabled: Boolean(config.enabled), + userAllowlist: normalizeStringList(config.userAllowlist), + intentAllowlist: intentAllowlist.length ? intentAllowlist : [...DEFAULT_INTENT_ALLOWLIST], + fallbackToDeepseek: config.fallbackToDeepseek !== false, + }; +} + +export function isUserAllowedByWechatCursorPolicy(user, policy) { + if (!policy?.enabled) return false; + const allowlist = normalizeStringList(policy.userAllowlist).map((item) => item.toLowerCase()); + if (allowlist.length === 0) return false; + if (allowlist.includes('*')) return true; + const identities = [ + user?.userId, + user?.id, + user?.username, + user?.slug, + user?.displayName, + user?.nickname, + ] + .map((value) => String(value ?? '').trim().toLowerCase()) + .filter(Boolean); + return identities.some((identity) => allowlist.includes(identity)); +} + +export function isIntentAllowedByWechatCursorPolicy(intentKind, policy) { + const allowlist = (policy?.intentAllowlist ?? DEFAULT_INTENT_ALLOWLIST) + .map((item) => String(item ?? '').trim()) + .filter(Boolean); + const normalized = String(intentKind ?? '').trim(); + if (!normalized) return false; + return allowlist.includes(normalized); +} + +async function ensureConfigTable(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} ( + config_scope VARCHAR(32) PRIMARY KEY, + config_json JSON NOT NULL, + updated_by CHAR(36) NULL, + updated_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + +async function loadStoredState(pool) { + await ensureConfigTable(pool); + const [rows] = await pool.query( + `SELECT config_json, updated_by, updated_at + FROM ${CONFIG_TABLE} + WHERE config_scope = ? + LIMIT 1`, + [CONFIG_SCOPE], + ); + const row = rows[0]; + if (!row) return null; + const parsed = parseJsonLike(row.config_json, {}); + return { + config: mergePatch(defaultConfigShape(), parsed), + updatedAt: Number(row.updated_at ?? 0) || null, + updatedBy: row.updated_by ?? null, + }; +} + +export function createWechatCursorExecutorAdminConfigService(pool) { + async function loadEffectiveConfig() { + const stored = await loadStoredState(pool); + if (stored) { + return { + config: cloneConfig(stored.config), + updatedAt: stored.updatedAt, + updatedBy: stored.updatedBy, + source: POLICY_SOURCE_ADMIN_DB, + }; + } + return { + config: defaultConfigShape(), + updatedAt: null, + updatedBy: null, + source: POLICY_SOURCE_DEFAULT, + }; + } + + return { + async getAdminConfig() { + const state = await loadEffectiveConfig(); + return { + config: state.config, + updatedAt: state.updatedAt, + updatedBy: state.updatedBy, + source: state.source, + }; + }, + + async updateAdminConfig(patch = {}, { updatedBy = null } = {}) { + const stored = await loadStoredState(pool); + const base = stored?.config ?? defaultConfigShape(); + const nextConfig = mergePatch(base, patch.config ?? patch); + await ensureConfigTable(pool); + const now = Date.now(); + await pool.query( + `INSERT INTO ${CONFIG_TABLE} + (config_scope, config_json, updated_by, updated_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + updated_by = VALUES(updated_by), + updated_at = VALUES(updated_at)`, + [CONFIG_SCOPE, JSON.stringify(nextConfig), updatedBy, now], + ); + return this.getAdminConfig(); + }, + + async getRuntimeState() { + const state = await loadEffectiveConfig(); + const policy = flattenPolicy(state.config, state.source); + return { + source: state.source, + updatedAt: state.updatedAt, + updatedBy: state.updatedBy, + config: state.config, + policy, + }; + }, + + async getEffectivePolicy(userId, user = null) { + const state = await loadEffectiveConfig(); + const policy = flattenPolicy(state.config, state.source); + const subject = user ?? { userId }; + return { + ...policy, + userAllowed: isUserAllowedByWechatCursorPolicy(subject, policy), + }; + }, + }; +} + +export const wechatCursorExecutorAdminConfigInternals = { + CONFIG_TABLE, + defaultConfigShape, + mergePatch, + flattenPolicy, +}; diff --git a/wechat-cursor-executor-policy.mjs b/wechat-cursor-executor-policy.mjs new file mode 100644 index 0000000..d44f224 --- /dev/null +++ b/wechat-cursor-executor-policy.mjs @@ -0,0 +1,17 @@ +import { + isIntentAllowedByWechatCursorPolicy, + isUserAllowedByWechatCursorPolicy, +} from './wechat-cursor-executor-admin-config.mjs'; + +export function resolveWechatCursorExecutorEligible({ + user = null, + userId = null, + intentKind = '', + policy = null, +} = {}) { + if (!policy?.enabled) return false; + const subject = user ?? { userId }; + if (!isUserAllowedByWechatCursorPolicy(subject, policy)) return false; + if (!isIntentAllowedByWechatCursorPolicy(intentKind, policy)) return false; + return true; +} diff --git a/wechat-cursor-executor-policy.test.mjs b/wechat-cursor-executor-policy.test.mjs new file mode 100644 index 0000000..5227dc6 --- /dev/null +++ b/wechat-cursor-executor-policy.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createWechatCursorExecutorAdminConfigService, + isUserAllowedByWechatCursorPolicy, + isIntentAllowedByWechatCursorPolicy, +} from './wechat-cursor-executor-admin-config.mjs'; +import { resolveWechatCursorExecutorEligible } from './wechat-cursor-executor-policy.mjs'; + +function createMemoryPool() { + const rows = new Map(); + return { + async query(sql, params = []) { + const normalized = String(sql).replace(/\s+/g, ' ').trim(); + if (normalized.startsWith('CREATE TABLE')) return [[]]; + if (normalized.startsWith('INSERT INTO h5_wechat_cursor_executor_config')) { + rows.set('global', { + config_json: params[1], + updated_by: params[2], + updated_at: params[3], + }); + return [{ affectedRows: 1 }]; + } + if (normalized.startsWith('SELECT config_json')) { + const row = rows.get('global'); + return [row ? [row] : []]; + } + throw new Error(`Unexpected SQL: ${normalized}`); + }, + }; +} + +test('default policy disables cursor channel for everyone', async () => { + const service = createWechatCursorExecutorAdminConfigService(createMemoryPool()); + const policy = await service.getEffectivePolicy('user-1', { userId: 'user-1' }); + assert.equal(policy.enabled, false); + assert.equal(policy.userAllowed, false); + assert.deepEqual(policy.intentAllowlist, ['page.generate']); +}); + +test('allowlisted user can use cursor channel for page.generate only', async () => { + const pool = createMemoryPool(); + const service = createWechatCursorExecutorAdminConfigService(pool); + await service.updateAdminConfig({ + enabled: true, + userAllowlist: ['john-uuid'], + intentAllowlist: ['page.generate'], + }, { updatedBy: 'admin-1' }); + + const policy = await service.getEffectivePolicy('john-uuid', { userId: 'john-uuid' }); + assert.equal(policy.userAllowed, true); + assert.equal( + resolveWechatCursorExecutorEligible({ + user: { userId: 'john-uuid' }, + intentKind: 'page.generate', + policy, + }), + true, + ); + assert.equal( + resolveWechatCursorExecutorEligible({ + user: { userId: 'john-uuid' }, + intentKind: 'chat.general', + policy, + }), + false, + ); + assert.equal( + resolveWechatCursorExecutorEligible({ + user: { userId: 'other-user' }, + intentKind: 'page.generate', + policy, + }), + false, + ); +}); + +test('isUserAllowedByWechatCursorPolicy matches username aliases', () => { + const policy = { enabled: true, userAllowlist: ['john'] }; + assert.equal( + isUserAllowedByWechatCursorPolicy({ userId: 'x', username: 'john' }, policy), + true, + ); + assert.equal(isUserAllowedByWechatCursorPolicy({ userId: 'x' }, policy), false); +}); + +test('isIntentAllowedByWechatCursorPolicy respects allowlist', () => { + const policy = { intentAllowlist: ['page.generate'] }; + assert.equal(isIntentAllowedByWechatCursorPolicy('page.generate', policy), true); + assert.equal(isIntentAllowedByWechatCursorPolicy('chat.general', policy), false); +}); diff --git a/wechat-cursor-page-delivery.mjs b/wechat-cursor-page-delivery.mjs new file mode 100644 index 0000000..23a796e --- /dev/null +++ b/wechat-cursor-page-delivery.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import { prepareWechatHtmlDeliveryAtWorkspace } from './mindspace-wechat-html-delivery.mjs'; +import { resolveArtifactLocalPath } from './wechat/verify/page-artifact.mjs'; +import { + extractPageTitle, + repairArtifactSharePreview, + repairSharePreviewHtml, +} from './wechat/verify/share-preview-repair.mjs'; + +const MIN_CURSOR_PAGE_BYTES = 512; + +export function isWechatCursorChannelReply(reply) { + return reply?.wechatCursorChannel === true; +} + +function padHtmlToMinBytes(html, minBytes = MIN_CURSOR_PAGE_BYTES) { + let next = String(html ?? ''); + if (Buffer.byteLength(next, 'utf8') >= minBytes) return next; + const deficit = minBytes - Buffer.byteLength(next, 'utf8') + 32; + const pad = `${' '.repeat(Math.max(0, deficit))}`; + if (/<\/body>/i.test(next)) { + return next.replace(/<\/body>/i, `${pad}`); + } + if (/<\/html>/i.test(next)) { + return next.replace(/<\/html>/i, `${pad}`); + } + return `${next}${pad}`; +} + +function normalizeCursorArtifactForWechatDelivery(artifact, { publishDir, topic }) { + const localPath = resolveArtifactLocalPath(artifact, publishDir); + if (!localPath) return artifact; + try { + if (!fs.existsSync(localPath)) return artifact; + const title = extractPageTitle(fs.readFileSync(localPath, 'utf8')); + let content = fs.readFileSync(localPath, 'utf8'); + content = padHtmlToMinBytes(content); + content = repairSharePreviewHtml(content, { title, topic }); + fs.writeFileSync(localPath, content, 'utf8'); + repairArtifactSharePreview( + { ...artifact, localPath }, + { topic, publishDir }, + ); + } catch { + // Best-effort repair; delivery validation will fail closed if still unusable. + } + return artifact; +} + +export function prepareWechatCursorPageDelivery({ + reply, + intent, + publishDir, + requestStartedAt, + buildCanonicalUrl, + topic = '', +}) { + const baseReply = { + ...reply, + wechatCursorChannel: true, + }; + if (!String(publishDir ?? '').trim()) { + return baseReply; + } + + const prepared = prepareWechatHtmlDeliveryAtWorkspace({ + reply: baseReply, + intent, + publishDir, + requestStartedAt, + allowRecentArtifacts: true, + buildCanonicalUrl, + }); + + const candidateArtifacts = [ + ...(prepared.recentArtifacts ?? []), + ...(prepared.confirmedArtifacts ?? []), + ...(prepared.publishedArtifacts ?? []), + ]; + for (const artifact of candidateArtifacts) { + normalizeCursorArtifactForWechatDelivery(artifact, { publishDir, topic }); + } + + const reprepared = prepareWechatHtmlDeliveryAtWorkspace({ + reply: baseReply, + intent, + publishDir, + requestStartedAt, + allowRecentArtifacts: true, + buildCanonicalUrl, + }); + const confirmedArtifacts = reprepared.confirmedArtifacts ?? []; + const urls = confirmedArtifacts + .map((artifact) => String(artifact?.url ?? '').trim()) + .filter(Boolean); + const uniqueUrls = [...new Set(urls)]; + + return { + ...baseReply, + text: uniqueUrls.length > 0 + ? [String(baseReply.text ?? '').trim(), ...uniqueUrls].filter(Boolean).join('\n') + : String(baseReply.text ?? '').trim(), + cursorPreparedArtifacts: confirmedArtifacts, + }; +} diff --git a/wechat-cursor-page-delivery.test.mjs b/wechat-cursor-page-delivery.test.mjs new file mode 100644 index 0000000..4d8d96a --- /dev/null +++ b/wechat-cursor-page-delivery.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + isWechatCursorChannelReply, + prepareWechatCursorPageDelivery, +} from './wechat-cursor-page-delivery.mjs'; + +test('prepareWechatCursorPageDelivery repairs recent cursor html for wechat delivery', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-cursor-delivery-')); + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + const htmlPath = path.join(publishDir, 'public', 'cursor-page.html'); + fs.writeFileSync(htmlPath, 'Cursor Page
hi
'); + const now = Date.now(); + fs.utimesSync(htmlPath, now / 1000, now / 1000); + + const reply = prepareWechatCursorPageDelivery({ + reply: { + text: '已由 TKMind 智趣完成执行,并通过平台文件验收。', + requestMessages: [], + }, + intent: { agentText: '帮我做一个简单测试页面', displayText: '帮我做一个简单测试页面' }, + publishDir, + requestStartedAt: now - 1000, + topic: '简单测试页面', + buildCanonicalUrl: (relativePath) => + `https://example.com/MindSpace/user-1/${String(relativePath).replace(/^\/+/, '')}`, + }); + + assert.equal(isWechatCursorChannelReply(reply), true); + assert.match(reply.text, /https:\/\/example.com\/MindSpace\/user-1\//); + const content = fs.readFileSync(htmlPath, 'utf8'); + assert.match(content, /mindspace-cover/i); + assert.match(content, /platform-brand/i); + assert.ok(Buffer.byteLength(content, 'utf8') >= 512); +});