Files
memind/wechat/push-subscription-service.mjs
T
john 830f8a4011
Memind CI / Test, build, and release guards (push) Successful in 5m36s
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 <cursoragent@cursor.com>
2026-09-20 16:15:15 +08:00

295 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
}