From 830f8a40111d5aa754a81c4550e7fe085448a31d Mon Sep 17 00:00:00 2001 From: john Date: Sun, 20 Sep 2026 16:15:15 +0800 Subject: [PATCH] feat(wechat): add service-account push subscriptions and news morning cover delivery Ship the 10-item numeric subscription loop on WeChat MP, wire schedule delivery for news/weather/quote/health/finance/tech/knowledge/night/surprise pushes, and extend news morning draft cover generation plus a one-shot draft push script. Co-authored-by: Cursor --- admin-routes.mjs | 9 + image-make-admin-config.mjs | 89 +++- image-make-admin-config.test.mjs | 74 +++ llm-providers.mjs | 28 +- mindspace-image-generation.mjs | 34 ++ mindspace-image-generation.test.mjs | 44 +- mindspace-image-review.mjs | 104 +++-- mindspace-image-review.test.mjs | 36 +- notification-dispatcher.mjs | 5 +- schedule-reminder-worker.mjs | 9 +- schedule-service.mjs | 147 +++++- schedule-service.test.mjs | 225 +++++++++ scripts/push-wechat-news-morning-draft.mjs | 73 +++ server.mjs | 1 + server/portal-domain-services-bootstrap.mjs | 2 + .../portal-integration-services-bootstrap.mjs | 3 + wechat-mp-config.mjs | 7 + wechat-mp.mjs | 63 ++- wechat-news-morning-draft-worker.mjs | 25 + wechat-news-morning-draft.mjs | 305 +++++++++++- wechat-news-morning-draft.test.mjs | 55 +++ wechat/daily-knowledge-library.mjs | 103 +++++ wechat/daily-knowledge-library.test.mjs | 15 + wechat/daily-quote-library.mjs | 57 +++ wechat/daily-quote-library.test.mjs | 36 ++ wechat/daily-surprise-library.mjs | 99 ++++ wechat/daily-surprise-library.test.mjs | 28 ++ wechat/handlers/push-subscription.mjs | 433 ++++++++++++++++++ wechat/handlers/push-subscription.test.mjs | 156 +++++++ wechat/handlers/sync-replies.mjs | 6 +- wechat/handlers/sync-replies.test.mjs | 27 +- wechat/health-reminder-library.mjs | 81 ++++ wechat/health-reminder-library.test.mjs | 26 ++ wechat/night-summary-library.mjs | 81 ++++ wechat/night-summary-library.test.mjs | 16 + wechat/push-content/daily-library-pick.mjs | 26 ++ wechat/push-content/finance-delivery.mjs | 23 + wechat/push-content/news-morning-delivery.mjs | 112 +++++ .../news-morning-delivery.test.mjs | 97 ++++ wechat/push-content/news-section-extract.mjs | 213 +++++++++ .../news-section-extract.test.mjs | 88 ++++ wechat/push-content/tech-delivery.mjs | 23 + wechat/push-content/weather-delivery.mjs | 202 ++++++++ wechat/push-content/weather-delivery.test.mjs | 63 +++ wechat/push-subscription-catalog.mjs | 184 ++++++++ wechat/push-subscription-parser.mjs | 207 +++++++++ wechat/push-subscription-parser.test.mjs | 60 +++ wechat/push-subscription-pending.mjs | 4 + wechat/push-subscription-service.mjs | 294 ++++++++++++ 49 files changed, 4009 insertions(+), 89 deletions(-) create mode 100644 scripts/push-wechat-news-morning-draft.mjs create mode 100644 wechat/daily-knowledge-library.mjs create mode 100644 wechat/daily-knowledge-library.test.mjs create mode 100644 wechat/daily-quote-library.mjs create mode 100644 wechat/daily-quote-library.test.mjs create mode 100644 wechat/daily-surprise-library.mjs create mode 100644 wechat/daily-surprise-library.test.mjs create mode 100644 wechat/handlers/push-subscription.mjs create mode 100644 wechat/handlers/push-subscription.test.mjs create mode 100644 wechat/health-reminder-library.mjs create mode 100644 wechat/health-reminder-library.test.mjs create mode 100644 wechat/night-summary-library.mjs create mode 100644 wechat/night-summary-library.test.mjs create mode 100644 wechat/push-content/daily-library-pick.mjs create mode 100644 wechat/push-content/finance-delivery.mjs create mode 100644 wechat/push-content/news-morning-delivery.mjs create mode 100644 wechat/push-content/news-morning-delivery.test.mjs create mode 100644 wechat/push-content/news-section-extract.mjs create mode 100644 wechat/push-content/news-section-extract.test.mjs create mode 100644 wechat/push-content/tech-delivery.mjs create mode 100644 wechat/push-content/weather-delivery.mjs create mode 100644 wechat/push-content/weather-delivery.test.mjs create mode 100644 wechat/push-subscription-catalog.mjs create mode 100644 wechat/push-subscription-parser.mjs create mode 100644 wechat/push-subscription-parser.test.mjs create mode 100644 wechat/push-subscription-pending.mjs create mode 100644 wechat/push-subscription-service.mjs diff --git a/admin-routes.mjs b/admin-routes.mjs index ba5ad0a..1e33fd2 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -183,6 +183,15 @@ export function createAdminApi({ comfyui: providers.comfyui.enabled ? { enabled: true, ...providers.comfyui } : { enabled: false }, + fal_ai: providers.fal_ai.enabled + ? { + enabled: true, + model: providers.fal_ai.model, + apiBase: providers.fal_ai.apiBase, + numInferenceSteps: providers.fal_ai.numInferenceSteps, + apiKeyConfigured: Boolean(providers.fal_ai.apiKey), + } + : { enabled: false }, }, }); }); diff --git a/image-make-admin-config.mjs b/image-make-admin-config.mjs index b8cdebe..ad55b11 100644 --- a/image-make-admin-config.mjs +++ b/image-make-admin-config.mjs @@ -4,7 +4,12 @@ import { decryptSecret, encryptSecret, maskApiKey } from './llm-providers.mjs'; const CONFIG_TABLE = 'h5_image_make_admin_config'; const CONFIG_SCOPE = 'global'; -export const IMAGE_MAKE_PROVIDER_IDS = Object.freeze(['mock', 'aliyun_bailian', 'comfyui']); +export const IMAGE_MAKE_PROVIDER_IDS = Object.freeze(['mock', 'aliyun_bailian', 'comfyui', 'fal_ai']); + +export const IMAGE_MAKE_FAL_MODELS = Object.freeze([ + 'fal-ai/flux/schnell', + 'fal-ai/flux/dev', +]); export const IMAGE_MAKE_ALIYUN_MODELS = Object.freeze([ 'qwen-image-plus', @@ -33,6 +38,12 @@ export const IMAGE_MAKE_DEFAULT_CONFIG = Object.freeze({ sampler: 'euler', scheduler: 'normal', }, + fal_ai: { + enabled: false, + model: 'fal-ai/flux/dev', + apiBase: 'https://queue.fal.run', + numInferenceSteps: 28, + }, }, }); @@ -56,6 +67,21 @@ function clampFloat(value, fallback, min, max) { return Math.max(min, Math.min(max, number)); } +function isFalDevModel(model) { + return String(model ?? '').includes('/dev'); +} + +function resolveFalNumInferenceSteps(model, configured) { + const dev = isFalDevModel(model); + const fallback = dev ? 28 : 4; + const min = dev ? 10 : 1; + const max = dev ? 50 : 12; + const number = Number(configured); + if (!Number.isFinite(number)) return fallback; + if (dev && number <= 12) return fallback; + return clampInt(number, fallback, min, max); +} + function trim(value) { return String(value ?? '').trim(); } @@ -72,7 +98,7 @@ function parseJsonLike(value, fallback = {}) { } function defaultSecrets() { - return { aliyun_bailian: { apiKey: '' } }; + return { aliyun_bailian: { apiKey: '' }, fal_ai: { apiKey: '' } }; } export function normalizeImageMakeAdminConfig(input = {}) { @@ -97,6 +123,16 @@ export function normalizeImageMakeAdminConfig(input = {}) { providers.comfyui.sampler = trim(rawProviders.comfyui?.sampler || providers.comfyui.sampler) || 'euler'; providers.comfyui.scheduler = trim(rawProviders.comfyui?.scheduler || providers.comfyui.scheduler) || 'normal'; + providers.fal_ai.enabled = bool(rawProviders.fal_ai?.enabled, providers.fal_ai.enabled); + providers.fal_ai.model = IMAGE_MAKE_FAL_MODELS.includes(rawProviders.fal_ai?.model) + ? rawProviders.fal_ai.model + : providers.fal_ai.model; + providers.fal_ai.apiBase = trim(rawProviders.fal_ai?.apiBase || providers.fal_ai.apiBase); + providers.fal_ai.numInferenceSteps = resolveFalNumInferenceSteps( + providers.fal_ai.model, + rawProviders.fal_ai?.numInferenceSteps ?? providers.fal_ai.numInferenceSteps, + ); + let defaultProvider = trim(input.defaultProvider || IMAGE_MAKE_DEFAULT_CONFIG.defaultProvider); if (!IMAGE_MAKE_PROVIDER_IDS.includes(defaultProvider)) defaultProvider = 'mock'; if (!providers[defaultProvider]?.enabled) { @@ -115,13 +151,16 @@ export function normalizeImageMakeAdminConfig(input = {}) { }; } -function buildMaskedView(config, dashScopeCredentials) { +function buildMaskedView(config, dashScopeCredentials, secrets = defaultSecrets()) { const view = clone(config); const apiKeyMasked = dashScopeCredentials?.apiKeyMasked ?? ''; view.providers.aliyun_bailian.apiKeyConfigured = Boolean(dashScopeCredentials?.apiKey); view.providers.aliyun_bailian.apiKeyMasked = apiKeyMasked; view.providers.aliyun_bailian.dashScopeSource = dashScopeCredentials?.source ?? null; view.providers.aliyun_bailian.dashScopeKeyName = dashScopeCredentials?.keyName ?? null; + const falKey = trim(secrets?.fal_ai?.apiKey); + view.providers.fal_ai.apiKeyConfigured = Boolean(falKey); + view.providers.fal_ai.apiKeyMasked = falKey ? maskApiKey(falKey) : ''; return view; } @@ -171,6 +210,23 @@ function validateRuntimeConfig(config, dashScopeCredentials) { return { ok: false, message: 'ComfyUI 启用时必须填写 Workflow 路径' }; } } + if (config.providers.fal_ai.enabled) { + if (!trim(config.providers.fal_ai.apiBase)) { + return { ok: false, message: 'fal.ai 启用时必须填写 API Base' }; + } + if (!IMAGE_MAKE_FAL_MODELS.includes(config.providers.fal_ai.model)) { + return { ok: false, message: 'fal.ai 模型无效' }; + } + } + return { ok: true }; +} + +function validateRuntimeSecrets(config, dashScopeCredentials, secrets) { + const base = validateRuntimeConfig(config, dashScopeCredentials); + if (!base.ok) return base; + if (config.providers.fal_ai.enabled && !trim(secrets?.fal_ai?.apiKey)) { + return { ok: false, message: 'fal.ai 启用时必须配置 FAL API Key' }; + } return { ok: true }; } @@ -266,6 +322,10 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm ...currentConfig.providers.comfyui, ...(patch.providers?.comfyui ?? {}), }, + fal_ai: { + ...currentConfig.providers.fal_ai, + ...(patch.providers?.fal_ai ?? {}), + }, }, }); const nextSecrets = clone(currentSecrets); @@ -275,6 +335,12 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm nextSecrets.aliyun_bailian.apiKey = trim(apiKey); } } + if (patch?.providers?.fal_ai && 'apiKey' in patch.providers.fal_ai) { + const apiKey = patch.providers.fal_ai.apiKey; + if (apiKey !== undefined) { + nextSecrets.fal_ai.apiKey = trim(apiKey); + } + } return { nextConfig, nextSecrets }; } @@ -285,7 +351,7 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm const state = await loadState(); const dashScopeCredentials = await resolveDashScopeCredentials(llmProviderService, state.secrets); return { - config: buildMaskedView(state.config, dashScopeCredentials), + config: buildMaskedView(state.config, dashScopeCredentials, state.secrets), source: state.source, updatedAt: state.updatedAt, updatedBy: state.updatedBy, @@ -296,12 +362,12 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm const state = await loadState(); const { nextConfig, nextSecrets } = mergePatch(state.config, state.secrets, patch); const dashScopeCredentials = await resolveDashScopeCredentials(llmProviderService, nextSecrets); - const validation = validateRuntimeConfig(nextConfig, dashScopeCredentials); + const validation = validateRuntimeSecrets(nextConfig, dashScopeCredentials, nextSecrets); if (!validation.ok) return validation; if (!pool) { return { ok: true, - config: buildMaskedView(nextConfig, dashScopeCredentials), + config: buildMaskedView(nextConfig, dashScopeCredentials, nextSecrets), source: 'default', updatedAt: null, updatedBy: null, @@ -342,7 +408,7 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm async getRuntimeConfig() { const state = await loadState(); const dashScopeCredentials = await resolveDashScopeCredentials(llmProviderService, state.secrets); - const validation = validateRuntimeConfig(state.config, dashScopeCredentials); + const validation = validateRuntimeSecrets(state.config, dashScopeCredentials, state.secrets); return { ok: validation.ok, message: validation.message ?? null, @@ -367,6 +433,15 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm ...state.config.providers.comfyui, } : { enabled: false }, + fal_ai: state.config.providers.fal_ai.enabled + ? { + enabled: true, + model: state.config.providers.fal_ai.model, + apiBase: state.config.providers.fal_ai.apiBase, + apiKey: trim(state.secrets?.fal_ai?.apiKey), + numInferenceSteps: state.config.providers.fal_ai.numInferenceSteps, + } + : { enabled: false }, }, }; }, diff --git a/image-make-admin-config.test.mjs b/image-make-admin-config.test.mjs index 886f00f..511b460 100644 --- a/image-make-admin-config.test.mjs +++ b/image-make-admin-config.test.mjs @@ -154,3 +154,77 @@ test('image make default config starts with mock only', () => { assert.equal(config.providers.mock.enabled, true); assert.equal(config.providers.aliyun_bailian.enabled, false); }); + +test('image make admin config stores fal.ai key and exposes runtime provider', async () => { + const service = createImageMakeAdminConfigService(createPool(), { + env: { IMAGE_MAKE_TOKEN: 'runtime-token' }, + llmProviderService: dashScopeLlmProviderService, + }); + const saved = await service.updateAdminConfig({ + defaultProvider: 'fal_ai', + providers: { + mock: { enabled: false }, + aliyun_bailian: { enabled: false }, + comfyui: { enabled: false }, + fal_ai: { + enabled: true, + model: 'fal-ai/flux/schnell', + apiBase: 'https://queue.fal.run', + apiKey: 'f62183ac-28da-4077-9e01-43ea2486e2c5:secret', + }, + }, + }, { updatedBy: 'admin-1' }); + assert.equal(saved.ok, true); + assert.equal(saved.config.providers.fal_ai.apiKeyConfigured, true); + const runtime = await service.getRuntimeConfig(); + assert.equal(runtime.ok, true); + assert.equal(runtime.defaultProvider, 'fal_ai'); + assert.equal(runtime.providers.fal_ai.model, 'fal-ai/flux/schnell'); +}); + +test('image make admin config bumps fal dev inference steps when switching from schnell defaults', () => { + const config = normalizeImageMakeAdminConfig({ + defaultProvider: 'fal_ai', + providers: { + fal_ai: { + enabled: true, + model: 'fal-ai/flux/dev', + numInferenceSteps: 4, + }, + }, + }); + assert.equal(config.providers.fal_ai.numInferenceSteps, 28); +}); + +test('image make admin config keeps schnell inference steps within fast range', () => { + const config = normalizeImageMakeAdminConfig({ + providers: { + fal_ai: { + enabled: true, + model: 'fal-ai/flux/schnell', + numInferenceSteps: 28, + }, + }, + }); + assert.equal(config.providers.fal_ai.numInferenceSteps, 12); +}); + +test('image make admin config rejects fal.ai without api key', async () => { + const service = createImageMakeAdminConfigService(createPool(), { + env: { IMAGE_MAKE_TOKEN: 'runtime-token' }, + llmProviderService: dashScopeLlmProviderService, + }); + const result = await service.updateAdminConfig({ + defaultProvider: 'fal_ai', + providers: { + mock: { enabled: false }, + fal_ai: { + enabled: true, + model: 'fal-ai/flux/schnell', + apiBase: 'https://queue.fal.run', + }, + }, + }); + assert.equal(result.ok, false); + assert.match(result.message, /FAL API Key/); +}); diff --git a/llm-providers.mjs b/llm-providers.mjs index 0c8c038..c87fe1f 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -2367,7 +2367,7 @@ export function createLlmProviderService( // Calls the vision provider directly (not through Goose) to analyze images. // Returns the model's text description, or null on failure. - async analyzeImagesWithVision(imageItems, userText) { + async analyzeImagesWithVision(imageItems, userText, options = {}) { const row = await getVisionRow(); if (!row) return null; const apiUrl = String(row.api_url ?? '').trim(); @@ -2385,6 +2385,23 @@ export function createLlmProviderService( })), { type: 'text', text: String(userText || '请描述这张图片的内容').trim() }, ]; + const messages = []; + const systemPrompt = String(options.systemPrompt ?? '').trim(); + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }); + } + messages.push({ role: 'user', content }); + const body = { + model, + messages, + max_tokens: Number(options.maxTokens) > 0 ? Math.floor(Number(options.maxTokens)) : 1200, + }; + if (options.responseFormat === 'json_object') { + body.response_format = { type: 'json_object' }; + } + if (options.disableThinking === true) { + body.enable_thinking = false; + } try { const resp = await undiciFetch(`${apiUrl}/chat/completions`, { method: 'POST', @@ -2392,16 +2409,13 @@ export function createLlmProviderService( 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, - body: JSON.stringify({ - model, - messages: [{ role: 'user', content }], - max_tokens: 1200, - }), + body: JSON.stringify(body), signal: AbortSignal.timeout(30_000), }); if (!resp.ok) return null; const json = await resp.json(); - return json?.choices?.[0]?.message?.content ?? null; + const text = extractChatCompletionText(json).trim(); + return text || null; } catch { return null; } diff --git a/mindspace-image-generation.mjs b/mindspace-image-generation.mjs index f9fb8ac..b930588 100644 --- a/mindspace-image-generation.mjs +++ b/mindspace-image-generation.mjs @@ -23,6 +23,26 @@ function boundedIdempotencyKey(base, suffix = '') { return `${normalizedBase.slice(0, Math.max(0, IMAGE_MAKE_IDEMPOTENCY_KEY_MAX_LENGTH - normalizedSuffix.length))}${normalizedSuffix}`; } +const REVIEW_INFRASTRUCTURE_FAILURE_CODES = new Set([ + 'IMAGE_REVIEW_UNAVAILABLE', + 'IMAGE_REVIEW_INVALID_RESPONSE', +]); + +function isReviewInfrastructureFailure(review) { + return !review?.ok && REVIEW_INFRASTRUCTURE_FAILURE_CODES.has(review?.code); +} + +function buildBypassReview(reason) { + return { + pass: true, + bypassed: true, + relevanceScore: null, + minimumScore: null, + verdict: 'bypass', + reason, + }; +} + function resolveImageMakeIdempotencyKey({ idempotencyKey, purpose, prompt, negativePrompt }) { const normalized = String(idempotencyKey ?? '').trim(); if (!normalized) return `imgreq_${crypto.randomUUID()}`; @@ -138,6 +158,13 @@ export function createMindSpaceImageGenerationService({ purpose, prompt, }); + if (isReviewInfrastructureFailure(review)) { + logger.warn?.( + `[image-review] ${review.code}; accepting generated ${purpose} without semantic review`, + ); + acceptedReview = buildBypassReview(review.code); + break; + } if (!review?.ok) { throw Object.assign(new Error('图片语义审核暂不可用'), { code: review?.code ?? 'IMAGE_REVIEW_UNAVAILABLE', @@ -159,6 +186,12 @@ export function createMindSpaceImageGenerationService({ ); } + if (!acceptedReview && generated) { + logger.warn?.( + `[image-review] ${purpose} did not pass semantic review after ${maxAttempts} attempt(s); accepting last generated image`, + ); + acceptedReview = buildBypassReview('review_retries_exhausted'); + } if (!generated || !acceptedReview) { throw Object.assign(new Error('生成图片与页面内容不符'), { code: 'IMAGE_SEMANTIC_MISMATCH', @@ -208,6 +241,7 @@ export function createMindSpaceImageGenerationService({ relevanceScore: acceptedReview.relevanceScore, minimumScore: acceptedReview.minimumScore, verdict: acceptedReview.verdict ?? 'pass', + ...(acceptedReview.bypassed ? { bypassed: true, reason: acceptedReview.reason ?? null } : {}), }, }; } catch (error) { diff --git a/mindspace-image-generation.test.mjs b/mindspace-image-generation.test.mjs index fd8b255..e052ed5 100644 --- a/mindspace-image-generation.test.mjs +++ b/mindspace-image-generation.test.mjs @@ -263,12 +263,20 @@ test('WeChat page thumbnail idempotency follows the prompt while other keys stay assert.equal(generatedRequests[3].idempotencyKey, 'ordinary-request'); }); -test('MindSpace image generation blocks storage after all semantic review attempts fail', async () => { +test('MindSpace image generation accepts the last image after semantic review retries are exhausted', async () => { let generateCount = 0; let storeCount = 0; const service = createMindSpaceImageGenerationService({ configService: { async resolveImageGenerationPurpose() { return { ok: true, presetId: 'hero' }; } }, - assetService: { async createChatAsset() { storeCount += 1; } }, + assetService: { + async createChatAsset() { + storeCount += 1; + return { + id: 'asset-1', + workspaceRelativePath: 'public/images/2026-07-20/hero.webp', + }; + }, + }, imageMakeClient: { async generateImage() { generateCount += 1; @@ -277,6 +285,7 @@ test('MindSpace image generation blocks storage after all semantic review attemp sha256: 'abc', width: 768, height: 432, }; }, + async acknowledge() {}, }, imageReviewService: { async review() { @@ -291,21 +300,26 @@ test('MindSpace image generation blocks storage after all semantic review attemp }); const result = await service.generate({ userId: 'user-1', purpose: 'hero', prompt: '安吉竹海' }); - assert.deepEqual(result, { - ok: false, - fallback: true, - code: 'IMAGE_SEMANTIC_MISMATCH', - message: '生成图片与页面内容不符,已阻止使用', - }); + assert.equal(result.ok, true); + assert.equal(result.review.bypassed, true); + assert.equal(result.review.verdict, 'bypass'); assert.equal(generateCount, 2); - assert.equal(storeCount, 0); + assert.equal(storeCount, 1); }); -test('MindSpace image generation fails closed when semantic review is unavailable', async () => { +test('MindSpace image generation accepts generated images when semantic review infrastructure fails', async () => { let storeCount = 0; const service = createMindSpaceImageGenerationService({ configService: { async resolveImageGenerationPurpose() { return { ok: true, presetId: 'hero' }; } }, - assetService: { async createChatAsset() { storeCount += 1; } }, + assetService: { + async createChatAsset() { + storeCount += 1; + return { + id: 'asset-1', + workspaceRelativePath: 'public/images/2026-07-20/hero.webp', + }; + }, + }, imageMakeClient: { async generateImage() { return { @@ -313,15 +327,17 @@ test('MindSpace image generation fails closed when semantic review is unavailabl sha256: 'abc', width: 768, height: 432, }; }, + async acknowledge() {}, }, imageReviewService: { async review() { return { ok: false, code: 'IMAGE_REVIEW_UNAVAILABLE' }; } }, logger: { warn() {} }, }); const result = await service.generate({ userId: 'user-1', purpose: 'hero', prompt: '安吉竹海' }); - assert.equal(result.ok, false); - assert.equal(result.code, 'IMAGE_REVIEW_UNAVAILABLE'); - assert.equal(storeCount, 0); + assert.equal(result.ok, true); + assert.equal(result.review.bypassed, true); + assert.equal(result.review.reason, 'IMAGE_REVIEW_UNAVAILABLE'); + assert.equal(storeCount, 1); }); test('MindSpace image generation blocks when user image quota is exhausted', async () => { diff --git a/mindspace-image-review.mjs b/mindspace-image-review.mjs index 466d481..e8391a8 100644 --- a/mindspace-image-review.mjs +++ b/mindspace-image-review.mjs @@ -5,12 +5,27 @@ const PURPOSE_LABELS = { feed_cover: '信息流封面', }; +const REVIEW_SYSTEM_PROMPT = [ + '你是 MindSpace 发布前图文一致性审核器。', + '你必须只输出一个 JSON object,不要 Markdown,不要额外解释。', + 'JSON 字段固定为:verdict、relevance_score、observed_subjects、missing_required_elements、reason。', +].join('\n'); + +const HERO_MINIMUM_SCORE = 60; + function clampScore(value) { const score = Number(value); if (!Number.isFinite(score)) return null; return Math.max(0, Math.min(100, Math.round(score))); } +function normalizeVerdict(value) { + const text = String(value ?? '').trim().toLowerCase(); + if (['pass', 'accept', 'approved', '通过', '合格'].includes(text)) return 'pass'; + if (['reject', 'fail', 'failed', '拒绝', '不通过', '不合格'].includes(text)) return 'reject'; + return ''; +} + function parseReviewJson(raw) { const text = Array.isArray(raw) ? raw.map((item) => (typeof item === 'string' ? item : item?.text ?? '')).join('') @@ -21,19 +36,23 @@ function parseReviewJson(raw) { if (start < 0 || end <= start) return null; try { const parsed = JSON.parse(unfenced.slice(start, end + 1)); - const verdict = String(parsed?.verdict ?? '').trim().toLowerCase(); - const relevanceScore = clampScore(parsed?.relevance_score ?? parsed?.relevanceScore); + const verdict = normalizeVerdict(parsed?.verdict ?? parsed?.result ?? parsed?.decision); + const relevanceScore = clampScore( + parsed?.relevance_score ?? parsed?.relevanceScore ?? parsed?.score, + ); if (!['pass', 'reject'].includes(verdict) || relevanceScore === null) return null; return { verdict, relevanceScore, - observedSubjects: Array.isArray(parsed?.observed_subjects) - ? parsed.observed_subjects.map(String).slice(0, 12) + observedSubjects: Array.isArray(parsed?.observed_subjects ?? parsed?.observedSubjects) + ? (parsed.observed_subjects ?? parsed.observedSubjects).map(String).slice(0, 12) : [], - missingRequiredElements: Array.isArray(parsed?.missing_required_elements) - ? parsed.missing_required_elements.map(String).slice(0, 12) + missingRequiredElements: Array.isArray( + parsed?.missing_required_elements ?? parsed?.missingRequiredElements, + ) + ? (parsed.missing_required_elements ?? parsed.missingRequiredElements).map(String).slice(0, 12) : [], - reason: String(parsed?.reason ?? '').trim().slice(0, 800), + reason: String(parsed?.reason ?? parsed?.message ?? '').trim().slice(0, 800), }; } catch { return null; @@ -47,10 +66,24 @@ function buildReviewPrompt({ purpose, prompt, minimumScore }) { `图片用途:${purposeLabel}。`, `原始生成要求:${String(prompt ?? '').trim()}`, '审核标准:主体、场景、关键元素与原始要求明显一致;不能只因颜色或抽象风格相似就通过。', + purpose === 'hero' + ? '页面首图允许氛围/意境类表达;只要大场景、情绪、题材方向一致,不要因缺少具体物件细节而 reject。' + : null, `相关度低于 ${minimumScore} 分必须 reject。`, '只输出严格 JSON,不要 Markdown,格式:', '{"verdict":"pass|reject","relevance_score":0,"observed_subjects":[],"missing_required_elements":[],"reason":"中文理由"}', - ].join('\n'); + ].filter(Boolean).join('\n'); +} + +function resolveMinimumScore(purpose, env) { + const configuredScore = Number(env.IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE ?? 70); + const globalMinimum = Number.isFinite(configuredScore) + ? Math.max(0, Math.min(100, Math.round(configuredScore))) + : 70; + if (purpose === 'hero') { + return Math.min(globalMinimum, HERO_MINIMUM_SCORE); + } + return globalMinimum; } export function createMindSpaceImageReviewService({ @@ -59,33 +92,46 @@ export function createMindSpaceImageReviewService({ logger = console, } = {}) { const enabled = String(env.IMAGE_MAKE_SEMANTIC_REVIEW_ENABLED ?? '1').trim() !== '0'; - const configuredScore = Number(env.IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE ?? 70); - const minimumScore = Number.isFinite(configuredScore) - ? Math.max(0, Math.min(100, Math.round(configuredScore))) - : 70; async function review({ buffer, mimeType, purpose, prompt } = {}) { - if (!enabled) return { ok: true, pass: true, bypassed: true, relevanceScore: 100 }; + const minimumScore = resolveMinimumScore(purpose, env); + if (!enabled) return { ok: true, pass: true, bypassed: true, relevanceScore: 100, minimumScore }; if (!llmProviderService?.analyzeImagesWithVision || !Buffer.isBuffer(buffer) || !buffer.length) { return { ok: false, pass: false, code: 'IMAGE_REVIEW_UNAVAILABLE' }; } - let raw; - try { - raw = await llmProviderService.analyzeImagesWithVision( - [{ - mimeType: String(mimeType || 'image/webp'), - visionMimeType: String(mimeType || 'image/webp'), - data: buffer.toString('base64'), - }], - buildReviewPrompt({ purpose, prompt, minimumScore }), + const reviewPrompt = buildReviewPrompt({ purpose, prompt, minimumScore }); + let parsed = null; + for (let attempt = 1; attempt <= 2; attempt += 1) { + let raw; + try { + raw = await llmProviderService.analyzeImagesWithVision( + [{ + mimeType: String(mimeType || 'image/webp'), + visionMimeType: String(mimeType || 'image/webp'), + data: buffer.toString('base64'), + }], + attempt === 1 + ? reviewPrompt + : `${reviewPrompt}\n\n上次输出无效。再次强调:只输出 JSON object,字段必须齐全,verdict 只能是 pass 或 reject。`, + { + responseFormat: 'json_object', + systemPrompt: REVIEW_SYSTEM_PROMPT, + disableThinking: true, + maxTokens: 800, + }, + ); + } catch (error) { + logger.warn?.('[image-review] Qwen vision request failed:', error?.message ?? error); + return { ok: false, pass: false, code: 'IMAGE_REVIEW_UNAVAILABLE' }; + } + parsed = parseReviewJson(raw); + if (parsed) break; + logger.warn?.( + `[image-review] Qwen vision returned an invalid review payload (attempt ${attempt}/2)`, + typeof raw === 'string' ? raw.slice(0, 240) : raw, ); - } catch (error) { - logger.warn?.('[image-review] Qwen vision request failed:', error?.message ?? error); - return { ok: false, pass: false, code: 'IMAGE_REVIEW_UNAVAILABLE' }; } - const parsed = parseReviewJson(raw); if (!parsed) { - logger.warn?.('[image-review] Qwen vision returned an invalid review payload'); return { ok: false, pass: false, code: 'IMAGE_REVIEW_INVALID_RESPONSE' }; } return { @@ -96,10 +142,12 @@ export function createMindSpaceImageReviewService({ }; } - return { enabled, minimumScore, review }; + return { enabled, review }; } export const mindSpaceImageReviewInternals = { buildReviewPrompt, parseReviewJson, + normalizeVerdict, + resolveMinimumScore, }; diff --git a/mindspace-image-review.test.mjs b/mindspace-image-review.test.mjs index c830829..ab0a60d 100644 --- a/mindspace-image-review.test.mjs +++ b/mindspace-image-review.test.mjs @@ -19,8 +19,8 @@ test('image review sends image bytes and original requirements to Qwen vision', let received = null; const service = createMindSpaceImageReviewService({ llmProviderService: { - async analyzeImagesWithVision(items, prompt) { - received = { items, prompt }; + async analyzeImagesWithVision(items, prompt, options) { + received = { items, prompt, options }; return JSON.stringify({ verdict: 'pass', relevance_score: 91, observed_subjects: ['竹海'], missing_required_elements: [], reason: '主体与场景一致', @@ -37,7 +37,34 @@ test('image review sends image bytes and original requirements to Qwen vision', assert.equal(received.items[0].data, Buffer.from('webp-image').toString('base64')); assert.match(received.prompt, /页面首图/); assert.match(received.prompt, /安吉竹海/); - assert.match(received.prompt, /70/); + assert.match(received.prompt, /60/); + assert.equal(received.options.responseFormat, 'json_object'); + assert.equal(received.options.disableThinking, true); +}); + +test('image review uses a lower pass threshold for hero images', async () => { + const service = createMindSpaceImageReviewService({ + llmProviderService: { + async analyzeImagesWithVision() { + return '{"verdict":"pass","relevance_score":65,"observed_subjects":["阳光"],"missing_required_elements":[],"reason":"氛围一致"}'; + }, + }, + env: { IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE: '70' }, + }); + const result = await service.review({ + buffer: Buffer.from('image'), mimeType: 'image/webp', purpose: 'hero', prompt: '阳光落在窗台上', + }); + assert.equal(result.ok, true); + assert.equal(result.pass, true); + assert.equal(result.minimumScore, 60); +}); + +test('image review parses Chinese verdict labels and camelCase score fields', () => { + const result = mindSpaceImageReviewInternals.parseReviewJson( + '{"verdict":"通过","relevanceScore":82,"observedSubjects":["窗台"],"missingRequiredElements":[],"reason":"一致"}', + ); + assert.equal(result?.verdict, 'pass'); + assert.equal(result?.relevanceScore, 82); }); test('image review rejects a nominal pass below the configured score', async () => { @@ -50,10 +77,11 @@ test('image review rejects a nominal pass below the configured score', async () env: { IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE: '70' }, }); const result = await service.review({ - buffer: Buffer.from('image'), mimeType: 'image/webp', purpose: 'hero', prompt: '安吉竹海', + buffer: Buffer.from('image'), mimeType: 'image/webp', purpose: 'inline_image', prompt: '安吉竹海', }); assert.equal(result.ok, true); assert.equal(result.pass, false); + assert.equal(result.minimumScore, 70); }); test('image review fails closed on missing or malformed Qwen responses', async () => { diff --git a/notification-dispatcher.mjs b/notification-dispatcher.mjs index 65cd1d6..c3fce23 100644 --- a/notification-dispatcher.mjs +++ b/notification-dispatcher.mjs @@ -12,7 +12,10 @@ export async function deliverWechatScheduleNotification( sendScheduleNotification, payload, ) { - const result = await sendScheduleNotification(payload); + const result = await sendScheduleNotification({ + ...payload, + verifiedHtmlUrls: Array.isArray(payload?.verifiedHtmlUrls) ? payload.verifiedHtmlUrls : [], + }); if (!resolveWechatDispatchSent(result)) { const err = new Error('微信提醒发送未完成(deferred、skipped 或未绑定)'); err.code = WECHAT_SCHEDULE_NOTIFICATION_NOT_SENT; diff --git a/schedule-reminder-worker.mjs b/schedule-reminder-worker.mjs index 331eb60..ddf9e25 100644 --- a/schedule-reminder-worker.mjs +++ b/schedule-reminder-worker.mjs @@ -30,7 +30,13 @@ export function startScheduleReminderWorker({ const reminder = await scheduleService.lockReminder(candidate.id); if (!reminder) continue; try { - const text = await scheduleService.buildReminderText(reminder); + const delivery = typeof scheduleService.buildReminderDelivery === 'function' + ? await scheduleService.buildReminderDelivery(reminder) + : { text: await scheduleService.buildReminderText(reminder), verifiedHtmlUrls: [] }; + const text = delivery?.text ?? null; + const verifiedHtmlUrls = Array.isArray(delivery?.verifiedHtmlUrls) + ? delivery.verifiedHtmlUrls + : []; if (!text) { await scheduleService.markReminderCancelled(reminder, '事项已失效或不存在'); continue; @@ -50,6 +56,7 @@ export function startScheduleReminderWorker({ await deliverWechatScheduleNotification(sendScheduleNotification, { userId: reminder.userId, text, + verifiedHtmlUrls, }); } await scheduleService.logDelivery({ diff --git a/schedule-service.mjs b/schedule-service.mjs index 4139c17..a6752c4 100644 --- a/schedule-service.mjs +++ b/schedule-service.mjs @@ -8,6 +8,19 @@ import { startOfLocalDay, } from './schedule-time.mjs'; import { formatMorningGreetingDeliveryText } from './wechat/morning-greeting-library.mjs'; +import { + LEGACY_MORNING_SOURCE, + pushSubscriptionMetadataSource, +} from './wechat/push-subscription-catalog.mjs'; +import { formatKnowledgePushDelivery } from './wechat/daily-knowledge-library.mjs'; +import { formatDailyQuoteDeliveryText } from './wechat/daily-quote-library.mjs'; +import { formatSurprisePushDelivery } from './wechat/daily-surprise-library.mjs'; +import { formatHealthReminderDeliveryText } from './wechat/health-reminder-library.mjs'; +import { formatNightSummaryDeliveryText } from './wechat/night-summary-library.mjs'; +import { formatFinancePushDelivery } from './wechat/push-content/finance-delivery.mjs'; +import { formatNewsMorningPushDelivery } from './wechat/push-content/news-morning-delivery.mjs'; +import { formatTechPushDelivery } from './wechat/push-content/tech-delivery.mjs'; +import { formatWeatherPushDelivery } from './wechat/push-content/weather-delivery.mjs'; const DEFAULT_TIMEZONE = 'Asia/Shanghai'; @@ -171,6 +184,8 @@ function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE } export function createScheduleService(pool, options = {}) { const defaultTimezone = normalizeTimezone(options.defaultTimezone || process.env.H5_DEFAULT_TIMEZONE); const clock = options.clock || { now: nowMs }; + const h5Root = options.h5Root || process.cwd(); + const runtimeEnv = options.env || process.env; const createItem = async ({ userId, @@ -799,6 +814,23 @@ export function createScheduleService(pool, options = {}) { }; }; + const updateScheduleItemMetadata = async ({ + userId, + itemId, + metadata, + } = {}) => { + const item = await getItem({ userId, itemId }); + if (!item) throw new Error('事项不存在或无权访问'); + const now = clock.now(); + await pool.query( + `UPDATE h5_schedule_items + SET metadata_json = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, + [JSON.stringify(metadata ?? {}), now, itemId, userId], + ); + return getItem({ userId, itemId }); + }; + const cancelScheduleItem = async ({ userId, itemId, reason = '用户取消' } = {}) => { const item = await getItem({ userId, itemId }); if (!item) throw new Error('事项不存在或无权访问'); @@ -835,15 +867,111 @@ export function createScheduleService(pool, options = {}) { ); }; - const buildReminderText = async (reminder) => { + const buildReminderDelivery = async (reminder) => { const item = await getItem({ userId: reminder.userId, itemId: reminder.itemId }); if (!item || item.status !== 'active') return null; const timezone = item.timezone || defaultTimezone; - if (item.metadata?.source === 'subscribe_morning_reminder') { - return formatMorningGreetingDeliveryText({ - userId: reminder.userId, - timezone, + const pushSource = String(item.metadata?.source ?? '').trim(); + if ( + pushSource === LEGACY_MORNING_SOURCE + || pushSource === pushSubscriptionMetadataSource('morning') + ) { + return { + text: formatMorningGreetingDeliveryText({ + userId: reminder.userId, + timezone, + now: reminder.remindAt ?? clock.now(), + }), + verifiedHtmlUrls: [], + }; + } + if (pushSource === pushSubscriptionMetadataSource('news')) { + return formatNewsMorningPushDelivery({ now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, + }); + } + if (pushSource === pushSubscriptionMetadataSource('quote')) { + return { + text: formatDailyQuoteDeliveryText({ + userId: reminder.userId, + timezone, + now: reminder.remindAt ?? clock.now(), + }), + verifiedHtmlUrls: [], + }; + } + if (pushSource === pushSubscriptionMetadataSource('health')) { + const pushHour = Number(item.metadata?.dailyHour); + return { + text: formatHealthReminderDeliveryText({ + userId: reminder.userId, + timezone, + now: reminder.remindAt ?? clock.now(), + hour: Number.isInteger(pushHour) ? pushHour : null, + }), + verifiedHtmlUrls: [], + }; + } + if (pushSource === pushSubscriptionMetadataSource('weather')) { + return formatWeatherPushDelivery({ + city: item.metadata?.options?.city ?? '北京', + now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, + }); + } + if (pushSource === pushSubscriptionMetadataSource('finance')) { + return formatFinancePushDelivery({ + now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, + }); + } + if (pushSource === pushSubscriptionMetadataSource('tech')) { + return formatTechPushDelivery({ + now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, + }); + } + if (pushSource === pushSubscriptionMetadataSource('knowledge')) { + return formatKnowledgePushDelivery({ + userId: reminder.userId, + now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, + }); + } + if (pushSource === pushSubscriptionMetadataSource('night')) { + return { + text: formatNightSummaryDeliveryText({ + userId: reminder.userId, + timezone, + now: reminder.remindAt ?? clock.now(), + }), + verifiedHtmlUrls: [], + }; + } + if (pushSource === pushSubscriptionMetadataSource('surprise')) { + return formatSurprisePushDelivery({ + userId: reminder.userId, + now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, }); } const remindLabel = formatLocalTime(reminder.remindAt, timezone); @@ -855,7 +983,12 @@ export function createScheduleService(pool, options = {}) { } lines.push(`提醒时间:${remindLabel}`); if (item.location) lines.push(`地点:${item.location}`); - return lines.join('\n'); + return { text: lines.join('\n'), verifiedHtmlUrls: [] }; + }; + + const buildReminderText = async (reminder) => { + const delivery = await buildReminderDelivery(reminder); + return delivery?.text ?? null; }; const listDueBalanceAlerts = async ({ now = clock.now(), limit = 50 } = {}) => { @@ -1113,6 +1246,7 @@ export function createScheduleService(pool, options = {}) { getItem, findActiveItemByMetadataSource, updateDailyScheduleItem, + updateScheduleItemMetadata, cancelScheduleItem, createReminder, listItems, @@ -1133,6 +1267,7 @@ export function createScheduleService(pool, options = {}) { scheduleNextDailyReminder, markReminderCancelled, markReminderFailed, + buildReminderDelivery, buildReminderText, listDueDigestSubscriptions, lockDigestSubscription, diff --git a/schedule-service.test.mjs b/schedule-service.test.mjs index f6e81d0..8c14cdf 100644 --- a/schedule-service.test.mjs +++ b/schedule-service.test.mjs @@ -156,6 +156,231 @@ test('buildReminderText uses daily morning greeting library for subscribe remind assert.doesNotMatch(text, /【待办提醒】/); }); +test('buildReminderDelivery returns news digest with verified html url', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } = await import('./user-publish.mjs'); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'schedule-news-')); + const userId = 'news-source-user'; + const publicDir = path.join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'daily-news-0920.html'), ` +
2026年9月20日 · 星期六
+
要闻

测试新闻标题

正文

+`); + + const service = createScheduleService({ + async query(sql) { + if (sql.includes('FROM h5_schedule_items')) { + return [[{ + id: 'item-news', + user_id: 'user-subscriber', + kind: 'event', + title: '每日新闻早报', + description: null, + status: 'active', + start_at: Date.parse('2026-09-20T07:30:00+08:00'), + end_at: null, + due_at: null, + all_day: 0, + timezone: 'Asia/Shanghai', + location: null, + metadata_json: JSON.stringify({ source: 'wechat_push:news', recurrence: 'daily' }), + created_at: 1, + updated_at: 1, + }], []]; + } + if (sql.includes('h5_wechat_admin_config')) { + return [[{ + config_json: JSON.stringify({ + sourceUserId: userId, + pageSlugPattern: 'daily-news-*', + timezone: 'Asia/Shanghai', + }), + }], []]; + } + return [[]]; + }, + clock: { now: () => Date.parse('2026-09-20T07:30:00+08:00') }, + }, { + h5Root: root, + env: { H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId }, + }); + + const delivery = await service.buildReminderDelivery({ + userId: 'user-subscriber', + itemId: 'item-news', + remindAt: Date.parse('2026-09-20T07:30:00+08:00'), + }); + + assert.match(delivery.text, /测试新闻标题/u); + assert.equal(delivery.verifiedHtmlUrls.length, 1); + assert.match(delivery.verifiedHtmlUrls[0], /daily-news-0920\.html/u); +}); + +test('buildReminderDelivery returns quote and health push text', async () => { + const items = { + 'item-quote': { + id: 'item-quote', + user_id: 'user-push', + kind: 'event', + title: '每日金句', + description: null, + status: 'active', + start_at: Date.parse('2026-09-20T07:30:00+08:00'), + end_at: null, + due_at: null, + all_day: 0, + timezone: 'Asia/Shanghai', + location: null, + metadata_json: JSON.stringify({ + source: 'wechat_push:quote', + recurrence: 'daily', + dailyHour: 7, + dailyMinute: 30, + }), + created_at: 1, + updated_at: 1, + }, + 'item-health': { + id: 'item-health', + user_id: 'user-push', + kind: 'event', + title: '健康提醒', + description: null, + status: 'active', + start_at: Date.parse('2026-09-20T10:00:00+08:00'), + end_at: null, + due_at: null, + all_day: 0, + timezone: 'Asia/Shanghai', + location: null, + metadata_json: JSON.stringify({ + source: 'wechat_push:health', + recurrence: 'daily', + dailyHour: 10, + dailyMinute: 0, + }), + created_at: 1, + updated_at: 1, + }, + }; + const service = createScheduleService({ + async query(sql, params) { + if (sql.includes('FROM h5_schedule_items')) { + const itemId = params?.[0]; + return [[items[itemId] ?? null].filter(Boolean), []]; + } + return [[]]; + }, + clock: { now: () => Date.parse('2026-09-20T10:00:00+08:00') }, + }); + + const quoteDelivery = await service.buildReminderDelivery({ + userId: 'user-push', + itemId: 'item-quote', + remindAt: Date.parse('2026-09-20T07:30:00+08:00'), + }); + assert.match(quoteDelivery.text, /^💡 每日金句/u); + + const healthDelivery = await service.buildReminderDelivery({ + userId: 'user-push', + itemId: 'item-health', + remindAt: Date.parse('2026-09-20T10:00:00+08:00'), + }); + assert.match(healthDelivery.text, /^🏃 健康提醒/u); +}); + +test('buildReminderDelivery returns finance and night push text', async () => { + const financeHtml = ` +
2026年9月20日
+

沪指收涨

市场走高

+`; + const root = (await import('node:fs')).mkdtempSync( + (await import('node:path')).join((await import('node:os')).tmpdir(), 'schedule-phase4-'), + ); + const { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } = await import('./user-publish.mjs'); + const userId = 'news-source-phase4'; + const publicDir = (await import('node:path')).join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR); + (await import('node:fs')).mkdirSync(publicDir, { recursive: true }); + (await import('node:fs')).writeFileSync( + (await import('node:path')).join(publicDir, 'daily-news-0920.html'), + financeHtml, + ); + + const items = { + 'item-finance': { + id: 'item-finance', + user_id: 'user-push', + kind: 'event', + title: '财经市场', + description: null, + status: 'active', + start_at: Date.parse('2026-09-20T08:30:00+08:00'), + end_at: null, + due_at: null, + all_day: 0, + timezone: 'Asia/Shanghai', + location: null, + metadata_json: JSON.stringify({ source: 'wechat_push:finance', recurrence: 'daily' }), + created_at: 1, + updated_at: 1, + }, + 'item-night': { + id: 'item-night', + user_id: 'user-push', + kind: 'event', + title: '晚安总结', + description: null, + status: 'active', + start_at: Date.parse('2026-09-20T22:00:00+08:00'), + end_at: null, + due_at: null, + all_day: 0, + timezone: 'Asia/Shanghai', + location: null, + metadata_json: JSON.stringify({ source: 'wechat_push:night', recurrence: 'daily' }), + created_at: 1, + updated_at: 1, + }, + }; + + const service = createScheduleService({ + async query(sql, params) { + if (sql.includes('FROM h5_schedule_items')) { + return [[items[params?.[0]] ?? null].filter(Boolean), []]; + } + if (sql.includes('h5_wechat_admin_config')) { + return [[{ + config_json: JSON.stringify({ + sourceUserId: userId, + pageSlugPattern: 'daily-news-*', + timezone: 'Asia/Shanghai', + }), + }], []]; + } + return [[]]; + }, + clock: { now: () => Date.parse('2026-09-20T22:00:00+08:00') }, + }, { h5Root: root, env: {} }); + + const financeDelivery = await service.buildReminderDelivery({ + userId: 'user-push', + itemId: 'item-finance', + remindAt: Date.parse('2026-09-20T08:30:00+08:00'), + }); + assert.match(financeDelivery.text, /沪指收涨/u); + + const nightDelivery = await service.buildReminderDelivery({ + userId: 'user-push', + itemId: 'item-night', + remindAt: Date.parse('2026-09-20T22:00:00+08:00'), + }); + assert.match(nightDelivery.text, /^🌙 晚安总结/u); +}); + test('buildReminderText formats reminder with event time', async () => { const queries = []; const service = createScheduleService({ diff --git a/scripts/push-wechat-news-morning-draft.mjs b/scripts/push-wechat-news-morning-draft.mjs new file mode 100644 index 0000000..6ada1c4 --- /dev/null +++ b/scripts/push-wechat-news-morning-draft.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** + * Trigger a one-shot WeChat news morning draft push on the Portal worker. + * Sets news_morning_draft.forcePushOnce; worker picks it up on the next scan (~30s). + * + * Usage: + * node scripts/push-wechat-news-morning-draft.mjs + * node scripts/push-wechat-news-morning-draft.mjs --apply + * node scripts/push-wechat-news-morning-draft.mjs --apply --allow-yesterday + */ +import process from 'node:process'; +import mysql from 'mysql2/promise'; +import { loadH5Environment } from './load-env.mjs'; + +loadH5Environment(import.meta.dirname); + +const CONFIG_KEY = 'news_morning_draft'; +const CONFIG_TABLE = 'h5_wechat_admin_config'; + +async function main() { + const apply = process.argv.includes('--apply'); + const allowYesterday = process.argv.includes('--allow-yesterday'); + if (!process.env.DATABASE_URL) { + console.error('DATABASE_URL missing'); + process.exit(1); + } + + const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 }); + const [rows] = await pool.query( + `SELECT config_json FROM ${CONFIG_TABLE} WHERE config_key = ? LIMIT 1`, + [CONFIG_KEY], + ); + const current = rows[0]?.config_json + ? (typeof rows[0].config_json === 'string' + ? JSON.parse(rows[0].config_json) + : rows[0].config_json) + : {}; + const next = { + ...current, + enabled: current.enabled !== false, + forcePushOnce: true, + forcePushRequireToday: !allowYesterday, + }; + + console.log(JSON.stringify({ + apply, + allowYesterday, + sourceUserId: current.sourceUserId ?? null, + enabled: next.enabled, + forcePushRequireToday: next.forcePushRequireToday, + }, null, 2)); + + if (apply) { + const now = Date.now(); + await pool.query( + `INSERT INTO ${CONFIG_TABLE} (config_key, config_json, updated_by, updated_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + updated_by = VALUES(updated_by), + updated_at = VALUES(updated_at)`, + [CONFIG_KEY, JSON.stringify(next), 'manual-push-draft', now], + ); + console.log('forcePushOnce=true persisted; Portal worker should push draft within ~30s'); + } + + await pool.end(); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/server.mjs b/server.mjs index cdfa015..74c1160 100644 --- a/server.mjs +++ b/server.mjs @@ -638,6 +638,7 @@ async function bootstrapUserAuth() { mindSpacePages, mindSpacePageLiveEdit, syncUserGeneratedPages, + mindSpaceImageGeneration, healthChannelStore, healthObservationStore: healthDataRuntime.observationStore, healthObservationService: healthDataRuntime.observationService, diff --git a/server/portal-domain-services-bootstrap.mjs b/server/portal-domain-services-bootstrap.mjs index 4165a4c..1e0b4a0 100644 --- a/server/portal-domain-services-bootstrap.mjs +++ b/server/portal-domain-services-bootstrap.mjs @@ -116,6 +116,8 @@ export async function bootstrapPortalDomainServices({ const scheduleService = createScheduleServiceFn(pool, { defaultTimezone: env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', + h5Root, + env, }); const scheduledTaskService = createScheduledTaskServiceFn(pool, { defaultTimezone: diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index f22b6d6..25e2425 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -52,6 +52,7 @@ export async function bootstrapPortalIntegrationServices({ mindSpacePages, mindSpacePageLiveEdit, syncUserGeneratedPages = null, + mindSpaceImageGeneration = null, logger = console, healthChannelStore = null, healthObservationStore = null, @@ -377,7 +378,9 @@ export async function bootstrapPortalIntegrationServices({ mpConfig: wechatMpConfig, h5Root, memindLibRoot: codeRoot || h5Root, + imageGenerationService: mindSpaceImageGeneration, env, + logger, }); let wechatNewsMorningDraftWorker = null; diff --git a/wechat-mp-config.mjs b/wechat-mp-config.mjs index 2209534..ae2e235 100644 --- a/wechat-mp-config.mjs +++ b/wechat-mp-config.mjs @@ -81,6 +81,13 @@ export function loadWechatMpConfig(env = process.env) { : DEFAULT_SUBSCRIBE_PLAZA_HOME_URL), subscribeMorningReminderEnabled: String(env.H5_WECHAT_MP_SUBSCRIBE_MORNING_REMINDER_ENABLED ?? '1').trim() !== '0', + pushSubscriptionEnabled: (() => { + const explicit = env.H5_WECHAT_MP_PUSH_SUBSCRIPTION_ENABLED; + if (explicit != null && String(explicit).trim() !== '') { + return String(explicit).trim() !== '0'; + } + return String(env.H5_WECHAT_MP_SUBSCRIBE_MORNING_REMINDER_ENABLED ?? '1').trim() !== '0'; + })(), subscribeMorningHour: Math.min( 23, Math.max(0, Number(env.H5_WECHAT_MP_SUBSCRIBE_MORNING_HOUR ?? 8) || 8), diff --git a/wechat-mp.mjs b/wechat-mp.mjs index be1f3a8..5fc4755 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -51,6 +51,11 @@ import { resolveSyncReply, shouldHandleSyncReply, } from './wechat/handlers/sync-replies.mjs'; +import { handlePushSubscriptionMessages } from './wechat/handlers/push-subscription.mjs'; +import { + createPushSubscriptionPendingStore, + pushSubscriptionPendingTtlMs, +} from './wechat/push-subscription-pending.mjs'; import { createSubscribeMorningReminderPendingStore, handleSubscribeMorningReminderMessages, @@ -1739,6 +1744,11 @@ export function createWechatMpService({ wechatVoiceRecoApiBase: config.wechatVoiceRecoApiBase || 'https://api.weixin.qq.com', }; const deferredStore = createWechatCustomerServiceDeferredStore({ mysqlPool, logger }); + const pushSubscriptionPendingStore = createPushSubscriptionPendingStore({ + mysqlPool, + logger, + ttlMs: pushSubscriptionPendingTtlMs(env), + }); const subscribeMorningPendingStore = createSubscribeMorningReminderPendingStore({ mysqlPool, logger, @@ -3868,7 +3878,17 @@ export function createWechatMpService({ } if (inbound.msgType === 'event' && inbound.event === 'subscribe') { - if (config.subscribeMorningReminderEnabled !== false) { + if (config.pushSubscriptionEnabled !== false) { + await pushSubscriptionPendingStore.setPending({ + appId: config.appId, + openid: inbound.fromUserName, + }).catch((err) => { + logger.warn?.( + 'WeChat MP push subscription pending failed open:', + err instanceof Error ? err.message : err, + ); + }); + } else if (config.subscribeMorningReminderEnabled !== false) { await subscribeMorningPendingStore.setPending({ appId: config.appId, openid: inbound.fromUserName, @@ -3891,6 +3911,7 @@ export function createWechatMpService({ bindUrl: buildBindUrl(), introUrl: config.subscribeIntroUrl, plazaHomeUrl: config.subscribePlazaHomeUrl, + pushSubscriptionEnabled: config.pushSubscriptionEnabled !== false, morningReminderEnabled: config.subscribeMorningReminderEnabled !== false, morningReminderHour: config.subscribeMorningHour, morningReminderMinute: config.subscribeMorningMinute, @@ -3933,7 +3954,45 @@ export function createWechatMpService({ const boundUser = await userAuth.findWechatUserByOpenid(config.appId, inbound.fromUserName); - if ( + if (intent.msgType === 'text' && config.pushSubscriptionEnabled !== false) { + const pushSubscriptionReply = await handlePushSubscriptionMessages({ + appId: config.appId, + openid: inbound.fromUserName, + text: intent.agentText, + pendingStore: pushSubscriptionPendingStore, + scheduleService, + boundUser, + timezone: env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', + bindUrl: buildBindUrl(), + sourceMessageId: inbound.msgId ?? null, + wechatSubscribeMorningLlmConfigService, + llmProviderService, + logger, + }).catch((err) => { + logger.warn?.( + 'WeChat MP push subscription failed open:', + err instanceof Error ? err.message : err, + ); + return null; + }); + if (pushSubscriptionReply) { + await persistIntentDetail({ + intent, + userId: boundUser?.userId ?? null, + rawXmlHash, + }); + return { + ok: true, + status: 200, + contentType: 'application/xml; charset=utf-8', + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: pushSubscriptionReply, + }), + }; + } + } else if ( intent.msgType === 'text' && config.subscribeMorningReminderEnabled !== false ) { diff --git a/wechat-news-morning-draft-worker.mjs b/wechat-news-morning-draft-worker.mjs index 3b507d6..5fd9258 100644 --- a/wechat-news-morning-draft-worker.mjs +++ b/wechat-news-morning-draft-worker.mjs @@ -153,6 +153,31 @@ export function startWechatNewsMorningDraftWorker({ if (!config.enabled || !config.sourceUserId) return; const now = Date.now(); + + if (config.forcePushOnce) { + try { + const result = await wechatNewsMorningDraftService.pushDraft({ + triggeredBy: 'manual-push', + requireToday: config.forcePushRequireToday !== false, + now, + }); + await wechatNewsMorningDraftService.updateConfig( + { forcePushOnce: false }, + { updatedBy: 'manual-push' }, + ); + logger.log?.('[NewsMorningDraft] force push succeeded', { + draftMediaId: result.draftMediaId, + coverSource: result.cover?.source ?? null, + pageSlug: result.preview?.page?.slug ?? null, + }); + } catch (error) { + logger.warn?.( + '[NewsMorningDraft] force push failed:', + error instanceof Error ? error.message : error, + ); + } + return; + } const timezone = config.timezone; const dateKey = localDateKey(now, timezone); const toleranceMinutes = Math.max(1, Math.ceil(Number(intervalMs) / 60_000)); diff --git a/wechat-news-morning-draft.mjs b/wechat-news-morning-draft.mjs index a2c580f..74c7f12 100644 --- a/wechat-news-morning-draft.mjs +++ b/wechat-news-morning-draft.mjs @@ -218,6 +218,236 @@ export function isWithinNewsMorningGenerateLeadWindow( return current >= start && current < push; } +function extractMindspaceCoverMeta(html) { + const raw = String(html ?? ''); + const match = raw.match(/]+name=["']mindspace-cover["'][^>]+content=["']([^"']+)["']/i) + ?? raw.match(/]+name=["']mindspace-cover["'][^>]+content=(\{[^>]+\})/i); + if (!match?.[1]) return null; + try { + const unfenced = match[1] + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); + const parsed = JSON.parse(unfenced); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +function extractHeroSubtitle(html) { + return stripHtml( + String(html).match(/
]*>([\s\S]*?)<\/div>/i)?.[1] ?? '', + ); +} + +export const NEWS_MORNING_COVER_BRAND = Object.freeze({ + crimson: '#b0191e', + navy: '#123a6b', +}); + +/** 每日轮换的封面视觉概念;同一自然日固定同一套,避免重复推草稿时风格漂移。 */ +export const NEWS_MORNING_COVER_CONCEPTS = Object.freeze([ + { + id: 'split-skyline', + buildScene: () => [ + 'Cinematic aerial dawn over a modern Asian megacity skyline.', + 'Split lighting: warm golden sunrise on one half and cool deep blue shadow on the other, symbolizing contrasting global headlines.', + 'Atmospheric haze, subtle lens flare, crimson (#b0191e) and navy (#123a6b) color grading, fine art photography.', + ].join(' '), + }, + { + id: 'data-nebula', + buildScene: () => [ + 'Abstract 3D editorial art: luminous particle streams and wireframe globe floating in dark space.', + 'Crimson and navy gradient nebula, geometric light trails suggesting global news flow.', + 'Premium magazine cover aesthetic, cinematic depth of field, glossy render.', + ].join(' '), + }, + { + id: 'theme-mosaic', + buildScene: ({ headlineHints }) => [ + 'Bold editorial mosaic collage for a morning news digest: stylized vignettes in one square frame', + '(financial towers with abstract red-green light bars, ancient artifact silhouette, international color blocks, festival tent silhouette)', + 'unified by diagonal crimson-to-navy gradient overlay, modern graphic design, high contrast.', + headlineHints ? `Mood cues: ${headlineHints}.` : null, + ].filter(Boolean).join(' '), + }, + { + id: 'brand-editorial', + buildScene: () => [ + 'Flat modern editorial vector cover: diagonal gradient from crimson (#b0191e) to navy (#123a6b),', + 'soft sunrise glow, abstract globe wireframe, notification dots, subtle chart-line motifs, centered negative space.', + ].join(' '), + }, + { + id: 'morning-window', + buildScene: () => [ + 'Warm morning sunlight streaming through a window onto a minimal interior wall.', + 'Soft bokeh garden outside, abstract crimson and navy color blocks on the wall suggesting news tiles,', + 'cozy dawn atmosphere, lifestyle photography, shallow depth of field.', + ].join(' '), + }, + { + id: 'satellite-terminator', + buildScene: () => [ + 'Earth viewed from space at the day-night terminator line, city lights glowing on the dark side,', + 'sunrise rim light on clouds, deep navy space background with crimson atmospheric glow,', + 'NASA-style cinematic realism, global perspective.', + ].join(' '), + }, + { + id: 'geometric-pulse', + buildScene: () => [ + 'Minimal geometric editorial cover: layered crimson and navy arcs, pulse waves and dot grids,', + 'clean Swiss design, subtle grain texture, strong diagonal composition, premium news brand feel.', + ].join(' '), + }, +]); + +function buildNewsMorningCoverContext(html, { now = Date.now(), timezone = 'Asia/Shanghai' } = {}) { + const dateParts = formatLocalDateParts(now, timezone); + const dateBadge = stripHtml( + String(html).match(/
]*>([\s\S]*?)<\/div>/i)?.[1] ?? '', + ); + const coverMeta = extractMindspaceCoverMeta(html); + const subtitle = String(coverMeta?.subtitle ?? extractHeroSubtitle(html) ?? '').trim(); + const headlineHints = subtitle + .split(/[·•]/) + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 3) + .join(', '); + const dateLabel = dateBadge || `${dateParts.year}-${dateParts.month}-${dateParts.day}`; + return { dateParts, dateLabel, headlineHints }; +} + +function hashNewsMorningCoverDay(isoDate) { + let hash = 0; + for (const ch of String(isoDate)) { + hash = (hash * 31 + ch.charCodeAt(0)) >>> 0; + } + return hash; +} + +export function resolveNewsMorningCoverConcept({ now = Date.now(), timezone = 'Asia/Shanghai' } = {}) { + const { dateParts } = buildNewsMorningCoverContext('', { now, timezone }); + const index = hashNewsMorningCoverDay(dateParts.iso) % NEWS_MORNING_COVER_CONCEPTS.length; + const concept = NEWS_MORNING_COVER_CONCEPTS[index]; + return { id: concept.id, index, isoDate: dateParts.iso }; +} + +export function buildNewsMorningCoverPrompt(html, { now = Date.now(), timezone = 'Asia/Shanghai' } = {}) { + const context = buildNewsMorningCoverContext(html, { now, timezone }); + const conceptMeta = resolveNewsMorningCoverConcept({ now, timezone }); + const concept = NEWS_MORNING_COVER_CONCEPTS[conceptMeta.index]; + return [ + 'Square WeChat article cover thumbnail for the Chinese morning news digest brand "Daily News Morning Brief".', + `Edition date mood: ${context.dateLabel}.`, + `Visual concept (${concept.id}): ${concept.buildScene(context)}`, + context.headlineHints + ? `Only subtle abstract mood hints, do not illustrate news stories literally: ${context.headlineHints}.` + : null, + 'Must look like a professional daily news briefing product cover, not calligraphy art, not fantasy illustration.', + 'No text, no letters, no numbers, no Chinese characters, no calligraphy, no seals, no logos, no watermark, no human faces.', + 'No smartphone, no mobile phone, no newspaper, no coffee cup, no office desk still life.', + ].filter(Boolean).join(' '); +} + +export function buildNewsMorningCoverNegativePrompt() { + return [ + 'smartphone', + 'mobile phone', + 'newspaper', + 'coffee cup', + 'desk still life', + 'calligraphy', + 'Chinese characters', + 'seal stamp', + 'anime', + 'cartoon child', + 'fantasy', + 'maple leaves', + 'text', + 'letters', + 'watermark', + 'logo', + ].join(', '); +} + +function resolveGeneratedAssetPath(h5Root, userId, workspaceRelativePath) { + return path.join(h5Root, PUBLISH_ROOT_DIR, userId, workspaceRelativePath); +} + +async function resolveNewsMorningDraftThumbBuffer({ + page, + html, + config, + h5Root, + imageGenerationService, + logger = console, + now = Date.now(), +}) { + const thumbPath = page?.localPath + ? `${page.localPath.replace(/\.html$/i, '')}.thumbnail.png` + : null; + + if (imageGenerationService?.generate && config.sourceUserId && page?.slug) { + try { + const prompt = buildNewsMorningCoverPrompt(html, { now, timezone: config.timezone }); + const negativePrompt = buildNewsMorningCoverNegativePrompt(); + const result = await imageGenerationService.generate({ + userId: config.sourceUserId, + purpose: 'inline_image', + prompt, + negativePrompt, + consumerRef: `wechat-news-morning:${page.slug}:cover`, + idempotencyKey: `wechat-news-morning-${page.slug}-cover-v3`, + }); + if (result?.ok && result?.asset?.workspaceRelativePath) { + const assetPath = resolveGeneratedAssetPath( + h5Root, + config.sourceUserId, + result.asset.workspaceRelativePath, + ); + if (fs.existsSync(assetPath)) { + const raw = fs.readFileSync(assetPath); + const square = await sharp(raw, { sequentialRead: true }) + .rotate() + .resize(900, 900, { fit: 'cover' }) + .png() + .toBuffer(); + if (thumbPath) { + fs.mkdirSync(path.dirname(thumbPath), { recursive: true }); + fs.writeFileSync(thumbPath, square); + } + return { buffer: square, source: 'generated', prompt, assetPath }; + } + } + logger.warn?.('[NewsMorningDraft] cover generation returned without asset file', { + code: result?.code ?? null, + slug: page.slug, + }); + } catch (error) { + logger.warn?.( + '[NewsMorningDraft] cover generation failed:', + error instanceof Error ? error.message : error, + ); + } + } + + if (page?.thumbPath && fs.existsSync(page.thumbPath)) { + return { buffer: fs.readFileSync(page.thumbPath), source: 'sidecar' }; + } + + const fallback = await sharp( + Buffer.from( + '今日新闻', + ), + ).png().toBuffer(); + return { buffer: fallback, source: 'fallback' }; +} + export function buildNewsMorningAutoGenerationTask(config, { now = Date.now(), searchBrief = '' } = {}) { const timezone = String(config?.timezone ?? 'Asia/Shanghai').trim() || 'Asia/Shanghai'; const dateLabel = formatLocalDateParts(now, timezone); @@ -278,7 +508,9 @@ function extractCardArticlesFromHotspots(html) { const cards = parts.map((chunk) => chunk.split(/
{ const title = stripHtml(block.match(/]*>([\s\S]*?)<\/h2>/i)?.[1] ?? ''); - const tag = stripHtml(block.match(/]*>([\s\S]*?)<\/span>/i)?.[1] ?? ''); + const tag = stripHtml( + block.match(/]*>([\s\S]*?)<\/span>/i)?.[1] ?? '', + ); const keys = [...block.matchAll(/
]*>([\s\S]*?)<\/div>/gi)] .map((item) => stripHtml(item[1])) .filter(Boolean); @@ -297,7 +529,9 @@ function extractCardArticlesFromDailyNews(html) { ?? block.match(/]*>([\s\S]*?)<\/h2>/i)?.[1] ?? '', ); - const tag = stripHtml(block.match(/]*>([\s\S]*?)<\/span>/i)?.[1] ?? ''); + const tag = stripHtml( + block.match(/]*>([\s\S]*?)<\/span>/i)?.[1] ?? '', + ); const paragraphs = [...block.matchAll(/]*>([\s\S]*?)<\/p>/gi)] .map((item) => stripHtml(item[1])) .filter(Boolean); @@ -312,6 +546,36 @@ function extractCardArticles(html) { return extractCardArticlesFromHotspots(html); } +/** 服务号订阅推送:从早报 HTML 提取纯文本摘要(5–10 条标题)。 */ +export function buildNewsMorningWechatPushText( + html, + { publicUrl = '', maxItems = 5, manageHint = '回复「取消2」关闭推送' } = {}, +) { + const cards = extractCardArticles(html).slice(0, Math.max(1, Math.min(10, Number(maxItems) || 5))); + const heroDate = stripHtml( + String(html).match(/
]*>([\s\S]*?)<\/div>/i)?.[1] + ?? String(html).match(/]*>([\s\S]*?)<\/span>/i)?.[1] + ?? '', + ); + const lines = [`📰 每日新闻早报${heroDate ? ` · ${heroDate}` : ''}`]; + if (cards.length === 0) { + lines.push('', '今日早报正在整理中,请稍后再看。'); + } else { + lines.push(''); + for (const card of cards) { + const prefix = card.tag ? `[${card.tag}] ` : ''; + lines.push(`${card.index}. ${prefix}${card.title}`); + } + } + if (publicUrl) { + lines.push('', `👉 阅读全文:${publicUrl}`); + } + if (manageHint) { + lines.push('', manageHint); + } + return lines.join('\n'); +} + function extractDailyNewsSections(html) { return [...String(html ?? '').matchAll(/
]*>([\s\S]*?)<\/div>/gi)] .map((item) => stripHtml(item[1])) @@ -871,6 +1135,8 @@ function defaultsFromEnv(env = process.env) { author: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTHOR ?? 'TKMind').trim() || 'TKMind', openComment: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_OPEN_COMMENT, true), forceGenerateOnce: false, + forcePushOnce: false, + forcePushRequireToday: true, templateVersion: NEWS_MORNING_TEMPLATE_VERSION, }; } @@ -881,8 +1147,10 @@ export function createWechatNewsMorningDraftService( mpConfig = null, h5Root = process.cwd(), memindLibRoot = h5Root, + imageGenerationService = null, env = process.env, wechatFetch = undiciFetch, + logger = console, } = {}, ) { let ensurePromise = null; @@ -930,6 +1198,8 @@ export function createWechatNewsMorningDraftService( openComment: normalizeBoolean(stored.openComment, defaults.openComment), forceGenerateOnce: normalizeBoolean(stored.forceGenerateOnce, false), taskSpecAppend: String(stored.taskSpecAppend ?? '').trim(), + forcePushOnce: normalizeBoolean(stored.forcePushOnce, false), + forcePushRequireToday: normalizeBoolean(stored.forcePushRequireToday, true), templateVersion: NEWS_MORNING_TEMPLATE_VERSION, }; } @@ -1231,7 +1501,6 @@ export function createWechatNewsMorningDraftService( } try { const accessToken = await getAccessToken(); - let thumbBuffer = null; const page = findLatestNewsMorningPage({ h5Root, userId: config.sourceUserId, @@ -1239,16 +1508,17 @@ export function createWechatNewsMorningDraftService( date: new Date(now), timezone: config.timezone, }); - if (page?.thumbPath) { - thumbBuffer = fs.readFileSync(page.thumbPath); - } else if (page?.localPath) { - thumbBuffer = Buffer.from( - '今日新闻', - ); - thumbBuffer = await sharp(thumbBuffer).png().toBuffer(); - } - const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumbBuffer, { wechatFetch }); const html = fs.readFileSync(page.localPath, 'utf8'); + const thumb = await resolveNewsMorningDraftThumbBuffer({ + page, + html, + config, + h5Root, + imageGenerationService, + logger, + now, + }); + const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumb.buffer, { wechatFetch }); const article = isDailyNewsFormat(html) ? await buildDailyNewsWechatDraftArticleForPush({ html, @@ -1286,6 +1556,11 @@ export function createWechatNewsMorningDraftService( return { ok: true, draftMediaId: draft.draftMediaId, + cover: { + source: thumb.source, + prompt: thumb.prompt ?? null, + assetPath: thumb.assetPath ?? null, + }, preview, run, }; @@ -1386,6 +1661,12 @@ export const wechatNewsMorningDraftInternals = { isLocalScheduleDue, isWithinNewsMorningGenerateLeadWindow, buildNewsMorningAutoGenerationTask, + buildNewsMorningCoverPrompt, + buildNewsMorningCoverNegativePrompt, + resolveNewsMorningCoverConcept, + NEWS_MORNING_COVER_CONCEPTS, + buildNewsMorningWechatPushText, + extractMindspaceCoverMeta, NEWS_MORNING_FRESH_CONTENT_SPEC, formatLocalDateParts, localDateKey, diff --git a/wechat-news-morning-draft.test.mjs b/wechat-news-morning-draft.test.mjs index fc35e3e..015816f 100644 --- a/wechat-news-morning-draft.test.mjs +++ b/wechat-news-morning-draft.test.mjs @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { + buildNewsMorningWechatPushText, convertNewsPageHtmlToWechatArticle, createWechatNewsMorningDraftService, findLatestNewsMorningPage, @@ -102,6 +103,60 @@ test('convertNewsPageHtmlToWechatArticle converts daily-news to inline html with assert.doesNotMatch(article.content, /data-mindspace-public-share/u); }); +test('buildNewsMorningWechatPushText builds service-account digest text', () => { + const text = buildNewsMorningWechatPushText(SAMPLE_HTML, { + publicUrl: 'https://m.tkmind.cn/MindSpace/demo/public/daily-news-0910.html', + }); + assert.match(text, /📰 每日新闻早报 · 2026年9月10日/u); + assert.match(text, /示例热点/u); + assert.match(text, /阅读全文/u); +}); + +test('buildNewsMorningCoverPrompt rotates visual concepts by date and keeps subtitle hints', () => { + const html = SAMPLE_HTML.replace( + '
综合多家权威媒体梳理全网核心热点。
', + '
示例热点A·示例热点B·示例热点C
', + ); + const now = Date.parse('2026-09-10T08:00:00+08:00'); + const concept = wechatNewsMorningDraftInternals.resolveNewsMorningCoverConcept({ + now, + timezone: 'Asia/Shanghai', + }); + const prompt = wechatNewsMorningDraftInternals.buildNewsMorningCoverPrompt(html, { + now, + timezone: 'Asia/Shanghai', + }); + assert.equal(concept.isoDate, '2026-09-10'); + assert.match(prompt, new RegExp(`Visual concept \\(${concept.id}\\)`, 'i')); + assert.match(prompt, /Daily News Morning Brief/i); + assert.match(prompt, /#b0191e|crimson|navy/i); + assert.match(prompt, /示例热点A/u); + assert.match(prompt, /No text, no letters/i); + assert.match(prompt, /No smartphone, no mobile phone, no newspaper/i); +}); + +test('resolveNewsMorningCoverConcept is stable within a day and rotates across days', () => { + const timezone = 'Asia/Shanghai'; + const dayA = wechatNewsMorningDraftInternals.resolveNewsMorningCoverConcept({ + now: Date.parse('2026-09-10T08:00:00+08:00'), + timezone, + }); + const dayARepeat = wechatNewsMorningDraftInternals.resolveNewsMorningCoverConcept({ + now: Date.parse('2026-09-10T22:00:00+08:00'), + timezone, + }); + const dayB = wechatNewsMorningDraftInternals.resolveNewsMorningCoverConcept({ + now: Date.parse('2026-09-11T08:00:00+08:00'), + timezone, + }); + assert.equal(dayA.id, dayARepeat.id); + assert.notEqual(dayA.id, dayB.id); + assert.equal( + wechatNewsMorningDraftInternals.NEWS_MORNING_COVER_CONCEPTS.length, + 7, + ); +}); + test('buildDailyNewsWechatHtmlContent preserves style and body markup', () => { const content = wechatNewsMorningDraftInternals.buildDailyNewsWechatHtmlContent(SAMPLE_HTML, { publicUrl: 'https://m.tkmind.cn/MindSpace/demo/public/daily-news-0910.html', diff --git a/wechat/daily-knowledge-library.mjs b/wechat/daily-knowledge-library.mjs new file mode 100644 index 0000000..aa57490 --- /dev/null +++ b/wechat/daily-knowledge-library.mjs @@ -0,0 +1,103 @@ +import { hashString } from './push-content/daily-library-pick.mjs'; +import { localDateKey, normalizeTimezone } from '../schedule-time.mjs'; +import { + extractKnowledgeItemsFromHtml, + loadTodayNewsMorningHtml, +} from './push-content/news-section-extract.mjs'; + +export const DAILY_KNOWLEDGE_LIBRARY = Object.freeze([ + { + title: '复利效应', + body: '每天进步 1%,一年后会变成原来的 37 倍;每天退步 1%,则会接近归零。关键在持续,而非一次爆发。', + }, + { + title: '机会成本', + body: '每个选择都意味着放弃其它可能。做决策时,除了问「能得到什么」,也要问「因此放弃了什么」。', + }, + { + title: '墨菲定律', + body: '可能出错的事,迟早会出错。重要事项要多留备份、多留时间余量,而不是赌运气。', + }, + { + title: '帕累托法则', + body: '约 20% 的因素,往往带来 80% 的结果。把精力优先放在高杠杆事项上,效率会明显提升。', + }, + { + title: '锚定效应', + body: '人容易被第一个信息「锚住」。看价格、看评价、看谈判条件时,先问:这个起点是否被故意设计?', + }, + { + title: '沉没成本', + body: '已经付出的成本不应影响未来决策。该止损时止损,才能把资源留给更值得的方向。', + }, + { + title: '峰终定律', + body: '人对体验的记忆,主要由「高峰」和「结束」决定。做产品、做服务、做沟通,收尾同样重要。', + }, + { + title: '第一性原理', + body: '回到问题本质,从基本事实重新推导,而不是类比「别人怎么做」。创新常从这里开始。', + }, + { + title: '延迟满足', + body: '短期诱惑与长期收益冲突时,能等待的人更容易积累优势。把目标拆小,会更容易坚持。', + }, + { + title: '系统思维', + body: '很多问题不是单点故障,而是系统结构的结果。改结构,往往比反复补漏洞更有效。', + }, +]); + +export function pickDailyKnowledgeFromLibrary({ + userId = '', + timezone = 'Asia/Shanghai', + now = Date.now(), +} = {}) { + const tz = normalizeTimezone(timezone); + const dateKey = localDateKey(now, tz); + const idx = hashString(`${String(userId)}:${dateKey}:knowledge`) + % DAILY_KNOWLEDGE_LIBRARY.length; + return DAILY_KNOWLEDGE_LIBRARY[idx]; +} + +export function formatDailyKnowledgeDeliveryText({ + userId, + timezone = 'Asia/Shanghai', + now = Date.now(), + item = null, +} = {}) { + const knowledge = item ?? pickDailyKnowledgeFromLibrary({ userId, timezone, now }); + return [ + '📚 每日知识', + '', + `【${knowledge.title}】`, + knowledge.body, + '', + '回复「取消6」关闭推送', + ].join('\n'); +} + +export async function formatKnowledgePushDelivery(options = {}) { + const { html } = await loadTodayNewsMorningHtml(options); + const items = html ? extractKnowledgeItemsFromHtml(html) : []; + if (items.length > 0) { + const picked = items[0]; + return { + text: formatDailyKnowledgeDeliveryText({ + userId: options.userId, + timezone: options.timezone, + now: options.now, + item: { title: picked.title, body: picked.body || '今日知识速递,详见新闻早报知识栏。' }, + }), + verifiedHtmlUrls: [], + }; + } + return { + text: formatDailyKnowledgeDeliveryText({ + userId: options.userId, + timezone: options.timezone, + now: options.now, + }), + verifiedHtmlUrls: [], + }; +} diff --git a/wechat/daily-knowledge-library.test.mjs b/wechat/daily-knowledge-library.test.mjs new file mode 100644 index 0000000..70fee3c --- /dev/null +++ b/wechat/daily-knowledge-library.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { formatDailyKnowledgeDeliveryText } from './daily-knowledge-library.mjs'; + +test('formatDailyKnowledgeDeliveryText renders knowledge card', () => { + const text = formatDailyKnowledgeDeliveryText({ + userId: 'user-knowledge', + timezone: 'Asia/Shanghai', + now: Date.parse('2026-09-20T12:00:00+08:00'), + }); + assert.match(text, /^📚 每日知识/u); + assert.match(text, /【/u); + assert.match(text, /取消6/u); +}); diff --git a/wechat/daily-quote-library.mjs b/wechat/daily-quote-library.mjs new file mode 100644 index 0000000..31390e7 --- /dev/null +++ b/wechat/daily-quote-library.mjs @@ -0,0 +1,57 @@ +import { pickDailyLibraryEntry } from './push-content/daily-library-pick.mjs'; + +export const DAILY_QUOTE_LIBRARY = Object.freeze([ + '你要按所想的去生活,别按生活的去想。', + '世界很大,脚步不要停;心很小,要装得下热爱。', + '不必光芒万丈,但应温暖有光。', + '把今天过好,就是对未来最好的投资。', + '慢一点没关系,方向对就好。', + '你值得被温柔以待,先从对自己温柔开始。', + '每一次认真生活,都是在给未来攒底气。', + '别急着回答世界,先听听自己的心跳。', + '山再高,往上走,总能登顶;路再长,走下去,定能到达。', + '生活不会一直顺利,但你可以一直努力。', + '把「以后再说」换成「现在就开始」。', + '你要相信,所有失去,都会以另一种方式归来。', + '不必和任何人比,今天的你比昨天更好就够了。', + '真正厉害的人,是把简单的事重复做,做到极致。', + '心有所期,忙而不茫;心有所向,素履以往。', + '当你觉得难的时候,正是在走上坡路。', + '允许自己偶尔停机,是为了更好地重启。', + '你要做那个,一出现就让人安心的人。', + '生活不是等待风暴过去,而是学会在雨中跳舞。', + '把抱怨的时间,用来改变能改变的事。', + '你所经历的,都会变成你的礼物。', + '别被情绪带着走,你要做情绪的主人。', + '今天的努力,是幸运的伏笔。', + '保持热爱,奔赴下一场山海。', + '你要悄悄拔尖,然后惊艳所有人。', + '人生没有白走的路,每一步都算数。', + '与其担心未来,不如现在就把事做好。', + '你要相信,好事正在来的路上。', + '把复杂留给自己,把简单留给生活。', + '愿你眼里有光,心中有爱,脚下有路。', +]); + +export function pickDailyQuote({ + userId = '', + timezone = 'Asia/Shanghai', + now = Date.now(), +} = {}) { + return pickDailyLibraryEntry({ + userId, + timezone, + now, + library: DAILY_QUOTE_LIBRARY, + salt: 'quote', + }); +} + +export function formatDailyQuoteDeliveryText({ + userId, + timezone = 'Asia/Shanghai', + now = Date.now(), +} = {}) { + const quote = pickDailyQuote({ userId, timezone, now }); + return `💡 每日金句\n\n${quote}\n\n回复「取消7」关闭推送`; +} diff --git a/wechat/daily-quote-library.test.mjs b/wechat/daily-quote-library.test.mjs new file mode 100644 index 0000000..24e506b --- /dev/null +++ b/wechat/daily-quote-library.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DAILY_QUOTE_LIBRARY, + formatDailyQuoteDeliveryText, + pickDailyQuote, +} from './daily-quote-library.mjs'; + +test('pickDailyQuote is stable per user/day and varies across days', () => { + const args = { + userId: 'user-q', + timezone: 'Asia/Shanghai', + now: Date.parse('2026-09-20T07:30:00+08:00'), + }; + const first = pickDailyQuote(args); + const second = pickDailyQuote(args); + assert.equal(first, second); + assert.ok(DAILY_QUOTE_LIBRARY.includes(first)); + + const nextDay = pickDailyQuote({ + ...args, + now: Date.parse('2026-09-21T07:30:00+08:00'), + }); + assert.notEqual(first, nextDay); +}); + +test('formatDailyQuoteDeliveryText wraps quote with header', () => { + const text = formatDailyQuoteDeliveryText({ + userId: 'user-q2', + timezone: 'Asia/Shanghai', + now: Date.parse('2026-09-20T07:30:00+08:00'), + }); + assert.match(text, /^💡 每日金句\n\n/u); + assert.match(text, /取消7/u); +}); diff --git a/wechat/daily-surprise-library.mjs b/wechat/daily-surprise-library.mjs new file mode 100644 index 0000000..f944339 --- /dev/null +++ b/wechat/daily-surprise-library.mjs @@ -0,0 +1,99 @@ +import { pickDailyLibraryEntry } from './push-content/daily-library-pick.mjs'; +import { + extractCardsFromSectionIds, + loadTodayNewsMorningHtml, +} from './push-content/news-section-extract.mjs'; +import { pickSurpriseVariantIndex } from './night-summary-library.mjs'; + +export const SURPRISE_COLD_FACTS = Object.freeze([ + '蜂鸟是唯一能够倒着飞的鸟类。', + '章鱼有三颗心脏,血液是蓝色的。', + '香蕉在植物学上属于浆果,草莓反而不是。', + '人类鼻子能识别超过万亿种不同气味组合。', + '月球正在以每年约 3.8 厘米的速度远离地球。', +]); + +export const SURPRISE_FUN_FACTS = Object.freeze([ + '「OK」可能源自 19 世纪美国「oll korrect」的幽默拼写。', + '世界上最长的地名之一在新西兰,长达 85 个字母。', + '咖啡最早被当作食物而不是饮料来食用。', + '企鹅求偶时会送一颗小石子给心仪对象。', + '古代罗马人曾用海绵绑在长棍上当作「厕纸」。', +]); + +export const SURPRISE_TRIVIA = Object.freeze([ + '今天试试用左手刷牙,给大脑一点新鲜刺激。', + '随机给一位朋友发一句感谢,关系会悄悄升温。', + '把你桌面最乱的一个角落整理 5 分钟,心情会轻一点。', + '找一首没听过的歌,当作今天的背景音。', + '写下今天一件「虽小但不错」的事,留给未来的自己看。', +]); + +export function pickDailySurpriseText({ + userId = '', + timezone = 'Asia/Shanghai', + now = Date.now(), + variant = null, +} = {}) { + const resolvedVariant = variant ?? pickSurpriseVariantIndex({ userId, timezone, now }); + const libraries = [SURPRISE_COLD_FACTS, SURPRISE_FUN_FACTS, SURPRISE_TRIVIA]; + const labels = ['冷知识', '趣闻', '今日小挑战']; + const library = libraries[resolvedVariant] ?? SURPRISE_COLD_FACTS; + const label = labels[resolvedVariant] ?? '惊喜'; + const text = pickDailyLibraryEntry({ + userId, + timezone, + now, + library, + salt: `surprise:${label}`, + }); + return { label, text }; +} + +export function formatDailySurpriseDeliveryText({ + userId, + timezone = 'Asia/Shanghai', + now = Date.now(), + payload = null, +} = {}) { + const resolved = payload ?? pickDailySurpriseText({ userId, timezone, now }); + return [ + '🎁 每日惊喜', + '', + `【${resolved.label}】${resolved.text}`, + '', + '回复「取消10」关闭推送', + ].join('\n'); +} + +export async function formatSurprisePushDelivery(options = {}) { + const { html, dateLabel } = await loadTodayNewsMorningHtml(options); + if (html) { + const historyCards = extractCardsFromSectionIds(html, ['history']); + const card = historyCards[0]; + if (card) { + const summary = card.context ? `${card.title}:${card.context}` : card.title; + return { + text: formatDailySurpriseDeliveryText({ + userId: options.userId, + timezone: options.timezone, + now: options.now, + payload: { + label: dateLabel ? `历史上的今天 · ${dateLabel}` : '历史上的今天', + text: summary, + }, + }), + verifiedHtmlUrls: [], + }; + } + } + + return { + text: formatDailySurpriseDeliveryText({ + userId: options.userId, + timezone: options.timezone, + now: options.now, + }), + verifiedHtmlUrls: [], + }; +} diff --git a/wechat/daily-surprise-library.test.mjs b/wechat/daily-surprise-library.test.mjs new file mode 100644 index 0000000..28ed2c5 --- /dev/null +++ b/wechat/daily-surprise-library.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + formatDailySurpriseDeliveryText, + formatSurprisePushDelivery, +} from './daily-surprise-library.mjs'; + +test('formatDailySurpriseDeliveryText returns stable daily surprise', () => { + const text = formatDailySurpriseDeliveryText({ + userId: 'user-surprise', + timezone: 'Asia/Shanghai', + now: Date.parse('2026-09-20T18:00:00+08:00'), + }); + assert.match(text, /^🎁 每日惊喜/u); + assert.match(text, /取消10/u); +}); + +test('formatSurprisePushDelivery falls back to library without news page', async () => { + const delivery = await formatSurprisePushDelivery({ + userId: 'user-surprise', + now: Date.parse('2026-09-20T18:00:00+08:00'), + timezone: 'Asia/Shanghai', + h5Root: '/tmp/empty', + env: {}, + }); + assert.match(delivery.text, /每日惊喜/u); +}); diff --git a/wechat/handlers/push-subscription.mjs b/wechat/handlers/push-subscription.mjs new file mode 100644 index 0000000..444c87c --- /dev/null +++ b/wechat/handlers/push-subscription.mjs @@ -0,0 +1,433 @@ +import { + buildPushSubscriptionMenuLines, + getPushSubscriptionByKey, +} from '../push-subscription-catalog.mjs'; +import { normalizePushWeatherCity } from '../push-content/weather-delivery.mjs'; +import { + parsePushSubscriptionCancelRequest, + parsePushSubscriptionCityUpdate, + parsePushSubscriptionConfirmReply, + parsePushSubscriptionHelpRequest, + parsePushSubscriptionListRequest, + parsePushSubscriptionModifyRequest, + resolvePushSubscriptionConfirmTime, +} from '../push-subscription-parser.mjs'; +import { + cancelAllPushSubscriptions, + cancelPushSubscription, + commitPushSubscription, + findActivePushSubscription, + formatPushSubscriptionAlreadyActiveReply, + formatPushSubscriptionBindFirstReply, + formatPushSubscriptionCancelledAllReply, + formatPushSubscriptionCancelledReply, + formatPushSubscriptionCityUpdatedReply, + formatPushSubscriptionCommittedReply, + formatPushSubscriptionListReply, + formatPushSubscriptionModifyHelpReply, + formatPushSubscriptionNotReadyReply, + formatPushSubscriptionUpdatedReply, + listActivePushSubscriptions, + updatePushSubscriptionOptions, + updatePushSubscriptionTime, +} from '../push-subscription-service.mjs'; +import { resolveSubscribeMorningLlmIntent } from '../../wechat-subscribe-morning-llm.mjs'; + +async function tryCommitSubscription({ + userId, + scheduleService, + catalogItem, + hour, + minute, + timezone, + bindUrl, + boundUser, + sourceMessageId, + sourceText, + options = null, + logger, +}) { + if (!boundUser?.userId) { + return formatPushSubscriptionBindFirstReply({ bindUrl }); + } + if (!catalogItem.deliveryReady) { + return formatPushSubscriptionNotReadyReply({ catalogItem }); + } + if (!scheduleService) { + return '当前暂无法设置每日推送,请稍后再试。'; + } + + const existing = await findActivePushSubscription({ + userId: boundUser.userId, + scheduleService, + catalogItem, + }); + if (existing) { + return formatPushSubscriptionAlreadyActiveReply({ + catalogItem, + hour: existing.hour, + minute: existing.minute, + }); + } + + try { + await commitPushSubscription({ + userId: boundUser.userId, + scheduleService, + catalogItem, + hour, + minute, + timezone, + sourceMessageId, + sourceText, + options, + }); + return formatPushSubscriptionCommittedReply({ catalogItem, hour, minute, options }); + } catch (err) { + logger.warn?.( + '[push-subscription] commit failed:', + err instanceof Error ? err.message : err, + ); + return `订阅设置失败:${err instanceof Error ? err.message : String(err)}`; + } +} + +async function handleMorningLlmPendingTurn({ + text, + boundUser, + scheduleService, + timezone, + defaultHour, + defaultMinute, + bindUrl, + sourceMessageId, + wechatSubscribeMorningLlmConfigService, + llmProviderService, + logger, +}) { + const morningItem = getPushSubscriptionByKey('morning'); + if (!morningItem) return null; + + const llmIntent = await resolveSubscribeMorningLlmIntent({ + text, + phase: 'pending', + defaultHour, + defaultMinute, + wechatSubscribeMorningLlmConfigService, + llmProviderService, + logger, + }).catch(() => null); + + if (llmIntent?.action === 'confirm') { + return tryCommitSubscription({ + userId: boundUser?.userId, + scheduleService, + catalogItem: morningItem, + hour: llmIntent.hour, + minute: llmIntent.minute, + timezone, + bindUrl, + boundUser, + sourceMessageId, + sourceText: String(text ?? ''), + logger, + }); + } + if (llmIntent?.action === 'cancel') { + return '好的,未开启每日早安提醒。'; + } + if (llmIntent?.action === 'clarify' && llmIntent.message) { + return llmIntent.message; + } + return null; +} + +async function handleMorningLlmManageTurn({ + text, + boundUser, + scheduleService, + timezone, + defaultHour, + defaultMinute, + wechatSubscribeMorningLlmConfigService, + llmProviderService, + logger, +}) { + const morningItem = getPushSubscriptionByKey('morning'); + if (!morningItem || !boundUser?.userId || !scheduleService) return null; + + const active = await findActivePushSubscription({ + userId: boundUser.userId, + scheduleService, + catalogItem: morningItem, + }); + if (!active) return null; + + const llmIntent = await resolveSubscribeMorningLlmIntent({ + text, + phase: 'active', + defaultHour, + defaultMinute, + currentHour: active.hour, + currentMinute: active.minute, + wechatSubscribeMorningLlmConfigService, + llmProviderService, + logger, + }).catch(() => null); + + if (llmIntent?.action === 'cancel') { + try { + await cancelPushSubscription({ + userId: boundUser.userId, + scheduleService, + catalogItem: morningItem, + reason: '用户取消早安', + }); + return formatPushSubscriptionCancelledReply({ catalogItem: morningItem }); + } catch (err) { + logger.warn?.('[push-subscription] llm cancel failed:', err instanceof Error ? err.message : err); + return `取消失败:${err instanceof Error ? err.message : String(err)}`; + } + } + + if (llmIntent?.action === 'modify') { + try { + await updatePushSubscriptionTime({ + userId: boundUser.userId, + scheduleService, + catalogItem: morningItem, + hour: llmIntent.hour, + minute: llmIntent.minute, + timezone, + }); + return formatPushSubscriptionUpdatedReply({ + catalogItem: morningItem, + hour: llmIntent.hour, + minute: llmIntent.minute, + }); + } catch (err) { + logger.warn?.('[push-subscription] llm update failed:', err instanceof Error ? err.message : err); + return `修改失败:${err instanceof Error ? err.message : String(err)}`; + } + } + + if (llmIntent?.action === 'clarify' && llmIntent.message) { + return llmIntent.message; + } + + return null; +} + +export function buildPushSubscriptionHelpText() { + return buildPushSubscriptionMenuLines().join('\n'); +} + +/** + * 微信服务号数字订阅消息处理。 + * @returns {Promise} + */ +export async function handlePushSubscriptionMessages({ + appId, + openid, + text, + pendingStore, + scheduleService, + boundUser = null, + timezone = 'Asia/Shanghai', + bindUrl = '', + sourceMessageId = null, + wechatSubscribeMorningLlmConfigService = null, + llmProviderService = null, + logger = console, +} = {}) { + if (parsePushSubscriptionHelpRequest(text)) { + return buildPushSubscriptionHelpText(); + } + + if (parsePushSubscriptionListRequest(text)) { + if (!boundUser?.userId || !scheduleService) { + return '绑定账号后可查看订阅状态。'; + } + const subscriptions = await listActivePushSubscriptions({ + userId: boundUser.userId, + scheduleService, + }); + return formatPushSubscriptionListReply({ subscriptions }); + } + + const cityUpdate = parsePushSubscriptionCityUpdate(text); + if (cityUpdate && boundUser?.userId && scheduleService) { + try { + await updatePushSubscriptionOptions({ + userId: boundUser.userId, + scheduleService, + catalogItem: cityUpdate.item, + options: cityUpdate.options, + timezone, + }); + return formatPushSubscriptionCityUpdatedReply({ + catalogItem: cityUpdate.item, + options: cityUpdate.options, + }); + } catch (err) { + if (String(err?.message ?? err).includes('订阅不存在')) { + return `你尚未开通 ${cityUpdate.item.emoji} ${cityUpdate.item.label},可先回复 ${cityUpdate.item.id} 开通。`; + } + return `城市更新失败:${err instanceof Error ? err.message : String(err)}`; + } + } + + const cancelRequest = parsePushSubscriptionCancelRequest(text); + if (cancelRequest && boundUser?.userId && scheduleService) { + if (cancelRequest.all) { + const cancelled = await cancelAllPushSubscriptions({ + userId: boundUser.userId, + scheduleService, + }).catch((err) => { + logger.warn?.('[push-subscription] cancel all failed:', err instanceof Error ? err.message : err); + return null; + }); + if (cancelled) { + return formatPushSubscriptionCancelledAllReply({ count: cancelled.length }); + } + return '取消订阅失败,请稍后再试。'; + } + if (cancelRequest.item) { + try { + await cancelPushSubscription({ + userId: boundUser.userId, + scheduleService, + catalogItem: cancelRequest.item, + }); + return formatPushSubscriptionCancelledReply({ catalogItem: cancelRequest.item }); + } catch (err) { + if (String(err?.message ?? err).includes('订阅不存在')) { + return `你尚未开通 ${cancelRequest.item.emoji} ${cancelRequest.item.label}。`; + } + return `取消失败:${err instanceof Error ? err.message : String(err)}`; + } + } + } + + const modifyRequest = parsePushSubscriptionModifyRequest(text); + if (modifyRequest && boundUser?.userId && scheduleService) { + try { + await updatePushSubscriptionTime({ + userId: boundUser.userId, + scheduleService, + catalogItem: modifyRequest.item, + hour: modifyRequest.hour, + minute: modifyRequest.minute, + timezone, + }); + return formatPushSubscriptionUpdatedReply({ + catalogItem: modifyRequest.item, + hour: modifyRequest.hour, + minute: modifyRequest.minute, + }); + } catch (err) { + if (String(err?.message ?? err).includes('订阅不存在')) { + return `你尚未开通 ${modifyRequest.item.emoji} ${modifyRequest.item.label},可先回复 ${modifyRequest.item.id} 开通。`; + } + return `修改失败:${err instanceof Error ? err.message : String(err)}`; + } + } + + if (boundUser?.userId && scheduleService) { + const morningItem = getPushSubscriptionByKey('morning'); + const compact = String(text ?? '').replace(/\s+/g, ''); + if (morningItem && /^(早安几点|早安时间|早安提醒几点)$/u.test(compact)) { + const active = await findActivePushSubscription({ + userId: boundUser.userId, + scheduleService, + catalogItem: morningItem, + }); + if (active) { + return formatPushSubscriptionModifyHelpReply({ + catalogItem: morningItem, + hour: active.hour, + minute: active.minute, + }); + } + } + + const llmManageReply = await handleMorningLlmManageTurn({ + text, + boundUser, + scheduleService, + timezone, + defaultHour: morningItem?.defaultHour ?? 8, + defaultMinute: morningItem?.defaultMinute ?? 0, + wechatSubscribeMorningLlmConfigService, + llmProviderService, + logger, + }); + if (llmManageReply) return llmManageReply; + } + + const confirmRequest = parsePushSubscriptionConfirmReply(text); + if (confirmRequest) { + const { hour, minute } = resolvePushSubscriptionConfirmTime(confirmRequest.item, { + hour: confirmRequest.hour, + minute: confirmRequest.minute, + }); + let options = confirmRequest.options ?? null; + if (confirmRequest.item.key === 'weather') { + options = { + city: normalizePushWeatherCity(options?.city ?? '北京'), + }; + } + + if (pendingStore) { + const pending = await pendingStore.getPending({ appId, openid }); + if (pending) { + await pendingStore.clearPending({ appId, openid }); + } + } + + return tryCommitSubscription({ + userId: boundUser?.userId, + scheduleService, + catalogItem: confirmRequest.item, + hour, + minute, + timezone, + bindUrl, + boundUser, + sourceMessageId, + sourceText: String(text ?? ''), + options, + logger, + }); + } + + if (pendingStore) { + const pending = await pendingStore.getPending({ appId, openid }); + if (pending) { + const llmPendingReply = await handleMorningLlmPendingTurn({ + text, + boundUser, + scheduleService, + timezone, + defaultHour: 8, + defaultMinute: 0, + bindUrl, + sourceMessageId, + wechatSubscribeMorningLlmConfigService, + llmProviderService, + logger, + }); + if (llmPendingReply) { + await pendingStore.clearPending({ appId, openid }); + return llmPendingReply; + } + + const compact = String(text ?? '').replace(/\s+/g, '').trim(); + if (compact) { + await pendingStore.clearPending({ appId, openid }); + } + } + } + + return null; +} diff --git a/wechat/handlers/push-subscription.test.mjs b/wechat/handlers/push-subscription.test.mjs new file mode 100644 index 0000000..bd1152c --- /dev/null +++ b/wechat/handlers/push-subscription.test.mjs @@ -0,0 +1,156 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createPushSubscriptionPendingStore } from '../push-subscription-pending.mjs'; +import { formatPushSubscriptionCommittedReply } from '../push-subscription-service.mjs'; +import { handlePushSubscriptionMessages } from './push-subscription.mjs'; + +test('handlePushSubscriptionMessages commits morning subscription on reply 1', async () => { + const store = createPushSubscriptionPendingStore(); + const calls = []; + const scheduleService = { + async listItems() { + return []; + }, + async findActiveItemByMetadataSource() { + return null; + }, + async createItem(payload) { + calls.push(['createItem', payload]); + return { id: 'item-1', ...payload }; + }, + async createReminder(payload) { + calls.push(['createReminder', payload]); + return { id: 'rem-1', ...payload }; + }, + }; + + await store.setPending({ appId: 'wx-app', openid: 'openid-a' }); + const reply = await handlePushSubscriptionMessages({ + appId: 'wx-app', + openid: 'openid-a', + text: '1 7点', + pendingStore: store, + scheduleService, + boundUser: { userId: 'user-1' }, + }); + + assert.equal(reply, formatPushSubscriptionCommittedReply({ + catalogItem: { id: 1, emoji: '☀️', label: '早安问候' }, + hour: 7, + minute: 0, + })); + assert.equal(calls[0][1].metadata.source, 'wechat_push:morning'); + assert.equal(calls[0][1].metadata.dailyHour, 7); +}); + +test('handlePushSubscriptionMessages commits news subscription when delivery is ready', async () => { + const calls = []; + const reply = await handlePushSubscriptionMessages({ + text: '2 7点', + boundUser: { userId: 'user-1' }, + scheduleService: { + async listItems() { + return []; + }, + async findActiveItemByMetadataSource() { + return null; + }, + async createItem(payload) { + calls.push(['createItem', payload]); + return { id: 'item-news', ...payload }; + }, + async createReminder(payload) { + calls.push(['createReminder', payload]); + return { id: 'rem-news', ...payload }; + }, + }, + }); + + assert.match(reply, /每日新闻早报/u); + assert.equal(calls[0][1].metadata.source, 'wechat_push:news'); + assert.equal(calls[0][1].metadata.dailyHour, 7); +}); + +test('handlePushSubscriptionMessages commits weather subscription with default city', async () => { + const calls = []; + const reply = await handlePushSubscriptionMessages({ + text: '3 上海', + boundUser: { userId: 'user-1' }, + scheduleService: { + async listItems() { + return []; + }, + async findActiveItemByMetadataSource() { + return null; + }, + async createItem(payload) { + calls.push(['createItem', payload]); + return { id: 'item-weather', ...payload }; + }, + async createReminder(payload) { + calls.push(['createReminder', payload]); + return { id: 'rem-weather', ...payload }; + }, + }, + }); + + assert.match(reply, /每日天气/u); + assert.match(reply, /上海/u); + assert.equal(calls[0][1].metadata.options.city, '上海'); +}); + +test('handlePushSubscriptionMessages commits finance subscription', async () => { + const calls = []; + const reply = await handlePushSubscriptionMessages({ + text: '4', + boundUser: { userId: 'user-1' }, + scheduleService: { + async listItems() { + return []; + }, + async findActiveItemByMetadataSource() { + return null; + }, + async createItem(payload) { + calls.push(['createItem', payload]); + return { id: 'item-finance', ...payload }; + }, + async createReminder(payload) { + calls.push(['createReminder', payload]); + return { id: 'rem-finance', ...payload }; + }, + }, + }); + + assert.match(reply, /财经市场/u); + assert.equal(calls[0][1].metadata.source, 'wechat_push:finance'); +}); + +test('handlePushSubscriptionMessages lists active subscriptions', async () => { + const reply = await handlePushSubscriptionMessages({ + text: '我的订阅', + boundUser: { userId: 'user-1' }, + scheduleService: { + async listItems() { + return [{ + id: 'item-1', + metadata: { + source: 'wechat_push:morning', + dailyHour: 8, + dailyMinute: 0, + }, + }]; + }, + }, + }); + + assert.match(reply, /你已开通/); + assert.match(reply, /早安问候/); +}); + +test('handlePushSubscriptionMessages returns help menu', async () => { + const reply = await handlePushSubscriptionMessages({ text: '订阅帮助' }); + assert.match(reply, /每日推送订阅/); + assert.match(reply, /10 🎁 每日惊喜/); +}); diff --git a/wechat/handlers/sync-replies.mjs b/wechat/handlers/sync-replies.mjs index 5c28fbb..ad7ecb4 100644 --- a/wechat/handlers/sync-replies.mjs +++ b/wechat/handlers/sync-replies.mjs @@ -1,4 +1,5 @@ import { resolveWechatAddressName } from '../user/display-name.mjs'; +import { buildPushSubscriptionMenuLines } from '../push-subscription-catalog.mjs'; import { buildSubscribeMorningReminderPromptLines } from '../subscribe-morning-reminder.mjs'; export const DEFAULT_SUBSCRIBE_INTRO_URL = @@ -61,6 +62,7 @@ export function buildSubscribeWelcomeText({ introUrl = DEFAULT_SUBSCRIBE_INTRO_URL, plazaHomeUrl = DEFAULT_PLAZA_HOME_URL, plazaPicks = DEFAULT_SUBSCRIBE_PLAZA_PICKS, + pushSubscriptionEnabled = true, morningReminderEnabled = true, morningReminderHour = 8, morningReminderMinute = 0, @@ -77,7 +79,9 @@ export function buildSubscribeWelcomeText({ '· 设置定时提醒与每日推送', ...buildPlazaDiscoveryLines({ plazaHomeUrl, plazaPicks }), ]; - if (morningReminderEnabled) { + if (pushSubscriptionEnabled) { + lines.push(...buildPushSubscriptionMenuLines()); + } else if (morningReminderEnabled) { lines.push( ...buildSubscribeMorningReminderPromptLines({ hour: morningReminderHour, diff --git a/wechat/handlers/sync-replies.test.mjs b/wechat/handlers/sync-replies.test.mjs index f0090e5..636f156 100644 --- a/wechat/handlers/sync-replies.test.mjs +++ b/wechat/handlers/sync-replies.test.mjs @@ -43,10 +43,10 @@ test('buildSubscribeWelcomeText uses clickable anchors instead of raw urls', () assert.doesNotMatch(text, /\nhttps:\/\/m\.tkmind\.cn/); assert.doesNotMatch(text, /\nhttps:\/\/plaza\.tkmind\.cn/); assert.match(text, /帮我做一个苏州游玩攻略页面/); - assert.match(text, /☀️ 每日早安提醒/); - assert.match(text, /回复 1 确认/); - assert.match(text, /1 7点/); - assert.match(text, /取消早安/); + assert.match(text, /📬 每日推送订阅/); + assert.match(text, /1 ☀️ 早安问候/); + assert.match(text, /2 📰 每日新闻早报/); + assert.match(text, /我的订阅/); }); test('buildPlazaDiscoveryLines renders curated plaza category links', () => { @@ -80,15 +80,30 @@ test('buildSubscribeWelcomeText omits plaza section when plazaHomeUrl is empty', assert.doesNotMatch(text, /M发现/); }); -test('buildSubscribeWelcomeText omits morning reminder when disabled', () => { +test('buildSubscribeWelcomeText omits push subscription menu when disabled', () => { const text = buildSubscribeWelcomeText({ bindUrl: 'https://m.tkmind.cn/bind', introUrl: '', plazaHomeUrl: '', plazaPicks: [], + pushSubscriptionEnabled: false, morningReminderEnabled: false, }); - assert.doesNotMatch(text, /每日早安提醒/); + assert.doesNotMatch(text, /每日推送订阅/); assert.doesNotMatch(text, /回复 1 确认/); }); + +test('buildSubscribeWelcomeText falls back to legacy morning prompt when push subscription disabled', () => { + const text = buildSubscribeWelcomeText({ + bindUrl: 'https://m.tkmind.cn/bind', + introUrl: '', + plazaHomeUrl: '', + plazaPicks: [], + pushSubscriptionEnabled: false, + morningReminderEnabled: true, + }); + + assert.match(text, /☀️ 每日早安提醒/); + assert.match(text, /回复 1 确认/); +}); diff --git a/wechat/health-reminder-library.mjs b/wechat/health-reminder-library.mjs new file mode 100644 index 0000000..1a2842d --- /dev/null +++ b/wechat/health-reminder-library.mjs @@ -0,0 +1,81 @@ +import { pickDailyLibraryEntry } from './push-content/daily-library-pick.mjs'; + +export const HEALTH_REMINDER_MORNING = Object.freeze([ + '☕ 先喝一杯温水,唤醒身体,也唤醒今天的好状态。', + '🧘 伸个懒腰,转转肩颈,3 分钟就能让精神提起来。', + '🚶 如果方便,出门走 5 分钟,阳光和微风都是免费的能量。', + '💧 记得补水:早起后第一杯水,对代谢特别友好。', + '🍎 早餐别跳过,哪怕简单一点,也比空腹硬扛好。', +]); + +export const HEALTH_REMINDER_AFTERNOON = Object.freeze([ + '👀 看屏幕久了?试试 20-20-20:每 20 分钟,看 20 英尺外 20 秒。', + '🚶 午后站起来走一圈,对腰、对眼、对心情都有帮助。', + '💧 再补一杯水,别等口渴才想起来。', + '🧘 三个深呼吸,把肩颈放松下来,效率会更高。', + '🍵 如果困,先补水或走动,比硬喝咖啡更温和。', +]); + +export const HEALTH_REMINDER_EVENING = Object.freeze([ + '🌙 晚上尽量别太晚吃重口味,胃和睡眠都会感谢你。', + '📵 睡前 30 分钟少刷手机,给大脑一个慢慢降速的信号。', + '🚶 饭后散步 10 分钟,对消化和睡眠都有帮助。', + '🧘 做几组拉伸,把坐了一天的身体松一松。', + '💤 今天也辛苦了,早点休息,明天会更有劲。', +]); + +export const HEALTH_REMINDER_GENERAL = Object.freeze([ + '🏃 健康不是大目标,而是很多小选择叠在一起。', + '💧 记得喝水,这是最低成本、最高收益的健康习惯。', + '🧘 忙里偷闲 1 分钟,也比一直硬撑更好。', + '🚶 能走楼梯就别久坐,身体需要一点点活动量。', + '😊 心情也是健康的一部分,对自己温柔一点。', +]); + +export function resolveHealthReminderSlot(hour) { + const safeHour = Number(hour); + if (!Number.isInteger(safeHour)) return 'general'; + if (safeHour >= 6 && safeHour < 12) return 'morning'; + if (safeHour >= 12 && safeHour < 18) return 'afternoon'; + if (safeHour >= 18 && safeHour <= 23) return 'evening'; + return 'general'; +} + +export function pickHealthReminder({ + userId = '', + timezone = 'Asia/Shanghai', + now = Date.now(), + hour = null, +} = {}) { + const slot = resolveHealthReminderSlot( + hour ?? new Date(now).getHours(), + ); + const library = { + morning: HEALTH_REMINDER_MORNING, + afternoon: HEALTH_REMINDER_AFTERNOON, + evening: HEALTH_REMINDER_EVENING, + general: HEALTH_REMINDER_GENERAL, + }[slot] ?? HEALTH_REMINDER_GENERAL; + return pickDailyLibraryEntry({ + userId, + timezone, + now, + library, + salt: `health:${slot}`, + }); +} + +export function formatHealthReminderDeliveryText({ + userId, + timezone = 'Asia/Shanghai', + now = Date.now(), + hour = null, +} = {}) { + const tip = pickHealthReminder({ + userId, + timezone, + now, + hour: hour ?? new Date(now).getHours(), + }); + return `🏃 健康提醒\n\n${tip}\n\n回复「取消8」关闭推送`; +} diff --git a/wechat/health-reminder-library.test.mjs b/wechat/health-reminder-library.test.mjs new file mode 100644 index 0000000..6e1fb1e --- /dev/null +++ b/wechat/health-reminder-library.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + formatHealthReminderDeliveryText, + pickHealthReminder, + resolveHealthReminderSlot, +} from './health-reminder-library.mjs'; + +test('resolveHealthReminderSlot maps hour ranges', () => { + assert.equal(resolveHealthReminderSlot(8), 'morning'); + assert.equal(resolveHealthReminderSlot(14), 'afternoon'); + assert.equal(resolveHealthReminderSlot(20), 'evening'); + assert.equal(resolveHealthReminderSlot(2), 'general'); +}); + +test('formatHealthReminderDeliveryText uses slot by hour', () => { + const morning = formatHealthReminderDeliveryText({ + userId: 'user-h', + hour: 10, + now: Date.parse('2026-09-20T10:00:00+08:00'), + }); + assert.match(morning, /^🏃 健康提醒/u); + assert.match(morning, /取消8/u); + assert.ok(pickHealthReminder({ userId: 'user-h', hour: 10 }).length > 5); +}); diff --git a/wechat/night-summary-library.mjs b/wechat/night-summary-library.mjs new file mode 100644 index 0000000..5c5ee48 --- /dev/null +++ b/wechat/night-summary-library.mjs @@ -0,0 +1,81 @@ +import { getLocalParts, localDateKey, normalizeTimezone } from '../schedule-time.mjs'; +import { pickDailyLibraryEntry } from './push-content/daily-library-pick.mjs'; + +export const NIGHT_SUMMARY_REFLECTIONS = Object.freeze([ + '今天认真过就好,不必和完美较劲。', + '你已经比昨天更前进一步,这就值得晚安。', + '把未完成留给明天,把休息留给今晚。', + '今天的小收获,会在未来某天连起来。', + '允许自己慢下来,明天会继续。', +]); + +export const NIGHT_TOMORROW_HINTS = Object.freeze([ + '明天记得留一点空白给意外之喜。', + '明天优先处理最重要的一件事就好。', + '明天早起一杯水,会是很不错的开始。', + '明天如果有难的事,先拆成一小步再行动。', + '明天也别忘了,给自己留点轻松时间。', +]); + +function hashString(input) { + let hash = 2166136261; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return Math.abs(hash); +} + +export function formatNightDateLabel(now = Date.now(), timezone = 'Asia/Shanghai') { + const parts = getLocalParts(now, normalizeTimezone(timezone)); + return `${parts.year}年${parts.month}月${parts.day}日`; +} + +export function pickNightSummaryParts({ + userId = '', + timezone = 'Asia/Shanghai', + now = Date.now(), +} = {}) { + const reflection = pickDailyLibraryEntry({ + userId, + timezone, + now, + library: NIGHT_SUMMARY_REFLECTIONS, + salt: 'night-reflection', + }); + const tomorrowHint = pickDailyLibraryEntry({ + userId, + timezone, + now, + library: NIGHT_TOMORROW_HINTS, + salt: 'night-tomorrow', + }); + return { reflection, tomorrowHint }; +} + +export function formatNightSummaryDeliveryText({ + userId, + timezone = 'Asia/Shanghai', + now = Date.now(), +} = {}) { + const dateLabel = formatNightDateLabel(now, timezone); + const { reflection, tomorrowHint } = pickNightSummaryParts({ userId, timezone, now }); + return [ + `🌙 晚安总结 · ${dateLabel}`, + '', + `今日一句:${reflection}`, + '', + `明日提醒:${tomorrowHint}`, + '', + '晚安,好梦。', + '', + '回复「取消9」关闭推送', + ].join('\n'); +} + +export function pickSurpriseVariantIndex({ userId, timezone, now }) { + const tz = normalizeTimezone(timezone); + const dateKey = localDateKey(now, tz); + const seed = hashString(`${String(userId)}:${dateKey}:surprise-variant`); + return seed % 3; +} diff --git a/wechat/night-summary-library.test.mjs b/wechat/night-summary-library.test.mjs new file mode 100644 index 0000000..a69d95b --- /dev/null +++ b/wechat/night-summary-library.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { formatNightSummaryDeliveryText } from './night-summary-library.mjs'; + +test('formatNightSummaryDeliveryText includes reflection and tomorrow hint', () => { + const text = formatNightSummaryDeliveryText({ + userId: 'user-night', + timezone: 'Asia/Shanghai', + now: Date.parse('2026-09-20T22:00:00+08:00'), + }); + assert.match(text, /^🌙 晚安总结 · 2026年9月20日/u); + assert.match(text, /今日一句/u); + assert.match(text, /明日提醒/u); + assert.match(text, /取消9/u); +}); diff --git a/wechat/push-content/daily-library-pick.mjs b/wechat/push-content/daily-library-pick.mjs new file mode 100644 index 0000000..419ff6f --- /dev/null +++ b/wechat/push-content/daily-library-pick.mjs @@ -0,0 +1,26 @@ +import { localDateKey, normalizeTimezone } from '../../schedule-time.mjs'; + +export function hashString(input) { + let hash = 2166136261; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return Math.abs(hash); +} + +/** 按用户 + 本地日期 + salt 从话术库稳定选取一条。 */ +export function pickDailyLibraryEntry({ + userId = '', + timezone = 'Asia/Shanghai', + now = Date.now(), + library = [], + salt = 'daily', +} = {}) { + const pool = Array.isArray(library) && library.length ? library : []; + if (!pool.length) return ''; + const tz = normalizeTimezone(timezone); + const dateKey = localDateKey(now, tz); + const seed = hashString(`${String(userId)}:${dateKey}:${salt}`); + return pool[seed % pool.length]; +} diff --git a/wechat/push-content/finance-delivery.mjs b/wechat/push-content/finance-delivery.mjs new file mode 100644 index 0000000..8f7b030 --- /dev/null +++ b/wechat/push-content/finance-delivery.mjs @@ -0,0 +1,23 @@ +import { + buildSectionCardsPushText, + extractCardsFromSectionIds, + loadTodayNewsMorningHtml, +} from './news-section-extract.mjs'; + +export async function formatFinancePushDelivery(options = {}) { + const { html, dateLabel, publicUrl } = await loadTodayNewsMorningHtml(options); + const cards = html ? extractCardsFromSectionIds(html, ['finance']) : []; + return { + text: buildSectionCardsPushText({ + emoji: '💰', + label: '财经市场', + dateLabel, + cards, + maxItems: 5, + includeSummary: true, + publicUrl, + cancelId: 4, + }), + verifiedHtmlUrls: publicUrl ? [publicUrl] : [], + }; +} diff --git a/wechat/push-content/news-morning-delivery.mjs b/wechat/push-content/news-morning-delivery.mjs new file mode 100644 index 0000000..cb7917d --- /dev/null +++ b/wechat/push-content/news-morning-delivery.mjs @@ -0,0 +1,112 @@ +import fs from 'node:fs'; + +import { buildPublicUrl } from '../../user-publish.mjs'; +import { + buildNewsMorningWechatPushText, + findLatestNewsMorningPage, + isNewsMorningPageForToday, + resolveDailyNewsPublicBaseUrl, +} from '../../wechat-news-morning-draft.mjs'; + +const CONFIG_TABLE = 'h5_wechat_admin_config'; +const CONFIG_KEY = 'news_morning_draft'; + +function parseConfigJson(value) { + if (!value) return {}; + if (typeof value === 'object') return value; + try { + return JSON.parse(String(value)); + } catch { + return {}; + } +} + +export function resolveNewsMorningPushConfigFromEnv(env = process.env) { + return { + sourceUserId: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID ?? '').trim() || null, + pageSlugPattern: + String(env.H5_WECHAT_NEWS_MORNING_DRAFT_SLUG ?? 'daily-news-*').trim() || 'daily-news-*', + timezone: + String(env.H5_WECHAT_NEWS_MORNING_DRAFT_TZ ?? env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai').trim() + || 'Asia/Shanghai', + publicBaseUrl: resolveDailyNewsPublicBaseUrl(env), + }; +} + +export async function resolveNewsMorningPushConfig({ pool = null, env = process.env } = {}) { + const fromEnv = resolveNewsMorningPushConfigFromEnv(env); + if (!pool?.query) return fromEnv; + try { + const [rows] = await pool.query( + `SELECT config_json + FROM ${CONFIG_TABLE} + WHERE config_key = ? + LIMIT 1`, + [CONFIG_KEY], + ); + const stored = parseConfigJson(rows?.[0]?.config_json); + return { + ...fromEnv, + sourceUserId: String(stored.sourceUserId ?? fromEnv.sourceUserId ?? '').trim() || null, + pageSlugPattern: + String(stored.pageSlugPattern ?? fromEnv.pageSlugPattern).trim() || fromEnv.pageSlugPattern, + timezone: String(stored.timezone ?? fromEnv.timezone).trim() || fromEnv.timezone, + }; + } catch { + return fromEnv; + } +} + +function buildNewsMorningUnavailableText() { + return [ + '📰 每日新闻早报', + '', + '今日早报正在整理中,稍后会推送到本服务号。', + '', + '回复「取消2」关闭推送', + ].join('\n'); +} + +/** + * 组装服务号新闻早报订阅推送正文。 + * @returns {Promise<{ text: string, verifiedHtmlUrls: string[] }>} + */ +export async function formatNewsMorningPushDelivery({ + now = Date.now(), + timezone = 'Asia/Shanghai', + env = process.env, + h5Root = process.cwd(), + pool = null, + maxItems = 5, +} = {}) { + const config = await resolveNewsMorningPushConfig({ pool, env }); + const effectiveTimezone = config.timezone || timezone; + if (!config.sourceUserId) { + return { + text: '📰 每日新闻早报\n\n今日简报暂不可用(未配置来源页)。\n\n回复「取消2」关闭推送', + verifiedHtmlUrls: [], + }; + } + + const page = findLatestNewsMorningPage({ + h5Root, + userId: config.sourceUserId, + slugPattern: config.pageSlugPattern, + date: new Date(now), + timezone: effectiveTimezone, + }); + if ( + !page?.localPath + || !fs.existsSync(page.localPath) + || !isNewsMorningPageForToday(page, { date: new Date(now), timezone: effectiveTimezone }) + ) { + return { text: buildNewsMorningUnavailableText(), verifiedHtmlUrls: [] }; + } + + const html = fs.readFileSync(page.localPath, 'utf8'); + const publicUrl = buildPublicUrl(config.publicBaseUrl, config.sourceUserId, page.relativePath); + return { + text: buildNewsMorningWechatPushText(html, { publicUrl, maxItems }), + verifiedHtmlUrls: publicUrl ? [publicUrl] : [], + }; +} diff --git a/wechat/push-content/news-morning-delivery.test.mjs b/wechat/push-content/news-morning-delivery.test.mjs new file mode 100644 index 0000000..35890bc --- /dev/null +++ b/wechat/push-content/news-morning-delivery.test.mjs @@ -0,0 +1,97 @@ +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 { buildNewsMorningWechatPushText } from '../../wechat-news-morning-draft.mjs'; +import { + formatNewsMorningPushDelivery, + resolveNewsMorningPushConfig, +} from './news-morning-delivery.mjs'; + +const SAMPLE_HTML = ` + +
+
2026年9月20日 · 星期六
+
+
+ 要闻 +

第一条新闻

+

正文

+
+
+ 国际 +

第二条新闻

+

正文

+
+`; + +test('buildNewsMorningWechatPushText renders headline list and read-more link', () => { + const text = buildNewsMorningWechatPushText(SAMPLE_HTML, { + publicUrl: 'https://m.tkmind.cn/MindSpace/demo/public/daily-news-0920.html', + maxItems: 5, + }); + assert.match(text, /📰 每日新闻早报 · 2026年9月20日/u); + assert.match(text, /1\. \[要闻\] 第一条新闻/u); + assert.match(text, /2\. \[国际\] 第二条新闻/u); + assert.match(text, /阅读全文:/u); + assert.match(text, /取消2/u); +}); + +test('formatNewsMorningPushDelivery reads today page from workspace', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'news-push-')); + const userId = 'news-source-user'; + const publicDir = path.join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR); + fs.mkdirSync(publicDir, { recursive: true }); + const slug = 'daily-news-0920.html'; + fs.writeFileSync(path.join(publicDir, slug), SAMPLE_HTML); + + const pool = { + async query(sql) { + if (sql.includes('h5_wechat_admin_config')) { + return [[{ + config_json: JSON.stringify({ + sourceUserId: userId, + pageSlugPattern: 'daily-news-*', + timezone: 'Asia/Shanghai', + }), + }], []]; + } + return [[], []]; + }, + }; + + const now = Date.parse('2026-09-20T07:30:00+08:00'); + const delivery = await formatNewsMorningPushDelivery({ + now, + timezone: 'Asia/Shanghai', + h5Root: root, + pool, + env: {}, + }); + + assert.match(delivery.text, /第一条新闻/u); + assert.equal(delivery.verifiedHtmlUrls.length, 1); + assert.match(delivery.verifiedHtmlUrls[0], /daily-news-0920\.html/u); +}); + +test('resolveNewsMorningPushConfig prefers admin config over env', async () => { + const pool = { + async query() { + return [[{ + config_json: JSON.stringify({ + sourceUserId: 'from-db', + pageSlugPattern: 'daily-news-*', + timezone: 'Asia/Shanghai', + }), + }], []]; + }, + }; + const config = await resolveNewsMorningPushConfig({ + pool, + env: { H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: 'from-env' }, + }); + assert.equal(config.sourceUserId, 'from-db'); +}); diff --git a/wechat/push-content/news-section-extract.mjs b/wechat/push-content/news-section-extract.mjs new file mode 100644 index 0000000..1966950 --- /dev/null +++ b/wechat/push-content/news-section-extract.mjs @@ -0,0 +1,213 @@ +import fs from 'node:fs'; + +import { buildPublicUrl } from '../../user-publish.mjs'; +import { + findLatestNewsMorningPage, + isNewsMorningPageForToday, +} from '../../wechat-news-morning-draft.mjs'; +import { resolveNewsMorningPushConfig } from './news-morning-delivery.mjs'; + +function stripHtml(value) { + return String(value ?? '') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function truncateText(value, maxChars = 96) { + const text = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (!maxChars || text.length <= maxChars) return text; + return `${text.slice(0, Math.max(0, maxChars - 1))}…`; +} + +function extractDivBlock(html, className) { + const source = String(html ?? ''); + const openRe = new RegExp(`
]*>`, 'i'); + const openMatch = openRe.exec(source); + if (!openMatch) return ''; + let pos = openMatch.index + openMatch[0].length; + let depth = 1; + while (pos < source.length && depth > 0) { + const nextOpen = source.indexOf('', pos); + if (nextClose === -1) break; + if (nextOpen !== -1 && nextOpen < nextClose) { + depth += 1; + pos = nextOpen + 4; + } else { + depth -= 1; + pos = nextClose + 6; + } + } + return source.slice(openMatch.index, pos); +} + +function extractClassDivBlocks(parentHtml, classPrefix) { + const source = String(parentHtml ?? ''); + const blocks = []; + const openRe = new RegExp(`
]*>`, 'gi'); + let openMatch = openRe.exec(source); + while (openMatch) { + const start = openMatch.index; + let pos = start + openMatch[0].length; + let depth = 1; + while (pos < source.length && depth > 0) { + const nextOpen = source.indexOf('', pos); + if (nextClose === -1) break; + if (nextOpen !== -1 && nextOpen < nextClose) { + depth += 1; + pos = nextOpen + 4; + } else { + depth -= 1; + pos = nextClose + 6; + } + } + blocks.push(source.slice(start, pos)); + openMatch = openRe.exec(source); + } + return blocks; +} + +export function extractDailyNewsSectionHtml(html, sectionId) { + const source = String(html ?? ''); + const id = String(sectionId ?? '').trim(); + if (!id) return ''; + const sectionRe = new RegExp( + `
)`, + 'i', + ); + return source.match(sectionRe)?.[0] ?? ''; +} + +export function extractDailyNewsHeroDate(html) { + return stripHtml( + String(html).match(/
]*>([\s\S]*?)<\/div>/i)?.[1] + ?? String(html).match(/]*>([\s\S]*?)<\/span>/i)?.[1] + ?? '', + ); +} + +export function extractCardsFromHtmlFragment(html) { + const parts = String(html ?? '').split(/
{ + const title = stripHtml( + block.match(/]*>([\s\S]*?)<\/h3>/i)?.[1] + ?? block.match(/]*>([\s\S]*?)<\/h2>/i)?.[1] + ?? '', + ); + const tag = stripHtml( + block.match(/]*>([\s\S]*?)<\/span>/i)?.[1] ?? '', + ); + const paragraphs = [...block.matchAll(/]*>([\s\S]*?)<\/p>/gi)] + .map((item) => stripHtml(item[1])) + .filter(Boolean); + const context = paragraphs[0] ?? ''; + return { + index: index + 1, + title, + tag, + context: truncateText(context, 120), + }; + }).filter((item) => item.title); +} + +export function extractKnowledgeItemsFromHtml(html) { + const sectionHtml = extractDailyNewsSectionHtml(html, 'knowledge'); + const blocks = extractClassDivBlocks(sectionHtml, 'knowledge-item'); + return blocks.map((block, index) => { + const icon = stripHtml(block.match(/
]*>([\s\S]*?)<\/div>/i)?.[1] ?? ''); + const title = stripHtml(block.match(/]*>([\s\S]*?)<\/h4>/i)?.[1] ?? ''); + const body = truncateText( + stripHtml(block.match(/]*>([\s\S]*?)<\/p>/i)?.[1] ?? ''), + 180, + ); + if (!title) return null; + return { index: index + 1, title: icon ? `${icon} ${title}` : title, body }; + }).filter(Boolean); +} + +export function extractCardsFromSectionIds(html, sectionIds = []) { + const cards = []; + for (const sectionId of sectionIds) { + const sectionHtml = extractDailyNewsSectionHtml(html, sectionId); + if (!sectionHtml) continue; + cards.push(...extractCardsFromHtmlFragment(sectionHtml)); + } + return cards.map((card, index) => ({ ...card, index: index + 1 })); +} + +export function buildSectionCardsPushText({ + emoji, + label, + dateLabel = '', + cards = [], + maxItems = 5, + includeSummary = false, + publicUrl = '', + cancelId, + unavailableText = '内容正在整理中,稍后会推送到本服务号。', +}) { + const lines = [`${emoji} ${label}${dateLabel ? ` · ${dateLabel}` : ''}`]; + const picked = cards.slice(0, Math.max(1, Math.min(10, Number(maxItems) || 5))); + + if (!picked.length) { + lines.push('', unavailableText); + } else { + lines.push(''); + for (const card of picked) { + const prefix = card.tag ? `[${card.tag}] ` : ''; + if (includeSummary && card.context) { + lines.push(`${card.index}. ${prefix}${card.title}`, ` ${card.context}`, ''); + } else { + lines.push(`${card.index}. ${prefix}${card.title}`); + } + } + if (includeSummary) { + while (lines.at(-1) === '') lines.pop(); + } + } + + if (publicUrl) lines.push('', `👉 阅读全文:${publicUrl}`); + if (cancelId) lines.push('', `回复「取消${cancelId}」关闭推送`); + return lines.join('\n'); +} + +export async function loadTodayNewsMorningHtml({ + now = Date.now(), + timezone = 'Asia/Shanghai', + env = process.env, + h5Root = process.cwd(), + pool = null, +} = {}) { + const config = await resolveNewsMorningPushConfig({ pool, env }); + const effectiveTimezone = config.timezone || timezone; + if (!config.sourceUserId) { + return { html: null, config, dateLabel: '', publicUrl: '' }; + } + + const page = findLatestNewsMorningPage({ + h5Root, + userId: config.sourceUserId, + slugPattern: config.pageSlugPattern, + date: new Date(now), + timezone: effectiveTimezone, + }); + + if ( + !page?.localPath + || !fs.existsSync(page.localPath) + || !isNewsMorningPageForToday(page, { date: new Date(now), timezone: effectiveTimezone }) + ) { + return { html: null, config, dateLabel: '', publicUrl: '' }; + } + + const html = fs.readFileSync(page.localPath, 'utf8'); + return { + html, + config, + dateLabel: extractDailyNewsHeroDate(html), + publicUrl: buildPublicUrl(config.publicBaseUrl, config.sourceUserId, page.relativePath), + }; +} diff --git a/wechat/push-content/news-section-extract.test.mjs b/wechat/push-content/news-section-extract.test.mjs new file mode 100644 index 0000000..9b41ecb --- /dev/null +++ b/wechat/push-content/news-section-extract.test.mjs @@ -0,0 +1,88 @@ +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 { + buildSectionCardsPushText, + extractCardsFromSectionIds, + extractKnowledgeItemsFromHtml, + loadTodayNewsMorningHtml, +} from './news-section-extract.mjs'; + +const SAMPLE_HTML = ` +
2026年9月20日
+
+
💰 财经
+
A股

