From 62caf4134b606ecc5a9f48a00fc171c25e389279 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 11 Sep 2026 08:48:02 +0800 Subject: [PATCH] feat(wechat): add Portal worker for daily news morning draft auto push Run scheduled page generation before push time and write WeChat drafts from admin config so news morning reports no longer require manual pushes. Co-authored-by: Cursor --- .env.example | 9 + package.json | 2 +- .../portal-integration-services-bootstrap.mjs | 40 ++++ ...al-integration-services-bootstrap.test.mjs | 14 ++ wechat-news-morning-draft-worker-config.mjs | 26 +++ ...-news-morning-draft-worker-config.test.mjs | 34 ++++ wechat-news-morning-draft-worker.mjs | 190 ++++++++++++++++++ wechat-news-morning-draft-worker.test.mjs | 141 +++++++++++++ wechat-news-morning-draft.mjs | 169 ++++++++++++++-- 9 files changed, 607 insertions(+), 18 deletions(-) create mode 100644 wechat-news-morning-draft-worker-config.mjs create mode 100644 wechat-news-morning-draft-worker-config.test.mjs create mode 100644 wechat-news-morning-draft-worker.mjs create mode 100644 wechat-news-morning-draft-worker.test.mjs diff --git a/.env.example b/.env.example index e13ffea..042710b 100644 --- a/.env.example +++ b/.env.example @@ -212,6 +212,15 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # H5_SCHEDULED_TASK_SCAN_INTERVAL_MS=30000 # H5_SCHEDULED_TASK_MAX_ATTEMPTS=3 # H5_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS=900000 +# 新闻早报 · 微信草稿箱自动 worker(默认跟随 H5_REMINDER_WORKER_ENABLED) +# H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED=1 +# H5_WECHAT_NEWS_MORNING_DRAFT_AUTO=1 +# H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED=1 +# H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID= +# H5_WECHAT_NEWS_MORNING_DRAFT_HOUR=6 +# H5_WECHAT_NEWS_MORNING_DRAFT_MINUTE=0 +# H5_WECHAT_NEWS_MORNING_DRAFT_AUTO_GENERATE=1 +# H5_WECHAT_NEWS_MORNING_DRAFT_GENERATE_LEAD_MINUTES=60 # Intent Transaction Layer(Phase A:Draft → Confirm → Commit;默认关闭) # H5_INTENT_TRANSACTION_ENABLED=1 diff --git a/package.json b/package.json index 480c651..6bc24b0 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "verify:experience-agent-run-local": "node scripts/verify-experience-agent-run-local.mjs", "verify:experience-reflect-local": "node scripts/verify-experience-reflect-local.mjs", "test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update", - "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-voice-reco.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs scheduled-task-intent.test.mjs scheduled-task-service.test.mjs scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs scheduled-task-worker-config.test.mjs wechat/handlers/scheduled-task.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs health-channel-state.test.mjs health-intent-rules.test.mjs health-extraction.test.mjs health-observation-validate.test.mjs health-baseline-maturity.test.mjs health-publish-guard.test.mjs health-p0-experiment.test.mjs health-wechat-channel.test.mjs server/portal-health-routes.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-long-image.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-wechat-mp-config.test.mjs mindspace-wechat-page-draft.test.mjs server/portal-mindspace-wechat-routes.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-audit.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-voice-reco.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs scheduled-task-intent.test.mjs scheduled-task-service.test.mjs scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs scheduled-task-worker-config.test.mjs wechat-news-morning-draft.test.mjs wechat-news-morning-draft-worker.test.mjs wechat-news-morning-draft-worker-config.test.mjs wechat/handlers/scheduled-task.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs health-channel-state.test.mjs health-intent-rules.test.mjs health-extraction.test.mjs health-observation-validate.test.mjs health-baseline-maturity.test.mjs health-publish-guard.test.mjs health-p0-experiment.test.mjs health-wechat-channel.test.mjs server/portal-health-routes.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-long-image.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-wechat-mp-config.test.mjs mindspace-wechat-page-draft.test.mjs server/portal-mindspace-wechat-routes.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-audit.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs", "test:deep-search": "node --test deep-search.test.mjs mindsearch.test.mjs", "test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs", diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index 7c57da2..c388285 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -10,6 +10,8 @@ import { startScheduleReminderWorker } from '../schedule-reminder-worker.mjs'; import { startScheduledTaskWorker } from '../scheduled-task-worker.mjs'; import { isScheduledTaskWorkerEnabled } from '../scheduled-task-worker-config.mjs'; import { startHealthBaselineWorker } from '../health-baseline-worker.mjs'; +import { startWechatNewsMorningDraftWorker } from '../wechat-news-morning-draft-worker.mjs'; +import { createWechatNewsMorningDraftService } from '../wechat-news-morning-draft.mjs'; import { createHealthEventNotificationService } from '../health-event-notification-service.mjs'; import { isPassiveCanaryRuntime } from './portal-runtime-role.mjs'; import { loadWechatMpModule } from '../wechat-mp-loader.mjs'; @@ -72,6 +74,10 @@ export async function bootstrapPortalIntegrationServices({ startScheduledTaskWorker, startHealthBaselineWorkerFn = startHealthBaselineWorker, + startWechatNewsMorningDraftWorkerFn = + startWechatNewsMorningDraftWorker, + createWechatNewsMorningDraftServiceFn = + createWechatNewsMorningDraftService, createPageEditSessionServiceFn = createPageEditSessionService, createToolGatewayFn = createToolGateway, @@ -337,6 +343,38 @@ export async function bootstrapPortalIntegrationServices({ ); } + const wechatNewsMorningDraftService = createWechatNewsMorningDraftServiceFn(pool, { + mpConfig: wechatMpConfig, + h5Root, + memindLibRoot: codeRoot || h5Root, + env, + }); + + let wechatNewsMorningDraftWorker = null; + if ( + !isPassiveCanaryRuntime(env) + && wechatMpConfig?.enabled + && userAuth + && tkmindProxy + ) { + wechatNewsMorningDraftWorker = startWechatNewsMorningDraftWorkerFn({ + wechatNewsMorningDraftService, + mpConfig: wechatMpConfig, + userAuth, + tkmindProxy, + agentRunGateway, + cursorExecutorPolicyService: wechatCursorExecutorPolicyService, + sessionSnapshotService, + pool, + h5Root, + env, + logger, + }); + if (wechatNewsMorningDraftWorker?.runOnce) { + logger.log?.('WeChat news morning draft worker enabled'); + } + } + let healthBaselineWorker = null; if (!isPassiveCanaryRuntime(env) && healthDataRuntime) { const healthEventNotificationService = createHealthEventNotificationService({ @@ -414,6 +452,8 @@ export async function bootstrapPortalIntegrationServices({ notificationDispatcher, scheduleReminderWorker, scheduledTaskWorker, + wechatNewsMorningDraftService, + wechatNewsMorningDraftWorker, healthBaselineWorker, subscriptionExpiryTimer, mindSpacePageEditSession, diff --git a/server/portal-integration-services-bootstrap.test.mjs b/server/portal-integration-services-bootstrap.test.mjs index 6774a60..a998ca2 100644 --- a/server/portal-integration-services-bootstrap.test.mjs +++ b/server/portal-integration-services-bootstrap.test.mjs @@ -173,6 +173,14 @@ function createSetup(overrides = {}) { reminderOptions = receivedOptions; return { id: 'reminder-worker' }; }, + createWechatNewsMorningDraftServiceFn(pool, options) { + calls.push(['news-morning-draft-service', pool, options]); + return { id: 'news-morning-draft-service' }; + }, + startWechatNewsMorningDraftWorkerFn(receivedOptions) { + calls.push(['news-morning-draft-worker']); + return { id: 'news-morning-draft-worker', runOnce: async () => {} }; + }, createPageEditSessionServiceFn(receivedOptions) { calls.push(['page-edit']); pageEditOptions = receivedOptions; @@ -449,6 +457,9 @@ test('preserves notification, recharge, reminder, and page-edit wiring', async ( setup.notificationDispatcher, }); assert.equal(result.scheduleReminderWorker.id, 'reminder-worker'); + assert.equal(result.wechatNewsMorningDraftWorker.id, 'news-morning-draft-worker'); + assert.ok(setup.calls.some(([name]) => name === 'news-morning-draft-service')); + assert.ok(setup.calls.some(([name]) => name === 'news-morning-draft-worker')); assert.deepEqual(captured.pageEditOptions, { apiTarget: 'http://api', apiSecret: 'secret', @@ -498,6 +509,7 @@ test('preserves subscription timer work, logs, and unref', async () => { test('keeps optional integrations disabled and contains timer failures', async () => { const setup = createSetup({ env: {}, + wechatMpConfig: { enabled: false }, mindSpacePublicFinish: null, scheduleService: null, subscriptionService: { @@ -546,6 +558,7 @@ test('keeps optional integrations disabled and contains timer failures', async ( null, ); assert.equal(result.scheduleReminderWorker, null); + assert.equal(result.wechatNewsMorningDraftWorker, null); assert.equal( captured.notificationOptions.sendWechatTextToUser, null, @@ -575,6 +588,7 @@ test('passive candidate runtime disables singleton reminder and subscription tim const captured = setup.getCaptured(); assert.equal(result.scheduleReminderWorker, null); + assert.equal(result.wechatNewsMorningDraftWorker, null); assert.equal(result.subscriptionExpiryTimer, null); assert.equal(captured.reminderOptions, undefined); assert.equal(captured.timerCallback, undefined); diff --git a/wechat-news-morning-draft-worker-config.mjs b/wechat-news-morning-draft-worker-config.mjs new file mode 100644 index 0000000..e6f9e3a --- /dev/null +++ b/wechat-news-morning-draft-worker-config.mjs @@ -0,0 +1,26 @@ +/** + * News morning WeChat draft auto worker enablement. + * Default follows H5_REMINDER_WORKER_ENABLED when unset. + */ +export function isWechatNewsMorningDraftWorkerEnabled(env = process.env) { + const explicit = String(env.H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED ?? '').trim(); + if (explicit === '1' || explicit === 'true') return true; + if (explicit === '0' || explicit === 'false') return false; + return env.H5_REMINDER_WORKER_ENABLED === '1'; +} + +export function wechatNewsMorningDraftWorkerIntervalMs(env = process.env) { + const raw = Number(env.H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_INTERVAL_MS ?? 30_000); + if (!Number.isFinite(raw) || raw < 5_000) return 30_000; + return raw; +} + +export function wechatNewsMorningDraftExecutionTimeoutMs(env = process.env) { + const raw = Number( + env.H5_WECHAT_NEWS_MORNING_DRAFT_GENERATION_TIMEOUT_MS + ?? env.H5_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS + ?? 15 * 60 * 1000, + ); + if (!Number.isFinite(raw) || raw < 60_000) return 15 * 60 * 1000; + return raw; +} diff --git a/wechat-news-morning-draft-worker-config.test.mjs b/wechat-news-morning-draft-worker-config.test.mjs new file mode 100644 index 0000000..1c7feff --- /dev/null +++ b/wechat-news-morning-draft-worker-config.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + isWechatNewsMorningDraftWorkerEnabled, + wechatNewsMorningDraftWorkerIntervalMs, +} from './wechat-news-morning-draft-worker-config.mjs'; + +test('news morning draft worker follows reminder worker by default', () => { + assert.equal( + isWechatNewsMorningDraftWorkerEnabled({ H5_REMINDER_WORKER_ENABLED: '1' }), + true, + ); + assert.equal( + isWechatNewsMorningDraftWorkerEnabled({ H5_REMINDER_WORKER_ENABLED: '0' }), + false, + ); + assert.equal( + isWechatNewsMorningDraftWorkerEnabled({ + H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1', + H5_REMINDER_WORKER_ENABLED: '0', + }), + true, + ); +}); + +test('news morning draft worker interval has sane floor', () => { + assert.equal(wechatNewsMorningDraftWorkerIntervalMs({}), 30_000); + assert.equal( + wechatNewsMorningDraftWorkerIntervalMs({ + H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_INTERVAL_MS: '1000', + }), + 30_000, + ); +}); diff --git a/wechat-news-morning-draft-worker.mjs b/wechat-news-morning-draft-worker.mjs new file mode 100644 index 0000000..d0d8616 --- /dev/null +++ b/wechat-news-morning-draft-worker.mjs @@ -0,0 +1,190 @@ +import { executeScheduledTask } from './scheduled-task-executor.mjs'; +import { localDateKey } from './schedule-time.mjs'; +import { + buildNewsMorningAutoGenerationTask, + findLatestNewsMorningPage, + isLocalScheduleDue, + isNewsMorningPageForToday, + isWithinNewsMorningGenerateLeadWindow, +} from './wechat-news-morning-draft.mjs'; +import { + isWechatNewsMorningDraftWorkerEnabled, + wechatNewsMorningDraftExecutionTimeoutMs, + wechatNewsMorningDraftWorkerIntervalMs, +} from './wechat-news-morning-draft-worker-config.mjs'; +import { isPassiveCanaryRuntime } from './server/portal-runtime-role.mjs'; + +function resolveTodayPage(config, h5Root, now = Date.now()) { + const page = findLatestNewsMorningPage({ + h5Root, + userId: config.sourceUserId, + slugPattern: config.pageSlugPattern, + date: new Date(now), + timezone: config.timezone, + }); + if (!page || !isNewsMorningPageForToday(page, { date: new Date(now), timezone: config.timezone })) { + return null; + } + return page; +} + +export function startWechatNewsMorningDraftWorker({ + wechatNewsMorningDraftService = null, + mpConfig = null, + userAuth = null, + tkmindProxy = null, + agentRunGateway = null, + cursorExecutorPolicyService = null, + sessionSnapshotService = null, + pool = null, + h5Root = null, + env = process.env, + executeTask = executeScheduledTask, + logger = console, + intervalMs = wechatNewsMorningDraftWorkerIntervalMs(env), + executionTimeoutMs = wechatNewsMorningDraftExecutionTimeoutMs(env), + runOnStart = true, + setIntervalFn = setInterval, +} = {}) { + if ( + isPassiveCanaryRuntime(env) + || !isWechatNewsMorningDraftWorkerEnabled(env) + || !wechatNewsMorningDraftService + || !mpConfig?.enabled + || !userAuth + || !tkmindProxy + || !h5Root + ) { + return { stop() {} }; + } + + let stopped = false; + let running = false; + let generationInFlightDateKey = null; + + const ensureTodayPage = async (config, dateKey) => { + if (generationInFlightDateKey === dateKey) return null; + generationInFlightDateKey = dateKey; + try { + const task = buildNewsMorningAutoGenerationTask(config); + await executeTask(task, { + userAuth, + tkmindProxy, + agentRunGateway, + cursorExecutorPolicyService, + sessionSnapshotService, + pool, + h5Root, + timeoutMs: executionTimeoutMs, + logger, + }); + const page = resolveTodayPage(config, h5Root); + if (!page) { + throw new Error('新闻早报页面生成未完成'); + } + await wechatNewsMorningDraftService.recordAutoGenerationRun({ + status: 'success', + pageSlug: page.slug, + }); + logger.log?.('[NewsMorningDraft] auto-generated today page', { + slug: page.slug, + userId: config.sourceUserId, + }); + return page; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await wechatNewsMorningDraftService.recordAutoGenerationRun({ + status: 'failed', + errorMessage: message, + }).catch(() => {}); + logger.warn?.('[NewsMorningDraft] auto-generate failed:', message); + throw error; + } finally { + if (generationInFlightDateKey === dateKey) { + generationInFlightDateKey = null; + } + } + }; + + const runOnce = async () => { + if (running || stopped) return; + running = true; + try { + const config = await wechatNewsMorningDraftService.getConfig(); + if (!config.enabled || !config.autoPushEnabled || !config.sourceUserId) return; + + const now = Date.now(); + const timezone = config.timezone; + const dateKey = localDateKey(now, timezone); + const toleranceMinutes = Math.max(1, Math.ceil(Number(intervalMs) / 60_000)); + let todayPage = resolveTodayPage(config, h5Root, now); + + const shouldGenerate = + config.autoGenerateEnabled !== false + && !todayPage + && ( + isWithinNewsMorningGenerateLeadWindow(config, now) + || isLocalScheduleDue( + { hour: config.pushHour, minute: config.pushMinute, timezone }, + now, + toleranceMinutes + 120, + ) + ); + + if (shouldGenerate && generationInFlightDateKey !== dateKey) { + try { + todayPage = await ensureTodayPage(config, dateKey); + } catch { + // keep trying during generate window on later scans + } + } + + if ( + !isLocalScheduleDue( + { hour: config.pushHour, minute: config.pushMinute, timezone }, + now, + toleranceMinutes, + ) + ) { + return; + } + + if (await wechatNewsMorningDraftService.hasSuccessfulAutoPushToday({ now })) { + return; + } + + todayPage = todayPage ?? resolveTodayPage(config, h5Root, now); + if (!todayPage) { + logger.warn?.('[NewsMorningDraft] skip auto push: today page missing', { + userId: config.sourceUserId, + dateKey, + }); + return; + } + + const result = await wechatNewsMorningDraftService.pushDraft({ + triggeredBy: 'auto-push', + }); + logger.log?.('[NewsMorningDraft] auto push succeeded', { + draftMediaId: result.draftMediaId, + pageSlug: result.preview?.page?.slug ?? todayPage.slug, + }); + } catch (error) { + logger.warn?.('[NewsMorningDraft] auto worker failed:', error); + } finally { + running = false; + } + }; + + const timer = setIntervalFn(runOnce, Math.max(5_000, Number(intervalMs) || 30_000)); + timer.unref?.(); + if (runOnStart) void runOnce(); + + return { + runOnce, + stop() { + stopped = true; + clearInterval(timer); + }, + }; +} diff --git a/wechat-news-morning-draft-worker.test.mjs b/wechat-news-morning-draft-worker.test.mjs new file mode 100644 index 0000000..9372315 --- /dev/null +++ b/wechat-news-morning-draft-worker.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } from './user-publish.mjs'; +import { startWechatNewsMorningDraftWorker } from './wechat-news-morning-draft-worker.mjs'; +import { + createWechatNewsMorningDraftService, + wechatNewsMorningDraftInternals, +} from './wechat-news-morning-draft.mjs'; + +const SAMPLE_HTML = `

