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, };