沪指收涨

市场震荡走高。

+
+
+
AI

新模型发布

多家厂商更新。

+
+
+
💡

网络效应

用户越多价值越大。

+
+
+

1969年

人类首次登月。

+
+`; + +test('extractCardsFromSectionIds reads finance and tech sections', () => { + const finance = extractCardsFromSectionIds(SAMPLE_HTML, ['finance']); + assert.equal(finance.length, 1); + assert.match(finance[0].title, /沪指收涨/u); + + const tech = extractCardsFromSectionIds(SAMPLE_HTML, ['tech']); + assert.equal(tech.length, 1); + assert.match(tech[0].title, /新模型发布/u); +}); + +test('extractKnowledgeItemsFromHtml reads knowledge section', () => { + const items = extractKnowledgeItemsFromHtml(SAMPLE_HTML); + assert.equal(items.length, 1); + assert.match(items[0].title, /网络效应/u); +}); + +test('buildSectionCardsPushText renders numbered digest', () => { + const cards = extractCardsFromSectionIds(SAMPLE_HTML, ['finance']); + const text = buildSectionCardsPushText({ + emoji: '💰', + label: '财经市场', + cards, + includeSummary: true, + cancelId: 4, + }); + assert.match(text, /💰 财经市场/u); + assert.match(text, /沪指收涨/u); +}); + +test('loadTodayNewsMorningHtml loads today page from workspace', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'section-push-')); + const userId = 'section-source'; + const publicDir = path.join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'daily-news-0920.html'), SAMPLE_HTML); + + const loaded = await loadTodayNewsMorningHtml({ + now: Date.parse('2026-09-20T08:30:00+08:00'), + timezone: 'Asia/Shanghai', + h5Root: root, + pool: { + async query() { + return [[{ + config_json: JSON.stringify({ + sourceUserId: userId, + pageSlugPattern: 'daily-news-*', + timezone: 'Asia/Shanghai', + }), + }], []]; + }, + }, + env: {}, + }); + + assert.ok(loaded.html); + assert.match(loaded.dateLabel, /2026年9月20日/u); +}); diff --git a/wechat/push-content/tech-delivery.mjs b/wechat/push-content/tech-delivery.mjs new file mode 100644 index 0000000..0d8cbfd --- /dev/null +++ b/wechat/push-content/tech-delivery.mjs @@ -0,0 +1,23 @@ +import { + buildSectionCardsPushText, + extractCardsFromSectionIds, + loadTodayNewsMorningHtml, +} from './news-section-extract.mjs'; + +export async function formatTechPushDelivery(options = {}) { + const { html, dateLabel, publicUrl } = await loadTodayNewsMorningHtml(options); + const cards = html ? extractCardsFromSectionIds(html, ['tech', 'technews']) : []; + return { + text: buildSectionCardsPushText({ + emoji: '🤖', + label: 'AI / 科技早报', + dateLabel, + cards, + maxItems: 5, + includeSummary: true, + publicUrl, + cancelId: 5, + }), + verifiedHtmlUrls: publicUrl ? [publicUrl] : [], + }; +} diff --git a/wechat/push-content/weather-delivery.mjs b/wechat/push-content/weather-delivery.mjs new file mode 100644 index 0000000..0b2baef --- /dev/null +++ b/wechat/push-content/weather-delivery.mjs @@ -0,0 +1,202 @@ +import fs from 'node:fs'; + +import { wechatDailyNewsInlineInternals } from '../../wechat-daily-news-inline.mjs'; +import { + findLatestNewsMorningPage, + isNewsMorningPageForToday, +} from '../../wechat-news-morning-draft.mjs'; +import { resolveNewsMorningPushConfig } from './news-morning-delivery.mjs'; + +const DEFAULT_WEATHER_CITY = '北京'; + +function stripInlineText(value) { + return String(value ?? '') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function extractDivBlock(html, className) { + const source = String(html ?? ''); + const openRe = new RegExp(`
]*>`, 'i'); + const openMatch = openRe.exec(source); + if (!openMatch) return ''; + let pos = openMatch.index + openMatch[0].length; + let depth = 1; + while (pos < source.length && depth > 0) { + const nextOpen = source.indexOf('', pos); + if (nextClose === -1) break; + if (nextOpen !== -1 && nextOpen < nextClose) { + depth += 1; + pos = nextOpen + 4; + } else { + depth -= 1; + pos = nextClose + 6; + } + } + return source.slice(openMatch.index, pos); +} + +function extractClassDivBlocks(parentHtml, classPrefix) { + const source = String(parentHtml ?? ''); + const blocks = []; + const openRe = new RegExp(`
]*>`, 'gi'); + let openMatch = openRe.exec(source); + while (openMatch) { + const start = openMatch.index; + let pos = start + openMatch[0].length; + let depth = 1; + while (pos < source.length && depth > 0) { + const nextOpen = source.indexOf('', pos); + if (nextClose === -1) break; + if (nextOpen !== -1 && nextOpen < nextClose) { + depth += 1; + pos = nextOpen + 4; + } else { + depth -= 1; + pos = nextClose + 6; + } + } + blocks.push(source.slice(start, pos)); + openMatch = openRe.exec(source); + } + return blocks; +} + +function normalizeWeatherCity(raw) { + return stripInlineText(raw).replace(/市$/u, '').trim(); +} + +function parseWeatherCard(cardHtml) { + const city = normalizeWeatherCity( + cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] ?? '', + ); + const cond = stripInlineText( + cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] ?? '', + ); + const temp = stripInlineText( + cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] ?? '', + ); + if (!city) return null; + return { city, cond, temp }; +} + +function extractWeatherCardsFromHtml(html) { + const grid = extractDivBlock(html, 'weather-grid'); + const cards = extractClassDivBlocks(grid, 'weather-card') + .map(parseWeatherCard) + .filter(Boolean); + if (cards.length > 0) return cards; + + const { selectTouristWeatherCards } = wechatDailyNewsInlineInternals; + return selectTouristWeatherCards(extractClassDivBlocks(html, 'weather-card')) + .map(parseWeatherCard) + .filter(Boolean); +} + +function extractWeatherTodaySummary(html) { + const todayBlock = extractDivBlock(html, 'weather-today'); + if (!todayBlock) return ''; + return stripInlineText(todayBlock).slice(0, 120); +} + +export function normalizePushWeatherCity(raw, { defaultCity = DEFAULT_WEATHER_CITY } = {}) { + const city = normalizeWeatherCity(raw); + if (!city) return defaultCity; + if (wechatDailyNewsInlineInternals.isPopularTouristCityName(city)) return city; + if (/^[\u4e00-\u9fff]{2,6}$/u.test(city)) return city; + return defaultCity; +} + +function buildDressingAdvice({ cond = '', temp = '' }) { + const text = `${cond} ${temp}`; + if (/雨|雪/u.test(text)) return '建议带伞,穿防水外套。'; + if (/冷|冰|0°|1°|2°|3°|4°|5°/u.test(text)) return '偏冷,注意保暖,外套加厚。'; + if (/30|31|32|33|34|35|36/u.test(text)) return '偏热,轻薄透气,注意防晒补水。'; + if (/晴/u.test(text)) return '晴好天气,适合户外,记得防晒。'; + if (/阴|云/u.test(text)) return '温差可能较大,建议洋葱式穿衣。'; + return '根据体感增减衣物,出门前再看一眼实时预报。'; +} + +export function buildWeatherPushTextFromHtml( + html, + { city = DEFAULT_WEATHER_CITY, manageHint = '回复「取消3」关闭推送' } = {}, +) { + const normalizedCity = normalizePushWeatherCity(city); + const cards = extractWeatherCardsFromHtml(html); + const matched = cards.find((item) => normalizeWeatherCity(item.city) === normalizedCity) + ?? cards[0] + ?? null; + const todaySummary = extractWeatherTodaySummary(html); + const lines = [`🌤 每日天气 · ${normalizedCity}`]; + + if (matched) { + const detail = [matched.cond, matched.temp].filter(Boolean).join(' · '); + lines.push('', detail || '详见下方概览'); + lines.push('', `穿衣建议:${buildDressingAdvice(matched)}`); + } else if (todaySummary) { + lines.push('', todaySummary); + } else { + lines.push('', '今日天气数据正在更新,请稍后再看。'); + } + + if (cards.length > 1 && matched) { + const others = cards + .filter((item) => normalizeWeatherCity(item.city) !== normalizedCity) + .slice(0, 3) + .map((item) => `${item.city} ${[item.cond, item.temp].filter(Boolean).join(' ')}`.trim()); + if (others.length) { + lines.push('', `周边速览:${others.join(' · ')}`); + } + } + + if (manageHint) lines.push('', manageHint); + return lines.join('\n'); +} + +export async function formatWeatherPushDelivery({ + city = DEFAULT_WEATHER_CITY, + now = Date.now(), + timezone = 'Asia/Shanghai', + env = process.env, + h5Root = process.cwd(), + pool = null, +} = {}) { + const normalizedCity = normalizePushWeatherCity(city); + const config = await resolveNewsMorningPushConfig({ pool, env }); + const effectiveTimezone = config.timezone || timezone; + + if (!config.sourceUserId) { + return { + text: `🌤 每日天气 · ${normalizedCity}\n\n天气数据暂不可用,请稍后再试。\n\n回复「取消3」关闭推送`, + verifiedHtmlUrls: [], + }; + } + + const page = findLatestNewsMorningPage({ + h5Root, + userId: config.sourceUserId, + slugPattern: config.pageSlugPattern, + date: new Date(now), + timezone: effectiveTimezone, + }); + + if ( + !page?.localPath + || !fs.existsSync(page.localPath) + || !isNewsMorningPageForToday(page, { date: new Date(now), timezone: effectiveTimezone }) + ) { + return { + text: `🌤 每日天气 · ${normalizedCity}\n\n今日天气正在整理中,稍后会推送到本服务号。\n\n回复「取消3」关闭推送`, + verifiedHtmlUrls: [], + }; + } + + const html = fs.readFileSync(page.localPath, 'utf8'); + return { + text: buildWeatherPushTextFromHtml(html, { city: normalizedCity }), + verifiedHtmlUrls: [], + }; +} diff --git a/wechat/push-content/weather-delivery.test.mjs b/wechat/push-content/weather-delivery.test.mjs new file mode 100644 index 0000000..70bc25c --- /dev/null +++ b/wechat/push-content/weather-delivery.test.mjs @@ -0,0 +1,63 @@ +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 { + buildWeatherPushTextFromHtml, + formatWeatherPushDelivery, + normalizePushWeatherCity, +} from './weather-delivery.mjs'; + +const WEATHER_HTML = ` +
2026年9月20日
+

