diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 8b9ccb0..5a78dca 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -168,7 +168,7 @@ export async function createAdminServices(env = {}) { }); const notificationDispatcher = createNotificationDispatcher({ sendWechatTextToUser: wechatMpService?.enabled - ? (userId, text) => wechatMpService.sendTextToUser(userId, text) + ? (userId, text, options) => wechatMpService.sendTextToUser(userId, text, options) : null, }); userAuth.setRechargeNotifier(async ({ userId, title, body, dedupeKey }) => { diff --git a/mindspace-delivery-contract.mjs b/mindspace-delivery-contract.mjs index 8885ae7..4eba86d 100644 --- a/mindspace-delivery-contract.mjs +++ b/mindspace-delivery-contract.mjs @@ -1,4 +1,6 @@ import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; export function normalizeDeliveryRelativePath(value) { const path = String(value ?? '').replace(/\\/g, '/').replace(/^\/+/, ''); @@ -65,17 +67,27 @@ export async function markPageDeliveryContractFailed({ return Number(result?.affectedRows ?? 0) > 0; } +export function scheduledTaskPublicHtmlExists(publishDir, relativePath) { + const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath); + if (!publishDir || !workspaceRelativePath) return false; + return fs.existsSync(path.join(publishDir, workspaceRelativePath)); +} + export async function releaseMaterializedPageDeliveryContracts({ pool, userId, relativePaths = [], allowPgRequired = false, + publishDir = null, } = {}) { if (!pool || !userId) return []; const released = []; for (const rawPath of relativePaths) { const workspaceRelativePath = normalizeDeliveryRelativePath(rawPath); if (!workspaceRelativePath) continue; + if (publishDir && !scheduledTaskPublicHtmlExists(publishDir, workspaceRelativePath)) { + continue; + } const contract = await getPageDeliveryContract({ pool, userId, diff --git a/notification-dispatcher.mjs b/notification-dispatcher.mjs index 7cb32ab..26436aa 100644 --- a/notification-dispatcher.mjs +++ b/notification-dispatcher.mjs @@ -1,8 +1,15 @@ +function resolveWechatDispatchSent(result) { + if (result && typeof result === 'object') { + return result.sent !== false && !result.deferred && !result.skipped; + } + return result !== false; +} + export function createNotificationDispatcher({ sendWechatTextToUser, logger = console } = {}) { - const sendWechat = async (userId, text) => { + const sendWechat = async (userId, text, options = {}) => { if (typeof sendWechatTextToUser !== 'function') return false; - await sendWechatTextToUser(userId, text); - return true; + const result = await sendWechatTextToUser(userId, text, options); + return resolveWechatDispatchSent(result); }; return { @@ -16,13 +23,14 @@ export function createNotificationDispatcher({ sendWechatTextToUser, logger = co }); return sent; }, - async sendScheduleNotification({ userId, text }) { - const sent = await sendWechat(userId, text); + async sendScheduleNotification({ userId, text, verifiedHtmlUrls = [] }) { + const sent = await sendWechat(userId, text, { verifiedHtmlUrls }); logger.info?.('Notification dispatch:', { type: 'schedule_notification', userId, dedupeKey: null, sent, + verifiedHtmlCount: verifiedHtmlUrls.length, }); return sent; }, diff --git a/notification-dispatcher.test.mjs b/notification-dispatcher.test.mjs index 94139b9..76dd5e6 100644 --- a/notification-dispatcher.test.mjs +++ b/notification-dispatcher.test.mjs @@ -73,11 +73,30 @@ test('notification dispatcher forwards schedule notification text unchanged', as userId: 'user-2', dedupeKey: null, sent: true, + verifiedHtmlCount: 0, }, }, ]); }); +test('notification dispatcher returns false when wechat sender defers delivery', async () => { + const dispatcher = createNotificationDispatcher({ + async sendWechatTextToUser() { + return { sent: false, deferred: true, errcode: 45015 }; + }, + logger: { info() {} }, + }); + + assert.equal( + await dispatcher.sendScheduleNotification({ + userId: 'user-4', + text: '定时任务完成', + verifiedHtmlUrls: ['https://m.tkmind.cn/MindSpace/user-4/public/news.html'], + }), + false, + ); +}); + test('notification dispatcher returns false when wechat sender is unavailable', async () => { const logs = []; const dispatcher = createNotificationDispatcher({ @@ -120,6 +139,7 @@ test('notification dispatcher returns false when wechat sender is unavailable', userId: 'user-3', dedupeKey: null, sent: false, + verifiedHtmlCount: 0, }, }, ]); diff --git a/scheduled-task-executor.mjs b/scheduled-task-executor.mjs index e34acef..ddc6e77 100644 --- a/scheduled-task-executor.mjs +++ b/scheduled-task-executor.mjs @@ -78,6 +78,23 @@ export function formatScheduledTaskDeliveryMessage(task, deliveryText) { return `${header}\n\n${body}`.trim(); } +export function resolveScheduledTaskPublicBaseUrl(env = process.env) { + return String(env.H5_PUBLIC_BASE_URL ?? 'https://m.tkmind.cn').replace(/\/+$/, ''); +} + +export function buildScheduledTaskVerifiedHtmlUrls(userId, readyPaths = [], { + publicBaseUrl = resolveScheduledTaskPublicBaseUrl(), +} = {}) { + const normalizedUserId = String(userId ?? '').trim(); + if (!normalizedUserId) return []; + return [...new Set( + (Array.isArray(readyPaths) ? readyPaths : []) + .map((relativePath) => normalizeDeliveryRelativePath(relativePath)) + .filter(Boolean) + .map((relativePath) => `${publicBaseUrl}/MindSpace/${normalizedUserId}/${relativePath}`), + )]; +} + const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [ /需确认/u, /请确认/u, @@ -90,30 +107,24 @@ const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [ /缺(?:少|失)/u, ]; -const DEFAULT_SCHEDULED_TASK_DELIVERY_RETRY_DELAYS_MS = [ - 250, - 1_000, - 3_000, - 5_000, - 10_000, - 30_000, - 60_000, -]; +const DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS = 5_000; -export function resolveScheduledTaskDeliveryRetryDelaysMs( +export function resolveScheduledTaskDeliveryPollIntervalMs( env = process.env, ) { - const raw = String( - env.H5_SCHEDULED_TASK_DELIVERY_RETRY_DELAYS_MS ?? '', - ).trim(); - if (!raw) return [...DEFAULT_SCHEDULED_TASK_DELIVERY_RETRY_DELAYS_MS]; - const parsed = raw - .split(',') - .map((value) => Number(value.trim())) - .filter((value) => Number.isFinite(value) && value >= 0); - return parsed.length > 0 + const parsed = Number( + env.H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS + ?? DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS, + ); + return Number.isFinite(parsed) && parsed > 0 ? parsed - : [...DEFAULT_SCHEDULED_TASK_DELIVERY_RETRY_DELAYS_MS]; + : DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS; +} + +/** @deprecated use resolveScheduledTaskDeliveryPollIntervalMs */ +export function resolveScheduledTaskDeliveryRetryDelaysMs(env = process.env) { + const interval = resolveScheduledTaskDeliveryPollIntervalMs(env); + return [0, interval, interval, interval]; } export function extractPublicHtmlPathsFromText(text) { @@ -260,6 +271,7 @@ export async function finalizeScheduledTaskPageDelivery({ userId, relativePaths: [...pathsToRelease], allowPgRequired: true, + publishDir, }).catch((error) => { logger.warn?.('[ScheduledTask] release delivery contracts failed:', error); return []; @@ -281,57 +293,72 @@ export async function awaitScheduledTaskPageDelivery({ messages, publishDir, deliveryText = '', + task = null, tkmindProxy = null, sessionSnapshotService = null, - retryDelaysMs = resolveScheduledTaskDeliveryRetryDelaysMs(), + timeoutMs = 15 * 60 * 1000, + pollIntervalMs = resolveScheduledTaskDeliveryPollIntervalMs(), sleepFn = (delayMs) => new Promise((resolve) => { setTimeout(resolve, delayMs); }), logger = console, } = {}) { let currentMessages = Array.isArray(messages) ? messages : []; + let currentDeliveryText = String(deliveryText ?? ''); let readyPaths = []; - const attempts = [0, ...retryDelaysMs]; + const deadline = Date.now() + Math.max(Number(pollIntervalMs) || 0, Number(timeoutMs) || 0); + let attempt = 0; - for (let attempt = 0; attempt < attempts.length; attempt += 1) { - if (attempt > 0) { - await sleepFn(attempts[attempt]); - currentMessages = await refreshScheduledTaskMessages({ - userId, - sessionId, - tkmindProxy, - sessionSnapshotService, - }); - } + while (true) { + attempt += 1; readyPaths = await finalizeScheduledTaskPageDelivery({ pool, userId, sessionId, messages: currentMessages, publishDir, - deliveryText, + deliveryText: currentDeliveryText, logger, }).catch((error) => { logger.warn?.('[ScheduledTask] finalize page delivery failed:', error); return []; }); - const promisesHtml = deliveryTextPromisesPublicHtml(deliveryText) + const promisesHtml = deliveryTextPromisesPublicHtml(currentDeliveryText) || collectScheduledTaskPageRelativePaths({ messages: currentMessages, publishDir, userId, - deliveryText, + deliveryText: currentDeliveryText, }).relativePaths.length > 0; - if (!promisesHtml || readyPaths.length > 0) { + if (!promisesHtml || readyPaths.length > 0 || Date.now() >= deadline) { + if (promisesHtml && readyPaths.length === 0 && Date.now() >= deadline) { + logger.warn?.('[ScheduledTask] page delivery timed out while preparing', { + userId, + sessionId, + attempt, + timeoutMs, + }); + } break; } + logger.warn?.('[ScheduledTask] page delivery still preparing', { userId, sessionId, - attempt: attempt + 1, - maxAttempts: attempts.length, + attempt, + nextPollMs: pollIntervalMs, }); + await sleepFn(pollIntervalMs); + currentMessages = await refreshScheduledTaskMessages({ + userId, + sessionId, + tkmindProxy, + sessionSnapshotService, + }); + if (task) { + currentDeliveryText = extractScheduledTaskDeliveryText(currentMessages, task); + } } return { @@ -390,7 +417,7 @@ export async function reconcileStuckStaticPageDeliveryContracts({ relativePath, }) ) { - released.push(relativePath); + released.push({ userId: row.user_id, relativePath }); logger.info?.('[ScheduledTask] reconciled static delivery contract', { userId: row.user_id, relativePath, @@ -400,6 +427,105 @@ export async function reconcileStuckStaticPageDeliveryContracts({ return released; } +export async function resendScheduledTaskWechatForReadyPage({ + pool, + userId, + relativePath, + notificationDispatcher = null, + publicBaseUrl = resolveScheduledTaskPublicBaseUrl(), + lookbackMs = 24 * 60 * 60 * 1000, + now = Date.now(), + logger = console, +} = {}) { + const normalizedPath = normalizeDeliveryRelativePath(relativePath); + const normalizedUserId = String(userId ?? '').trim(); + if ( + !pool + || !normalizedUserId + || !normalizedPath + || typeof notificationDispatcher?.sendScheduleNotification !== 'function' + ) { + return false; + } + + const [rows] = await pool.query( + `SELECT id, title, notify_channel, last_result_json, last_run_at + FROM h5_scheduled_tasks + WHERE user_id = ? AND last_run_at IS NOT NULL AND last_run_at >= ? + ORDER BY last_run_at DESC + LIMIT 20`, + [normalizedUserId, now - Math.max(lookbackMs, 60_000)], + ); + + const verifiedUrl = `${publicBaseUrl}/MindSpace/${normalizedUserId}/${normalizedPath}`; + for (const row of rows ?? []) { + const channel = row.notify_channel ?? 'both'; + if (channel !== 'wechat' && channel !== 'both') continue; + + const lastResult = row.last_result_json && typeof row.last_result_json === 'object' + ? row.last_result_json + : null; + const deliveryText = String(lastResult?.deliveryText ?? ''); + if ( + !deliveryText.includes(normalizedPath) + && !deliveryText.includes(verifiedUrl) + ) { + continue; + } + + const priorDelivery = lastResult?.wechatDelivery; + if ( + priorDelivery?.sentAt + && Array.isArray(priorDelivery.relativePaths) + && priorDelivery.relativePaths.includes(normalizedPath) + ) { + continue; + } + + const text = formatScheduledTaskDeliveryMessage( + { title: row.title }, + deliveryText, + ); + const verifiedHtmlUrls = buildScheduledTaskVerifiedHtmlUrls( + normalizedUserId, + [normalizedPath], + { publicBaseUrl }, + ); + const sent = await notificationDispatcher.sendScheduleNotification({ + userId: normalizedUserId, + text, + verifiedHtmlUrls, + }).catch((error) => { + logger.warn?.('[ScheduledTask] reconcile wechat resend failed:', error); + return false; + }); + if (!sent) continue; + + const nextResult = { + ...(lastResult ?? {}), + wechatDelivery: { + sentAt: now, + relativePaths: [normalizedPath], + source: 'reconcile', + }, + }; + await pool.query( + `UPDATE h5_scheduled_tasks + SET last_result_json = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [JSON.stringify(nextResult), now, row.id, normalizedUserId], + ); + logger.info?.('[ScheduledTask] reconciled wechat delivery resent', { + userId: normalizedUserId, + taskId: row.id, + relativePath: normalizedPath, + }); + return true; + } + + return false; +} + export async function executeScheduledTask(task, { userAuth, tkmindProxy, @@ -470,8 +596,10 @@ export async function executeScheduledTask(task, { messages, publishDir, deliveryText, + task, tkmindProxy, sessionSnapshotService, + timeoutMs, logger, }) : { messages, readyPaths: [] }; @@ -485,7 +613,7 @@ export async function executeScheduledTask(task, { && task.userId ) { const links = readyPaths.map( - (relativePath) => `https://m.tkmind.cn/MindSpace/${task.userId}/${relativePath}`, + (relativePath) => `${resolveScheduledTaskPublicBaseUrl()}/MindSpace/${task.userId}/${relativePath}`, ); deliveryText = `${deliveryText}\n\n页面链接:\n${links.join('\n')}`.trim(); } diff --git a/scheduled-task-executor.test.mjs b/scheduled-task-executor.test.mjs index 1ca5946..3e50253 100644 --- a/scheduled-task-executor.test.mjs +++ b/scheduled-task-executor.test.mjs @@ -5,6 +5,7 @@ import path from 'node:path'; import test from 'node:test'; import { buildScheduledTaskExecutionPrompt, + buildScheduledTaskVerifiedHtmlUrls, deliveryTextPromisesPublicHtml, extractPublicHtmlPathsFromText, extractScheduledTaskDeliveryText, @@ -12,6 +13,7 @@ import { formatScheduledTaskDeliveryMessage, looksLikeScheduledTaskNonDelivery, reconcileStuckStaticPageDeliveryContracts, + resolveScheduledTaskDeliveryPollIntervalMs, resolveScheduledTaskDeliveryRetryDelaysMs, } from './scheduled-task-executor.mjs'; @@ -75,22 +77,45 @@ test('deliveryTextPromisesPublicHtml detects page delivery replies', () => { ); }); -test('resolveScheduledTaskDeliveryRetryDelaysMs reads env override', () => { +test('resolveScheduledTaskDeliveryPollIntervalMs reads env override', () => { + assert.equal( + resolveScheduledTaskDeliveryPollIntervalMs({ + H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS: '7500', + }), + 7500, + ); +}); + +test('resolveScheduledTaskDeliveryRetryDelaysMs keeps backward-compatible shape', () => { assert.deepEqual( resolveScheduledTaskDeliveryRetryDelaysMs({ - H5_SCHEDULED_TASK_DELIVERY_RETRY_DELAYS_MS: '0,100,250', + H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS: '100', }), - [0, 100, 250], + [0, 100, 100, 100], + ); +}); + +test('buildScheduledTaskVerifiedHtmlUrls maps ready paths to public urls', () => { + assert.deepEqual( + buildScheduledTaskVerifiedHtmlUrls('user-1', ['public/news.html'], { + publicBaseUrl: 'https://m.tkmind.cn', + }), + ['https://m.tkmind.cn/MindSpace/user-1/public/news.html'], ); }); test('finalizeScheduledTaskPageDelivery prepares and releases static contracts', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-finalize-')); + const publishDir = path.join(dir, 'MindSpace', 'user-1'); + const relativePath = 'public/news.html'; + await fs.mkdir(path.dirname(path.join(publishDir, relativePath)), { recursive: true }); + await fs.writeFile(path.join(publishDir, relativePath), '', 'utf8'); const calls = []; const pool = { async query(sql, params) { calls.push({ sql, params }); if (sql.includes('FROM h5_page_delivery_contracts')) { - return [[{ workspace_relative_path: 'public/news.html' }]]; + return [[{ workspace_relative_path: relativePath }]]; } if (sql.includes('SELECT id, data_mode, status')) { return [[{ id: 'c1', data_mode: 'static', status: 'preparing' }]]; @@ -106,14 +131,43 @@ test('finalizeScheduledTaskPageDelivery prepares and releases static contracts', userId: 'user-1', sessionId: 'session-1', messages: [], - publishDir: '/tmp/publish', + publishDir, deliveryText: 'public/news.html 已生成', logger: { warn() {}, info() {} }, }); - assert.deepEqual(readyPaths, ['public/news.html']); + assert.deepEqual(readyPaths, [relativePath]); assert.ok(calls.some((call) => call.sql.includes('INSERT INTO h5_page_delivery_contracts'))); }); +test('finalizeScheduledTaskPageDelivery skips release when html file is missing', async () => { + const publishDir = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-finalize-missing-')), 'MindSpace', 'user-1'); + await fs.mkdir(publishDir, { recursive: true }); + const pool = { + async query(sql) { + if (sql.includes('FROM h5_page_delivery_contracts')) { + return [[{ workspace_relative_path: 'public/news.html' }]]; + } + if (sql.includes('SELECT id, data_mode, status')) { + return [[{ id: 'c1', data_mode: 'static', status: 'preparing' }]]; + } + if (sql.includes("SET status = 'ready'")) { + throw new Error('should not mark ready without html file'); + } + return [[]]; + }, + }; + const readyPaths = await finalizeScheduledTaskPageDelivery({ + pool, + userId: 'user-1', + sessionId: 'session-1', + messages: [], + publishDir, + deliveryText: 'public/news.html 已生成', + logger: { warn() {}, info() {} }, + }); + assert.deepEqual(readyPaths, []); +}); + test('reconcileStuckStaticPageDeliveryContracts releases materialized static pages', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-')); const userId = 'user-1'; @@ -137,7 +191,7 @@ test('reconcileStuckStaticPageDeliveryContracts releases materialized static pag h5Root: dir, logger: { info() {} }, }); - assert.deepEqual(released, [relativePath]); + assert.deepEqual(released, [{ userId, relativePath }]); }); test('reconcileStuckStaticPageDeliveryContracts fails orphan contracts without html', async () => { diff --git a/scheduled-task-worker.mjs b/scheduled-task-worker.mjs index f4a1038..c217c86 100644 --- a/scheduled-task-worker.mjs +++ b/scheduled-task-worker.mjs @@ -1,8 +1,11 @@ import { + buildScheduledTaskVerifiedHtmlUrls, + deliveryTextPromisesPublicHtml, executeScheduledTask, formatScheduledTaskDeliveryMessage, looksLikeScheduledTaskNonDelivery, reconcileStuckStaticPageDeliveryContracts, + resendScheduledTaskWechatForReadyPage, } from './scheduled-task-executor.mjs'; export function startScheduledTaskWorker({ @@ -36,9 +39,12 @@ export function startScheduledTaskWorker({ let stopped = false; let running = false; - const deliverTaskResult = async (task, deliveryText) => { + const deliverTaskResult = async (task, deliveryText, { readyPaths = [] } = {}) => { const text = formatScheduledTaskDeliveryMessage(task, deliveryText); const notifyChannel = task.notifyChannel ?? 'both'; + const verifiedHtmlUrls = buildScheduledTaskVerifiedHtmlUrls(task.userId, readyPaths); + const promisesHtml = deliveryTextPromisesPublicHtml(deliveryText); + let wechatDelivery = null; if (scheduleService?.createUserNotification && (notifyChannel === 'web' || notifyChannel === 'both')) { await scheduleService.createUserNotification({ userId: task.userId, @@ -58,10 +64,30 @@ export function startScheduledTaskWorker({ (notifyChannel === 'wechat' || notifyChannel === 'both') && typeof sendScheduleNotification === 'function' ) { - await sendScheduleNotification({ userId: task.userId, text }).catch((err) => { - logger.warn?.('Scheduled task wechat notification failed:', err); - }); + if (promisesHtml && verifiedHtmlUrls.length === 0) { + logger.warn?.('[ScheduledTask] skip wechat until public html is ready', { + taskId: task.id, + userId: task.userId, + }); + } else { + const sent = await sendScheduleNotification({ + userId: task.userId, + text, + verifiedHtmlUrls, + }).catch((err) => { + logger.warn?.('Scheduled task wechat notification failed:', err); + return false; + }); + if (sent && verifiedHtmlUrls.length > 0) { + wechatDelivery = { + sentAt: Date.now(), + relativePaths: readyPaths.map((value) => String(value ?? '').trim()).filter(Boolean), + source: 'scheduled_task_worker', + }; + } + } } + return { wechatDelivery }; }; const runOnce = async () => { @@ -69,13 +95,25 @@ export function startScheduledTaskWorker({ running = true; try { if (pool && h5Root) { - await reconcileStuckStaticPageDeliveryContracts({ + const reconciled = await reconcileStuckStaticPageDeliveryContracts({ pool, h5Root, logger, }).catch((err) => { logger.warn?.('Scheduled task delivery reconcile failed:', err); + return []; }); + for (const item of reconciled) { + await resendScheduledTaskWechatForReadyPage({ + pool, + userId: item.userId, + relativePath: item.relativePath, + notificationDispatcher, + logger, + }).catch((err) => { + logger.warn?.('Scheduled task reconcile wechat resend failed:', err); + }); + } } const dueTasks = await scheduledTaskService.listDueTasks({ limit: 10 }); for (const candidate of dueTasks) { @@ -99,10 +137,15 @@ export function startScheduledTaskWorker({ err.code = 'SCHEDULED_TASK_NON_DELIVERY'; throw err; } - await deliverTaskResult(task, result.deliveryText); + const deliveryMeta = await deliverTaskResult(task, result.deliveryText, { + readyPaths: result.readyPaths, + }); await scheduledTaskService.markTaskSucceeded(task, { result: { deliveryText: result.deliveryText, + ...(deliveryMeta.wechatDelivery + ? { wechatDelivery: deliveryMeta.wechatDelivery } + : {}), }, deliveryText: result.deliveryText, sessionId: result.sessionId, diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index 705831c..1e33879 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -236,10 +236,11 @@ export async function bootstrapPortalIntegrationServices({ const notificationDispatcher = createNotificationDispatcherFn({ sendWechatTextToUser: wechatMpService?.enabled - ? (userId, text) => + ? (userId, text, options) => wechatMpService.sendTextToUser( userId, text, + options, ) : null, }); diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 298c171..48a7048 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -2017,14 +2017,25 @@ export function createWechatMpService({ }); }; - const sendTextToUser = async (userId, content) => { + const sendTextToUser = async (userId, content, { verifiedHtmlUrls = [] } = {}) => { const openid = await userAuth.getWechatOpenidForUser(userId, config.appId); if (!openid) { throw new Error('用户尚未绑定服务号,无法推送提醒'); } - return sendCustomerServiceText(openid, content, null, { + const normalizedVerified = verifiedHtmlUrls + .map((url) => String(url ?? '').trim()) + .filter(Boolean); + const sendOptions = { deliveryPriority: 'formal_reply', - }); + verifiedHtmlUrls: normalizedVerified, + }; + if (normalizedVerified.length > 0) { + sendOptions.linkExistsForRequest = createPreparedPublicHtmlLinkExists({ + userId, + prepared: { validReplyUrls: normalizedVerified }, + }); + } + return sendCustomerServiceText(openid, content, null, sendOptions); }; const enforceFreshPageThumbnailDelivery = async ({ diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 436a117..deb8cd9 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -1333,6 +1333,40 @@ test('wechat mp service splits long agent replies into multiple customer message assert.equal(combined, longReply); }); +test('wechat mp sendTextToUser preserves verified MindSpace public links', async () => { + const sentBodies = []; + const service = createBoundWechatService({ + userAuth: { + async getWechatOpenidForUser() { + return 'openid-1'; + }, + }, + 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' }, + }); + } + sentBodies.push(JSON.parse(String(init?.body ?? '{}')).text.content); + return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + + const url = 'https://m.tkmind.cn/MindSpace/user-1/public/daily-news-0817.html'; + const result = await service.sendTextToUser( + 'user-1', + `页面已生成:${url}`, + { verifiedHtmlUrls: [url] }, + ); + assert.equal(result.sent, true); + assert.match(sentBodies[0], /daily-news-0817\.html/); + assert.doesNotMatch(sentBodies[0], /页面生成未完成/); +}); + test('wechat mp service defers 45047 customer-service delivery instead of throwing', async () => { const service = createBoundWechatService({ userAuth: {