📰 每日新闻早报

`; + +function createPool() { + const rows = []; + return { + rows, + async query(sql, params = []) { + if (sql.includes('CREATE TABLE')) return [[], []]; + if (sql.includes('FROM h5_wechat_admin_config')) return [[], []]; + if (sql.includes('INSERT INTO h5_wechat_news_draft_runs')) { + rows.push({ + id: params[0], + status: params[1], + pageSlug: params[2], + pageUrl: params[3], + draftMediaId: params[4], + errorMessage: params[5], + triggeredBy: params[6], + createdAt: Number(params[7]), + }); + return [[], []]; + } + if (sql.includes('FROM h5_wechat_news_draft_runs')) { + return [rows.slice().reverse(), []]; + } + return [[], []]; + }, + }; +} + +test('news morning draft worker auto-pushes at configured time', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-worker-')); + const userId = 'user-news'; + const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), SAMPLE_HTML); + + const pool = createPool(); + const service = createWechatNewsMorningDraftService(pool, { + mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' }, + h5Root: tmpRoot, + env: { + H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED: '1', + H5_WECHAT_NEWS_MORNING_DRAFT_AUTO: '1', + H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId, + H5_WECHAT_NEWS_MORNING_DRAFT_HOUR: '6', + H5_WECHAT_NEWS_MORNING_DRAFT_MINUTE: '0', + }, + }); + + const pushCalls = []; + service.pushDraft = async (options = {}) => { + pushCalls.push(options); + return { + ok: true, + draftMediaId: 'draft-1', + preview: { page: { slug: 'daily-news-0911' } }, + }; + }; + service.hasSuccessfulAutoPushToday = async () => false; + + const fixedNow = Date.UTC(2026, 8, 10, 22, 0, 0); + const originalNow = Date.now; + Date.now = () => fixedNow; + + try { + const worker = startWechatNewsMorningDraftWorker({ + wechatNewsMorningDraftService: service, + mpConfig: { enabled: true }, + userAuth: { canUseChat: async () => ({ ok: true }) }, + tkmindProxy: { id: 'proxy' }, + h5Root: tmpRoot, + env: { + H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1', + }, + intervalMs: 30_000, + runOnStart: false, + setIntervalFn: () => ({ unref() {} }), + }); + + await worker.runOnce(); + assert.equal(pushCalls.length, 1); + assert.deepEqual(pushCalls[0], { triggeredBy: 'auto-push' }); + } finally { + Date.now = originalNow; + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +}); + +test('schedule helpers detect today page and due windows', () => { + const { + isNewsMorningPageForToday, + isLocalScheduleDue, + isWithinNewsMorningGenerateLeadWindow, + formatLocalDateParts, + } = wechatNewsMorningDraftInternals; + + const now = Date.UTC(2026, 8, 10, 22, 0, 0); + const parts = formatLocalDateParts(new Date(now), 'Asia/Shanghai'); + assert.equal(parts.mmdd, '0911'); + + assert.equal( + isNewsMorningPageForToday( + { slug: 'daily-news-0911' }, + { date: new Date(now), timezone: 'Asia/Shanghai' }, + ), + true, + ); + assert.equal( + isLocalScheduleDue( + { hour: 6, minute: 0, timezone: 'Asia/Shanghai' }, + now, + 1, + ), + true, + ); + assert.equal( + isWithinNewsMorningGenerateLeadWindow( + { + pushHour: 6, + pushMinute: 0, + generateLeadMinutes: 60, + timezone: 'Asia/Shanghai', + }, + Date.UTC(2026, 8, 10, 21, 15, 0), + ), + true, + ); +}); diff --git a/wechat-news-morning-draft.mjs b/wechat-news-morning-draft.mjs index deb0cbd..afbdc35 100644 --- a/wechat-news-morning-draft.mjs +++ b/wechat-news-morning-draft.mjs @@ -10,6 +10,7 @@ import { } from './user-publish.mjs'; import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs'; import { convertDailyNewsHtmlToWechatInlineArticle } from './wechat-daily-news-inline.mjs'; +import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mjs'; const CONFIG_TABLE = 'h5_wechat_admin_config'; const CONFIG_KEY = 'news_morning_draft'; @@ -64,23 +65,17 @@ function clampMinute(value, fallback) { return Math.min(59, Math.max(0, Math.floor(num))); } -function slugPatternToRegExp(pattern) { - const raw = String(pattern ?? '').trim() || 'daily-news-*'; - const escaped = raw.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); - return new RegExp(`^${escaped}\\.html$`, 'i'); +function clampGenerateLeadMinutes(value, fallback) { + const num = Number(value); + if (!Number.isFinite(num)) return fallback; + return Math.min(24 * 60, Math.max(15, Math.floor(num))); } -function formatShanghaiDateParts(date = new Date()) { - const formatter = new Intl.DateTimeFormat('zh-CN', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }); - const parts = formatter.formatToParts(date); - const year = parts.find((item) => item.type === 'year')?.value ?? ''; - const month = parts.find((item) => item.type === 'month')?.value ?? ''; - const day = parts.find((item) => item.type === 'day')?.value ?? ''; +function formatLocalDateParts(date = new Date(), timezone = 'Asia/Shanghai') { + const parts = getLocalParts(date, timezone); + const year = String(parts.year); + const month = String(parts.month).padStart(2, '0'); + const day = String(parts.day).padStart(2, '0'); return { year, month, @@ -91,6 +86,76 @@ function formatShanghaiDateParts(date = new Date()) { }; } +export function isNewsMorningPageForToday( + page, + { date = new Date(), timezone = 'Asia/Shanghai' } = {}, +) { + if (!page?.slug) return false; + const { iso, mmdd, compact } = formatLocalDateParts(date, timezone); + const slug = String(page.slug); + return ( + slug.endsWith(`-${mmdd}`) + || slug.includes(iso) + || slug.includes(compact) + || slug.includes(iso.replace(/-/g, '')) + ); +} + +export function isLocalScheduleDue( + { hour, minute, timezone = 'Asia/Shanghai' }, + now = Date.now(), + toleranceMinutes = 1, +) { + const parts = getLocalParts(now, timezone); + const current = parts.hour * 60 + parts.minute; + const target = clampHour(hour, 0) * 60 + clampMinute(minute, 0); + return Math.abs(current - target) <= Math.max(0, toleranceMinutes); +} + +export function isWithinNewsMorningGenerateLeadWindow( + { + pushHour, + pushMinute, + generateLeadMinutes = 60, + timezone = 'Asia/Shanghai', + }, + now = Date.now(), +) { + const parts = getLocalParts(now, timezone); + const current = parts.hour * 60 + parts.minute; + const push = clampHour(pushHour, 6) * 60 + clampMinute(pushMinute, 0); + const start = push - clampGenerateLeadMinutes(generateLeadMinutes, 60); + return current >= start && current < push; +} + +export function buildNewsMorningAutoGenerationTask(config, { now = Date.now() } = {}) { + const timezone = String(config?.timezone ?? 'Asia/Shanghai').trim() || 'Asia/Shanghai'; + const dateLabel = formatLocalDateParts(now, timezone); + return { + id: 'news-morning-auto-generate', + userId: config.sourceUserId, + title: '每日新闻早报页面(自动)', + taskSpec: [ + NEWS_MORNING_TEMPLATE_0910_SPEC, + `今日日期:${dateLabel.iso}。`, + `输出文件名必须包含今日 MMDD:daily-news-${dateLabel.mmdd}.html。`, + ].join('\n'), + recurrence: 'once', + timezone, + notifyChannel: 'web', + }; +} + +function slugPatternToRegExp(pattern) { + const raw = String(pattern ?? '').trim() || 'daily-news-*'; + const escaped = raw.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(`^${escaped}\\.html$`, 'i'); +} + +function formatShanghaiDateParts(date = new Date()) { + return formatLocalDateParts(date, 'Asia/Shanghai'); +} + export function isDailyNewsFormat(html) { return /daily-news|每日新闻早报/u.test(String(html ?? '')) || /class="date-badge"/u.test(String(html ?? '')); @@ -359,6 +424,7 @@ export function findLatestNewsMorningPage({ userId, slugPattern = 'news-hotspots-*', date = new Date(), + timezone = 'Asia/Shanghai', } = {}) { if (!h5Root || !userId) throw new Error('缺少 h5Root 或 userId'); const publicDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR); @@ -366,7 +432,7 @@ export function findLatestNewsMorningPage({ return null; } const matcher = slugPatternToRegExp(slugPattern); - const { iso, mmdd, compact } = formatShanghaiDateParts(date); + const { iso, mmdd, compact } = formatLocalDateParts(date, timezone); const datedCandidates = []; const fallbackCandidates = []; for (const entry of fs.readdirSync(publicDir, { withFileTypes: true })) { @@ -509,9 +575,18 @@ async function ensureRunsTable(pool) { } function defaultsFromEnv(env = process.env) { + const autoPushEnabled = normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTO, false); return { enabled: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED, false), - autoPushEnabled: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTO, false), + autoPushEnabled, + autoGenerateEnabled: normalizeBoolean( + env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTO_GENERATE, + autoPushEnabled, + ), + generateLeadMinutes: clampGenerateLeadMinutes( + env.H5_WECHAT_NEWS_MORNING_DRAFT_GENERATE_LEAD_MINUTES, + 60, + ), pushHour: clampHour(env.H5_WECHAT_NEWS_MORNING_DRAFT_HOUR, 6), pushMinute: clampMinute(env.H5_WECHAT_NEWS_MORNING_DRAFT_MINUTE, 0), timezone: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_TZ ?? 'Asia/Shanghai').trim() || 'Asia/Shanghai', @@ -563,6 +638,11 @@ export function createWechatNewsMorningDraftService( return { enabled: normalizeBoolean(stored.enabled, defaults.enabled), autoPushEnabled: normalizeBoolean(stored.autoPushEnabled, defaults.autoPushEnabled), + autoGenerateEnabled: normalizeBoolean(stored.autoGenerateEnabled, defaults.autoGenerateEnabled), + generateLeadMinutes: clampGenerateLeadMinutes( + stored.generateLeadMinutes, + defaults.generateLeadMinutes, + ), pushHour: clampHour(stored.pushHour, defaults.pushHour), pushMinute: clampMinute(stored.pushMinute, defaults.pushMinute), timezone: String(stored.timezone ?? defaults.timezone).trim() || defaults.timezone, @@ -684,6 +764,14 @@ export function createWechatNewsMorningDraftService( payload.autoPushEnabled === undefined ? current.autoPushEnabled : normalizeBoolean(payload.autoPushEnabled, current.autoPushEnabled), + autoGenerateEnabled: + payload.autoGenerateEnabled === undefined + ? current.autoGenerateEnabled + : normalizeBoolean(payload.autoGenerateEnabled, current.autoGenerateEnabled), + generateLeadMinutes: + payload.generateLeadMinutes === undefined + ? current.generateLeadMinutes + : clampGenerateLeadMinutes(payload.generateLeadMinutes, current.generateLeadMinutes), pushHour: payload.pushHour === undefined ? current.pushHour : clampHour(payload.pushHour, current.pushHour), pushMinute: @@ -826,6 +914,47 @@ export function createWechatNewsMorningDraftService( createdAt: Number(row.createdAt ?? 0), })); }, + + async hasSuccessfulAutoPushToday({ now = Date.now() } = {}) { + const config = await this.getConfig(); + const timezone = config.timezone; + const page = findLatestNewsMorningPage({ + h5Root, + userId: config.sourceUserId, + slugPattern: config.pageSlugPattern, + date: new Date(now), + timezone, + }); + if (!page || !isNewsMorningPageForToday(page, { date: new Date(now), timezone })) { + return false; + } + await ensureReady(); + const dayStart = startOfLocalDay(now, timezone); + const [rows] = await pool.query( + `SELECT id + FROM ${RUNS_TABLE} + WHERE status = 'success' + AND page_slug = ? + AND triggered_by = 'auto-push' + AND created_at >= ? + LIMIT 1`, + [page.slug, dayStart], + ); + return rows.length > 0; + }, + + async recordAutoGenerationRun({ + status, + pageSlug = null, + errorMessage = null, + }) { + return recordRun({ + status, + pageSlug, + errorMessage, + triggeredBy: 'auto-generate', + }); + }, }; } @@ -841,4 +970,10 @@ export const wechatNewsMorningDraftInternals = { buildDailyNewsWechatHtmlContent, buildDailyNewsWechatDraftArticle, findLatestNewsMorningPage, + isNewsMorningPageForToday, + isLocalScheduleDue, + isWithinNewsMorningGenerateLeadWindow, + buildNewsMorningAutoGenerationTask, + formatLocalDateParts, + localDateKey, };