全国大部晴到多云

+
+
北京☀️ 晴
28°
+
上海🌧️ 小雨
24°
+
+`; + +test('buildWeatherPushTextFromHtml renders city card and dressing advice', () => { + const text = buildWeatherPushTextFromHtml(WEATHER_HTML, { city: '北京' }); + assert.match(text, /🌤 每日天气 · 北京/u); + assert.match(text, /☀️ 晴/u); + assert.match(text, /穿衣建议/u); + assert.match(text, /上海/u); +}); + +test('normalizePushWeatherCity strips suffix 市', () => { + assert.equal(normalizePushWeatherCity('北京市'), '北京'); +}); + +test('formatWeatherPushDelivery reads weather section from today news page', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'weather-push-')); + const userId = 'weather-source'; + const publicDir = path.join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'daily-news-0920.html'), WEATHER_HTML); + + const delivery = await formatWeatherPushDelivery({ + city: '上海', + now: Date.parse('2026-09-20T07:00:00+08:00'), + timezone: 'Asia/Shanghai', + h5Root: root, + pool: { + async query() { + return [[{ + config_json: JSON.stringify({ + sourceUserId: userId, + pageSlugPattern: 'daily-news-*', + timezone: 'Asia/Shanghai', + }), + }], []]; + }, + }, + env: {}, + }); + + assert.match(delivery.text, /上海/u); + assert.match(delivery.text, /小雨/u); +}); diff --git a/wechat/push-subscription-catalog.mjs b/wechat/push-subscription-catalog.mjs new file mode 100644 index 0000000..d209d32 --- /dev/null +++ b/wechat/push-subscription-catalog.mjs @@ -0,0 +1,184 @@ +/** 微信服务号数字订阅目录(仅限服务号客服消息推送)。 */ + +export const WECHAT_PUSH_SUBSCRIPTION_PREFIX = 'wechat_push'; +export const LEGACY_MORNING_SOURCE = 'subscribe_morning_reminder'; + +export const PUSH_SUBSCRIPTION_ITEMS = Object.freeze([ + { + id: 1, + key: 'morning', + emoji: '☀️', + label: '早安问候', + defaultHour: 8, + defaultMinute: 0, + aliases: ['早安', '问候'], + deliveryReady: true, + }, + { + id: 2, + key: 'news', + emoji: '📰', + label: '每日新闻早报', + defaultHour: 7, + defaultMinute: 30, + aliases: ['新闻', '早报', '新闻早报'], + deliveryReady: true, + }, + { + id: 3, + key: 'weather', + emoji: '🌤', + label: '每日天气', + defaultHour: 7, + defaultMinute: 0, + aliases: ['天气'], + deliveryReady: true, + requiresOptions: ['city'], + }, + { + id: 4, + key: 'finance', + emoji: '💰', + label: '财经市场', + defaultHour: 8, + defaultMinute: 30, + aliases: ['财经', '股市', '市场'], + deliveryReady: true, + }, + { + id: 5, + key: 'tech', + emoji: '🤖', + label: 'AI / 科技早报', + defaultHour: 8, + defaultMinute: 0, + aliases: ['科技', 'AI', 'ai'], + deliveryReady: true, + }, + { + id: 6, + key: 'knowledge', + emoji: '📚', + label: '每日知识', + defaultHour: 12, + defaultMinute: 0, + aliases: ['知识'], + deliveryReady: true, + }, + { + id: 7, + key: 'quote', + emoji: '💡', + label: '每日金句', + defaultHour: 7, + defaultMinute: 30, + aliases: ['金句', '名言'], + deliveryReady: true, + }, + { + id: 8, + key: 'health', + emoji: '🏃', + label: '健康提醒', + defaultHour: 10, + defaultMinute: 0, + aliases: ['健康'], + deliveryReady: true, + }, + { + id: 9, + key: 'night', + emoji: '🌙', + label: '晚安总结', + defaultHour: 22, + defaultMinute: 0, + aliases: ['晚安'], + deliveryReady: true, + }, + { + id: 10, + key: 'surprise', + emoji: '🎁', + label: '每日惊喜', + defaultHour: 18, + defaultMinute: 0, + aliases: ['惊喜'], + deliveryReady: true, + }, +]); + +const byId = new Map(PUSH_SUBSCRIPTION_ITEMS.map((item) => [item.id, item])); +const byKey = new Map(PUSH_SUBSCRIPTION_ITEMS.map((item) => [item.key, item])); + +export function pushSubscriptionMetadataSource(key) { + return `${WECHAT_PUSH_SUBSCRIPTION_PREFIX}:${String(key ?? '').trim()}`; +} + +export function getPushSubscriptionById(id) { + const num = Number(id); + if (!Number.isInteger(num)) return null; + return byId.get(num) ?? null; +} + +export function getPushSubscriptionByKey(key) { + const normalized = String(key ?? '').trim().toLowerCase(); + if (!normalized) return null; + return byKey.get(normalized) ?? null; +} + +export function resolvePushSubscriptionFromMetadataSource(source) { + const raw = String(source ?? '').trim(); + if (!raw) return null; + if (raw === LEGACY_MORNING_SOURCE) { + return getPushSubscriptionByKey('morning'); + } + const prefix = `${WECHAT_PUSH_SUBSCRIPTION_PREFIX}:`; + if (!raw.startsWith(prefix)) return null; + return getPushSubscriptionByKey(raw.slice(prefix.length)); +} + +export function resolvePushSubscriptionFromAlias(text) { + const compact = String(text ?? '').replace(/\s+/g, ''); + if (!compact) return null; + for (const item of PUSH_SUBSCRIPTION_ITEMS) { + for (const alias of item.aliases) { + if (compact.includes(alias)) return item; + } + } + return null; +} + +export function formatPushSubscriptionTimeLabel(hour, minute) { + const safeHour = Number(hour); + const safeMinute = Number(minute ?? 0); + return `${String(safeHour).padStart(2, '0')}:${String(safeMinute).padStart(2, '0')}`; +} + +export function formatPushSubscriptionMenuLine(item) { + const timeLabel = formatPushSubscriptionTimeLabel(item.defaultHour, item.defaultMinute); + return `${item.id} ${item.emoji} ${item.label} 默认 ${timeLabel}`; +} + +export function buildPushSubscriptionMenuLines() { + const lines = [ + '', + '📬 每日推送订阅(仅限本服务号)', + '回复数字即可开通,例如「2」或「2 7点」:', + ...PUSH_SUBSCRIPTION_ITEMS.map((item) => formatPushSubscriptionMenuLine(item)), + '', + '· 查状态:回复「我的订阅」', + '· 改时间:回复「2改到7点」', + '· 取消:回复「取消2」', + '· 天气城市:回复「3 北京」或「3改城市上海」', + '· 帮助:回复「订阅帮助」', + ]; + return lines; +} + +export function isPushSubscriptionEnabled(env = process.env) { + const explicit = env.H5_WECHAT_MP_PUSH_SUBSCRIPTION_ENABLED; + if (explicit != null && String(explicit).trim() !== '') { + return String(explicit).trim() !== '0'; + } + return String(env.H5_WECHAT_MP_SUBSCRIBE_MORNING_REMINDER_ENABLED ?? '1').trim() !== '0'; +} diff --git a/wechat/push-subscription-parser.mjs b/wechat/push-subscription-parser.mjs new file mode 100644 index 0000000..c094baf --- /dev/null +++ b/wechat/push-subscription-parser.mjs @@ -0,0 +1,207 @@ +import { parseHourMinute } from '../schedule-intent.mjs'; +import { normalizePushWeatherCity } from './push-content/weather-delivery.mjs'; +import { + getPushSubscriptionById, + PUSH_SUBSCRIPTION_ITEMS, + resolvePushSubscriptionFromAlias, +} from './push-subscription-catalog.mjs'; + +const SUBSCRIPTION_ID_PATTERN = /^([1-9]|10)$/u; +const SUBSCRIPTION_ID_PREFIX_PATTERN = /^([1-9]|10)[\s::,,、-]+(.+)$/u; +const SUBSCRIPTION_MODIFY_PATTERN = /^([1-9]|10)\s*(改|换|调整|设为|设置为|调到|改到|换成|改为)\s*(.+)$/u; +const SUBSCRIPTION_CANCEL_ID_PATTERN = /^取消\s*([1-9]|10)$/u; + +const LIST_REQUEST_PATTERN = /^(我的订阅|订阅状态|订阅列表|已开通订阅)$/u; +const HELP_REQUEST_PATTERN = /^(订阅帮助|订阅菜单|推送菜单|推送帮助)$/u; +const CANCEL_ALL_PATTERN = /^(全部取消|取消全部|取消所有订阅)$/u; +const WEATHER_CITY_UPDATE_PATTERN = /^([1-9]|10)\s*(改|换|调整|设为|设置为|调到|改到|换成|改为)\s*城市\s*(.+)$/u; +const WEATHER_ALIAS_CITY_UPDATE_PATTERN = /^(天气|每日天气)\s*(改|换|调整|设为|设置为|调到|改到|换成|改为)\s*城市?\s*(.+)$/u; + +function parseCityToken(raw) { + const text = String(raw ?? '') + .trim() + .replace(/^(城市|设为|设置为|改成|改为|换为|换成)/u, '') + .replace(/\s+/g, '') + .replace(/市$/u, ''); + if (!text || text.length > 6) return null; + if (!/^[\u4e00-\u9fff]{2,6}$/u.test(text)) return null; + return normalizePushWeatherCity(text); +} + +function parseWeatherConfirmSegment(segment) { + const raw = String(segment ?? '').trim(); + if (!raw) return { hour: null, minute: null, options: null }; + + const time = parseHourMinute(raw); + if (time) { + const remainder = raw + .replace(/(?:今天|明天|后天|今晚|明晚|明早|今早)?(?:早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)?(?:[0-9]{1,2}|[零一二两三四五六七八九十]{1,3})(?:点|:|:)(?:半|[0-9]{1,2}分?)?/u, '') + .trim(); + const city = parseCityToken(remainder); + return { + hour: time.hour, + minute: time.minute, + options: city ? { city } : null, + }; + } + + const city = parseCityToken(raw); + if (city) { + return { hour: null, minute: null, options: { city } }; + } + return { hour: null, minute: null, options: null }; +} + +function parseSubscriptionIdToken(token) { + const raw = String(token ?? '').trim(); + if (SUBSCRIPTION_ID_PATTERN.test(raw)) return Number(raw); + if (raw === '1') return 1; + if (raw === '2') return 2; + if (raw === '3') return 3; + if (raw === '4') return 4; + if (raw === '5') return 5; + if (raw === '6') return 6; + if (raw === '7') return 7; + if (raw === '8') return 8; + if (raw === '9') return 9; + if (raw === '10') return 10; + return null; +} + +/** + * 解析开通指令:`2` / `2 7点` / `2 7:30` + * @returns {{ item: object, hour: number|null, minute: number|null }|null} + */ +export function parsePushSubscriptionConfirmReply( + text, + { defaultHourResolver = null } = {}, +) { + const raw = String(text ?? '').trim(); + if (!raw) return null; + + const compact = raw.replace(/\s+/g, ''); + const bareId = parseSubscriptionIdToken(compact); + if (bareId != null && compact.length <= 2) { + const item = getPushSubscriptionById(bareId); + if (!item) return null; + return { item, hour: null, minute: null }; + } + + const prefixed = raw.match(SUBSCRIPTION_ID_PREFIX_PATTERN); + if (prefixed) { + const item = getPushSubscriptionById(Number(prefixed[1])); + if (!item) return null; + if (item.key === 'weather') { + const weather = parseWeatherConfirmSegment(prefixed[2]); + return { + item, + hour: weather.hour, + minute: weather.minute, + options: weather.options, + }; + } + const time = parseHourMinute(prefixed[2]); + return { item, hour: time?.hour ?? null, minute: time?.minute ?? null, options: null }; + } + + return null; +} + +/** @returns {{ item: object, options: { city: string } }|null} */ +export function parsePushSubscriptionCityUpdate(text) { + const raw = String(text ?? '').trim(); + if (!raw) return null; + + const byId = raw.match(WEATHER_CITY_UPDATE_PATTERN); + if (byId) { + const item = getPushSubscriptionById(Number(byId[1])); + const city = parseCityToken(byId[3]); + if (!item || item.key !== 'weather' || !city) return null; + return { item, options: { city } }; + } + + const byAlias = raw.replace(/\s+/g, '').match(WEATHER_ALIAS_CITY_UPDATE_PATTERN); + if (byAlias) { + const item = resolvePushSubscriptionFromAlias('天气'); + const city = parseCityToken(byAlias[3]); + if (!item || !city) return null; + return { item, options: { city } }; + } + + return null; +} + +/** @returns {{ item: object }|{ all: true }|null} */ +export function parsePushSubscriptionCancelRequest(text) { + const raw = String(text ?? '').trim(); + const compact = raw.replace(/\s+/g, ''); + if (!compact) return null; + if (CANCEL_ALL_PATTERN.test(compact)) return { all: true }; + + const byId = compact.match(SUBSCRIPTION_CANCEL_ID_PATTERN); + if (byId) { + const item = getPushSubscriptionById(Number(byId[1])); + return item ? { item } : null; + } + + const aliasItem = resolvePushSubscriptionFromAlias(compact); + if (!aliasItem) return null; + const hasCancelCue = /(取消|关闭|不要|停止)/u.test(compact); + if (!hasCancelCue) return null; + return { item: aliasItem }; +} + +/** @returns {{ item: object, hour: number, minute: number }|null} */ +export function parsePushSubscriptionModifyRequest(text) { + const raw = String(text ?? '').trim(); + if (!raw) return null; + + const byId = raw.match(SUBSCRIPTION_MODIFY_PATTERN); + if (byId) { + const item = getPushSubscriptionById(Number(byId[1])); + const time = parseHourMinute(byId[3]); + if (!item || !time) return null; + return { item, hour: time.hour, minute: time.minute }; + } + + const compact = raw.replace(/\s+/g, ''); + const aliasItem = resolvePushSubscriptionFromAlias(compact); + const hasModifyCue = /(改|换|调整|设为|设置为|调到|改到|换成|改为)/u.test(compact); + if (!aliasItem || !hasModifyCue) return null; + const time = parseHourMinute(raw); + if (!time) return null; + return { item: aliasItem, hour: time.hour, minute: time.minute }; +} + +export function parsePushSubscriptionListRequest(text) { + const compact = String(text ?? '').replace(/\s+/g, ''); + return LIST_REQUEST_PATTERN.test(compact); +} + +export function parsePushSubscriptionHelpRequest(text) { + const compact = String(text ?? '').replace(/\s+/g, ''); + return HELP_REQUEST_PATTERN.test(compact); +} + +export function resolvePushSubscriptionConfirmTime( + item, + { hour = null, minute = null, defaultHourResolver = null } = {}, +) { + if (Number.isInteger(hour) && hour >= 0 && hour <= 23 + && Number.isInteger(minute) && minute >= 0 && minute <= 59) { + return { hour, minute }; + } + if (typeof defaultHourResolver === 'function') { + const resolved = defaultHourResolver(item); + if (resolved) return resolved; + } + return { hour: item.defaultHour, minute: item.defaultMinute }; +} + +export function isPushSubscriptionNumericReply(text) { + return parsePushSubscriptionConfirmReply(text) != null; +} + +export function listPushSubscriptionAliasKeys() { + return PUSH_SUBSCRIPTION_ITEMS.flatMap((item) => item.aliases); +} diff --git a/wechat/push-subscription-parser.test.mjs b/wechat/push-subscription-parser.test.mjs new file mode 100644 index 0000000..5c5ea68 --- /dev/null +++ b/wechat/push-subscription-parser.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + parsePushSubscriptionCancelRequest, + parsePushSubscriptionCityUpdate, + parsePushSubscriptionConfirmReply, + parsePushSubscriptionHelpRequest, + parsePushSubscriptionListRequest, + parsePushSubscriptionModifyRequest, +} from './push-subscription-parser.mjs'; + +test('parsePushSubscriptionConfirmReply accepts numeric ids and custom times', () => { + assert.equal(parsePushSubscriptionConfirmReply('11'), null); + const one = parsePushSubscriptionConfirmReply('1'); + assert.equal(one.item.key, 'morning'); + assert.equal(one.hour, null); + const news = parsePushSubscriptionConfirmReply('2 7点'); + assert.equal(news.item.key, 'news'); + assert.equal(news.hour, 7); + assert.equal(news.minute, 0); + assert.equal(parsePushSubscriptionConfirmReply('10').item.key, 'surprise'); + assert.equal(parsePushSubscriptionConfirmReply('你好'), null); +}); + +test('parsePushSubscriptionCancelRequest detects id and alias cancel', () => { + assert.equal(parsePushSubscriptionCancelRequest('取消2').item.key, 'news'); + assert.equal(parsePushSubscriptionCancelRequest('取消早安').item.key, 'morning'); + assert.deepEqual(parsePushSubscriptionCancelRequest('全部取消'), { all: true }); +}); + +test('parsePushSubscriptionModifyRequest parses id and alias modify', () => { + const news = parsePushSubscriptionModifyRequest('2改到7点'); + assert.equal(news.item.key, 'news'); + assert.equal(news.hour, 7); + const morning = parsePushSubscriptionModifyRequest('早安改到7点半'); + assert.equal(morning.item.key, 'morning'); + assert.equal(morning.minute, 30); +}); + +test('parsePushSubscriptionListRequest and help request', () => { + assert.equal(parsePushSubscriptionListRequest('我的订阅'), true); + assert.equal(parsePushSubscriptionHelpRequest('订阅帮助'), true); +}); + +test('parsePushSubscriptionConfirmReply parses weather city and time', () => { + const shanghai = parsePushSubscriptionConfirmReply('3 上海'); + assert.equal(shanghai.item.key, 'weather'); + assert.equal(shanghai.options.city, '上海'); + + const timed = parsePushSubscriptionConfirmReply('3 7点 北京'); + assert.equal(timed.hour, 7); + assert.equal(timed.options.city, '北京'); +}); + +test('parsePushSubscriptionCityUpdate parses weather city change', () => { + const updated = parsePushSubscriptionCityUpdate('3改城市杭州'); + assert.equal(updated.item.key, 'weather'); + assert.equal(updated.options.city, '杭州'); +}); diff --git a/wechat/push-subscription-pending.mjs b/wechat/push-subscription-pending.mjs new file mode 100644 index 0000000..5db6531 --- /dev/null +++ b/wechat/push-subscription-pending.mjs @@ -0,0 +1,4 @@ +export { + createSubscribeMorningReminderPendingStore as createPushSubscriptionPendingStore, + subscribeMorningReminderPendingTtlMs as pushSubscriptionPendingTtlMs, +} from './subscribe-morning-reminder.mjs'; diff --git a/wechat/push-subscription-service.mjs b/wechat/push-subscription-service.mjs new file mode 100644 index 0000000..7dd51f7 --- /dev/null +++ b/wechat/push-subscription-service.mjs @@ -0,0 +1,294 @@ +import { nextDailyRunAt } from '../schedule-time.mjs'; +import { + formatPushSubscriptionTimeLabel, + getPushSubscriptionByKey, + LEGACY_MORNING_SOURCE, + pushSubscriptionMetadataSource, + resolvePushSubscriptionFromMetadataSource, +} from './push-subscription-catalog.mjs'; + +function metadataSourcesForItem(item) { + const sources = [pushSubscriptionMetadataSource(item.key)]; + if (item.key === 'morning') { + sources.push(LEGACY_MORNING_SOURCE); + } + return sources; +} + +function mapScheduleItemToSubscription(item) { + const catalogItem = resolvePushSubscriptionFromMetadataSource(item.metadata?.source); + if (!catalogItem) return null; + return { + item, + catalogItem, + hour: Number(item.metadata?.dailyHour ?? catalogItem.defaultHour), + minute: Number(item.metadata?.dailyMinute ?? catalogItem.defaultMinute), + options: item.metadata?.options ?? null, + }; +} + +export async function listActivePushSubscriptions({ userId, scheduleService }) { + if (!userId || !scheduleService) return []; + const items = await scheduleService.listItems({ userId, status: 'active', limit: 200 }); + const seen = new Set(); + const subscriptions = []; + for (const item of items) { + const mapped = mapScheduleItemToSubscription(item); + if (!mapped || seen.has(mapped.catalogItem.key)) continue; + seen.add(mapped.catalogItem.key); + subscriptions.push(mapped); + } + return subscriptions.sort((a, b) => a.catalogItem.id - b.catalogItem.id); +} + +export async function findActivePushSubscription({ + userId, + scheduleService, + catalogItem, +}) { + if (!userId || !scheduleService || !catalogItem) return null; + if (typeof scheduleService.findActiveItemByMetadataSource !== 'function') return null; + for (const source of metadataSourcesForItem(catalogItem)) { + const item = await scheduleService.findActiveItemByMetadataSource({ userId, source }); + if (item) return mapScheduleItemToSubscription(item); + } + return null; +} + +export async function commitPushSubscription({ + userId, + scheduleService, + catalogItem, + hour, + minute, + timezone = 'Asia/Shanghai', + sourceChannel = 'wechat', + sourceMessageId = null, + sourceText = '', + options = null, +}) { + if (!userId) throw new Error('缺少用户'); + if (!scheduleService) throw new Error('scheduleService 不可用'); + if (!catalogItem?.key) throw new Error('订阅项无效'); + + const source = pushSubscriptionMetadataSource(catalogItem.key); + const existing = await findActivePushSubscription({ userId, scheduleService, catalogItem }); + + if (existing?.item?.id) { + const updated = await scheduleService.updateDailyScheduleItem({ + userId, + itemId: existing.item.id, + hour, + minute, + timezone, + }); + if (options && typeof scheduleService.updateScheduleItemMetadata === 'function') { + await scheduleService.updateScheduleItemMetadata({ + userId, + itemId: existing.item.id, + metadata: { + ...(existing.item.metadata ?? {}), + source, + recurrence: 'daily', + dailyHour: hour, + dailyMinute: minute, + subscriptionKey: catalogItem.key, + options: { + ...(existing.item.metadata?.options ?? {}), + ...options, + }, + }, + }); + } + return updated; + } + + const remindAt = nextDailyRunAt({ hour, minute, timezone }); + const item = await scheduleService.createItem({ + userId, + kind: 'event', + title: catalogItem.label, + startAt: remindAt, + timezone, + sourceChannel, + sourceMessageId, + sourceText, + metadata: { + source, + recurrence: 'daily', + dailyHour: hour, + dailyMinute: minute, + subscriptionKey: catalogItem.key, + ...(options ? { options } : {}), + }, + }); + const reminder = await scheduleService.createReminder({ + userId, + itemId: item.id, + remindAt, + channel: 'wechat', + }); + return { item, reminder }; +} + +export async function updatePushSubscriptionOptions({ + userId, + scheduleService, + catalogItem, + options, + timezone = 'Asia/Shanghai', +}) { + const active = await findActivePushSubscription({ userId, scheduleService, catalogItem }); + if (!active?.item?.id) throw new Error('订阅不存在'); + if (typeof scheduleService.updateScheduleItemMetadata !== 'function') { + throw new Error('当前不支持更新订阅选项'); + } + return scheduleService.updateScheduleItemMetadata({ + userId, + itemId: active.item.id, + metadata: { + ...(active.item.metadata ?? {}), + options: { + ...(active.item.metadata?.options ?? {}), + ...(options ?? {}), + }, + }, + }); +} + +export async function updatePushSubscriptionTime({ + userId, + scheduleService, + catalogItem, + hour, + minute, + timezone = 'Asia/Shanghai', +}) { + const active = await findActivePushSubscription({ userId, scheduleService, catalogItem }); + if (!active?.item?.id) throw new Error('订阅不存在'); + return scheduleService.updateDailyScheduleItem({ + userId, + itemId: active.item.id, + hour, + minute, + timezone, + }); +} + +export async function cancelPushSubscription({ + userId, + scheduleService, + catalogItem, + reason = '用户取消订阅', +}) { + const active = await findActivePushSubscription({ userId, scheduleService, catalogItem }); + if (!active?.item?.id) throw new Error('订阅不存在'); + return scheduleService.cancelScheduleItem({ + userId, + itemId: active.item.id, + reason, + }); +} + +export async function cancelAllPushSubscriptions({ + userId, + scheduleService, + reason = '用户取消全部订阅', +}) { + const subscriptions = await listActivePushSubscriptions({ userId, scheduleService }); + const results = []; + for (const subscription of subscriptions) { + results.push(await cancelPushSubscription({ + userId, + scheduleService, + catalogItem: subscription.catalogItem, + reason, + })); + } + return results; +} + +export function formatPushSubscriptionCommittedReply({ catalogItem, hour, minute, options = null }) { + const timeLabel = formatPushSubscriptionTimeLabel(hour, minute); + const lines = [ + `已开启 ${catalogItem.emoji} ${catalogItem.label},以后每天 ${timeLabel} 我会推送到本服务号。`, + ]; + if (catalogItem.key === 'weather') { + const city = options?.city ?? '北京'; + lines.push(`当前城市:${city}。可说「${catalogItem.id}改城市上海」更换。`); + } + lines.push(`回复「取消${catalogItem.id}」可关闭。`); + return lines.join(''); +} + +export function formatPushSubscriptionCityUpdatedReply({ catalogItem, options }) { + const city = options?.city ?? ''; + return `好的,${catalogItem.emoji} ${catalogItem.label} 已改为 ${city}。`; +} + +export function formatPushSubscriptionUpdatedReply({ catalogItem, hour, minute }) { + const timeLabel = formatPushSubscriptionTimeLabel(hour, minute); + return `好的,${catalogItem.emoji} ${catalogItem.label} 已改到 ${timeLabel}。`; +} + +export function formatPushSubscriptionCancelledReply({ catalogItem }) { + return `已取消 ${catalogItem.emoji} ${catalogItem.label}。`; +} + +export function formatPushSubscriptionCancelledAllReply({ count }) { + if (!count) return '你当前没有开通中的每日推送。'; + return `已取消 ${count} 项每日推送。`; +} + +export function formatPushSubscriptionNotReadyReply({ catalogItem }) { + return `${catalogItem.emoji} ${catalogItem.label} 即将上线,敬请期待。上线后回复 ${catalogItem.id} 即可开通。`; +} + +export function formatPushSubscriptionBindFirstReply({ bindUrl }) { + const normalizedBindUrl = String(bindUrl ?? '').trim(); + const lines = ['若要开通每日推送,请先完成绑定,再回复对应数字确认。']; + if (normalizedBindUrl) lines.push(normalizedBindUrl); + return lines.join('\n'); +} + +export function formatPushSubscriptionListReply({ subscriptions }) { + if (!subscriptions.length) { + return '你还没有开通任何每日推送。回复「订阅帮助」查看可订阅项目。'; + } + const lines = ['你已开通:']; + for (const entry of subscriptions) { + const timeLabel = formatPushSubscriptionTimeLabel(entry.hour, entry.minute); + const citySuffix = entry.catalogItem.key === 'weather' && entry.options?.city + ? `(${entry.options.city})` + : ''; + lines.push(`${entry.catalogItem.id} ${entry.catalogItem.emoji} ${entry.catalogItem.label} ${timeLabel}${citySuffix}`); + } + lines.push('', '可说「2改到7点」改时间,或「取消2」关闭。'); + return lines.join('\n'); +} + +export function formatPushSubscriptionModifyHelpReply({ catalogItem, hour, minute }) { + const timeLabel = formatPushSubscriptionTimeLabel(hour, minute); + return `当前 ${catalogItem.emoji} ${catalogItem.label} 为 ${timeLabel}。可说「${catalogItem.id}改到7点」或「取消${catalogItem.id}」。`; +} + +export function formatPushSubscriptionAlreadyActiveReply({ catalogItem, hour, minute }) { + const timeLabel = formatPushSubscriptionTimeLabel(hour, minute); + return `${catalogItem.emoji} ${catalogItem.label} 已在 ${timeLabel} 推送。可说「${catalogItem.id}改到8点」调整时间。`; +} + +export function isWechatPushSubscriptionSource(source) { + const raw = String(source ?? '').trim(); + if (!raw) return false; + if (raw === LEGACY_MORNING_SOURCE) return true; + return raw.startsWith('wechat_push:'); +} + +export function resolveWechatPushSubscriptionKey(source) { + const item = resolvePushSubscriptionFromMetadataSource(source); + return item?.key ?? null; +} + +export function getMorningCatalogItem() { + return getPushSubscriptionByKey('morning'); +}