Files
memind/wechat/handlers/push-subscription.mjs
john c36558e0f3
Memind CI / Test, build, and release guards (push) Successful in 4m20s
feat(wechat): keep morning/weather push for suspended billing users
欠费停用用户仍送达早安与天气,跳过复杂页面订阅与定时任务补发。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:30:40 +08:00

481 lines
14 KiB
JavaScript

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';
import { formatNewsMorningPushDelivery } from '../push-content/news-morning-delivery.mjs';
import { parsePushSubscriptionSecretCode } from '../push-subscription-secret-codes.mjs';
import {
canCommitPushSubscriptionForSuspendedUser,
isSuspendedBillingUser,
} from '../suspended-user-push-policy.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 (
isSuspendedBillingUser(boundUser)
&& !canCommitPushSubscriptionForSuspendedUser(catalogItem.key)
) {
return '账户已欠费停用,暂仅保留早安问候与每日天气推送。请先充值后再开通其它订阅。';
}
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<string|null>}
*/
export async function handlePushSubscriptionMessages({
appId,
openid,
text,
pendingStore,
scheduleService,
boundUser = null,
timezone = 'Asia/Shanghai',
bindUrl = '',
sourceMessageId = null,
wechatSubscribeMorningLlmConfigService = null,
llmProviderService = null,
mysqlPool = null,
h5Root = '',
env = process.env,
logger = console,
} = {}) {
if (parsePushSubscriptionHelpRequest(text)) {
return buildPushSubscriptionHelpText();
}
const secretCode = parsePushSubscriptionSecretCode(text);
if (secretCode?.kind === 'morning_subscribe') {
const morningItem = getPushSubscriptionByKey('morning');
if (morningItem) {
return tryCommitSubscription({
userId: boundUser?.userId,
scheduleService,
catalogItem: morningItem,
hour: morningItem.defaultHour,
minute: morningItem.defaultMinute,
timezone,
bindUrl,
boundUser,
sourceMessageId,
sourceText: String(text ?? ''),
logger,
});
}
}
if (secretCode?.kind === 'news_immediate') {
if (isSuspendedBillingUser(boundUser)) {
return '账户已欠费停用,暂不提供复杂页面推送。请先充值后再试。';
}
const delivery = await formatNewsMorningPushDelivery({
timezone,
h5Root: String(h5Root ?? '').trim() || process.cwd(),
pool: mysqlPool,
env,
});
return delivery.text;
}
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;
}