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'); }