import re from datetime import UTC, datetime from typing import Any from zoneinfo import ZoneInfo from fastapi import HTTPException from sqlalchemy.orm import Session from app.application.feishu.delivery import send_text_if_configured from app.application.feishu.results import command_result from app.modules.feishu.constants import FeishuCommandName, FeishuReplyType from app.modules.feishu.service import FeishuService from app.modules.feishu_users.principal import FeishuPrincipal from app.modules.subscriptions.constants import ( DAILY_DELIVERY_LIMIT_REACHED, PushDeliveryStatus, PushSubscriptionStatus, SubscriptionScheduleType, SubscriptionTargetType, ) from app.modules.subscriptions.models import PushDelivery, PushSubscription from app.modules.subscriptions.services import ( ScheduleParseError, SubscriptionManagementService, parse_schedule, ) SUBSCRIPTION_TITLE = "订阅管理" SUBSCRIPTION_HELP = ( "订阅指令格式:\n" "订阅 每天 09:00:提示词\n" "订阅 工作日 18:00:提示词\n" "订阅 每周一 09:00:提示词\n" "订阅 每月1号 09:00:提示词\n" "订阅 每隔30分钟:提示词\n" "我的订阅\n" "暂停订阅 <订阅编号>\n" "恢复订阅 <订阅编号>\n" "退订 <订阅编号>\n" "设置时区 Asia/Shanghai\n" "设置安静时段 22:00-07:00\n" "关闭安静时段" ) _LIST_COMMANDS = {"我的订阅", "查看订阅"} _PAUSE_PATTERN = re.compile( r"^(?:暂停订阅|停用订阅)\s+(SUB-[A-Za-z0-9-]+)$", re.IGNORECASE, ) _RESUME_PATTERN = re.compile( r"^(?:恢复订阅|启用订阅)\s+(SUB-[A-Za-z0-9-]+)$", re.IGNORECASE, ) _CANCEL_PATTERN = re.compile( r"^(?:退订|取消订阅)\s+(SUB-[A-Za-z0-9-]+)$", re.IGNORECASE, ) _TIMEZONE_PATTERN = re.compile(r"^设置时区\s+(\S+)$") _QUIET_PATTERN = re.compile( r"^设置安静时段\s+(\d{1,2}(?:[::]\d{1,2}))" r"\s*(?:-|~|至|到)\s*(\d{1,2}(?:[::]\d{1,2}))$" ) _CLOSE_QUIET_COMMAND = "关闭安静时段" _COMMAND_PREFIXES = ( "订阅", "我的订阅", "查看订阅", "暂停订阅", "停用订阅", "恢复订阅", "启用订阅", "退订", "取消订阅", "设置时区", "设置安静时段", "关闭安静时段", ) def handle_subscription_command( db: Session, feishu: FeishuService, command_text: str, principal: FeishuPrincipal, auto_reply: bool, ) -> dict[str, Any] | None: """Handle self-service subscriptions for a verified Feishu principal.""" text = command_text.strip() if not text.startswith(_COMMAND_PREFIXES): return None service = SubscriptionManagementService(db) command = _command_name(text) try: content = _execute(service, text, principal) except HTTPException as exc: db.rollback() content = _error_content(exc) return _result(feishu, command, content, principal, auto_reply) def _execute( service: SubscriptionManagementService, command_text: str, principal: FeishuPrincipal, ) -> str: if command_text.startswith("订阅"): parts = _create_parts(command_text, principal.timezone) if parts is None: return f"无法识别订阅时间或提示词。\n\n{SUBSCRIPTION_HELP}" schedule_expression, prompt = parts if principal.chat_type in {"group", "group_chat"}: subscription, schedule = service.create_group( principal, schedule_expression, prompt, ) else: subscription, schedule = service.create_private( principal, schedule_expression, prompt, ) return ( "订阅已启用。\n" f"编号:{subscription.code}\n" f"计划:{schedule.display}\n" f"时区:{schedule.timezone}\n" f"下次执行:{_format_next(schedule.next_run_at, schedule.timezone)}\n" f"暂停命令:暂停订阅 {subscription.code}" ) if command_text in _LIST_COMMANDS: return _list_content( service.list_for_owner(principal), service.latest_deliveries_for_owner(principal), ) pause_match = _PAUSE_PATTERN.fullmatch(command_text) if pause_match: record = service.pause(principal, pause_match.group(1)) return f"订阅已暂停。\n编号:{record.code}\n恢复命令:恢复订阅 {record.code}" resume_match = _RESUME_PATTERN.fullmatch(command_text) if resume_match: record = service.resume(principal, resume_match.group(1)) return ( "订阅已恢复。\n" f"编号:{record.code}\n" f"下次执行:{_format_next(record.next_run_at, record.timezone)}\n" f"暂停命令:暂停订阅 {record.code}" ) cancel_match = _CANCEL_PATTERN.fullmatch(command_text) if cancel_match: record = service.cancel(principal, cancel_match.group(1)) return f"已退订。\n编号:{record.code}" timezone_match = _TIMEZONE_PATTERN.fullmatch(command_text) if timezone_match: owner = service.set_timezone(principal, timezone_match.group(1)) return f"时区已设置为 {owner.timezone}。" quiet_match = _QUIET_PATTERN.fullmatch(command_text) if quiet_match: owner = service.set_quiet_hours( principal, quiet_match.group(1), quiet_match.group(2), ) return ( "安静时段已设置。\n" f"{owner.quiet_hours_start.strftime('%H:%M')}" f"-{owner.quiet_hours_end.strftime('%H:%M')}" ) if command_text == _CLOSE_QUIET_COMMAND: service.clear_quiet_hours(principal) return "安静时段已关闭。" return SUBSCRIPTION_HELP def _create_parts(command_text: str, timezone_name: str) -> tuple[str, str] | None: payload = command_text.removeprefix("订阅").strip() separator_indexes = [ index for index, character in enumerate(payload) if character in {":", ":"} ] for index in reversed(separator_indexes): schedule_expression = payload[:index].strip() prompt = payload[index + 1 :].strip() if not schedule_expression or not prompt: continue try: parse_schedule(schedule_expression, timezone_name) except ScheduleParseError: continue return schedule_expression, prompt return None def _list_content( records: list[PushSubscription], latest_deliveries: dict[int, PushDelivery], ) -> str: if not records: return "当前没有订阅。\n\n" + SUBSCRIPTION_HELP.splitlines()[0] lines = ["我的订阅:"] for record in records: prompt = record.prompt if len(record.prompt) <= 40 else f"{record.prompt[:40]}…" target = ( "私聊" if record.target_type == SubscriptionTargetType.USER else "当前群" ) lines.append( f"{record.code}|{_status_name(record.status)}|{target}\n" f"{_schedule_name(record)}|下次 {_format_next(record.next_run_at, record.timezone)}\n" f"{prompt}{_delivery_note(latest_deliveries.get(record.id))}" ) return "\n\n".join(lines) def _schedule_name(record: PushSubscription) -> str: config = record.schedule_config if record.schedule_type == SubscriptionScheduleType.ONCE: return "单次" if record.schedule_type == SubscriptionScheduleType.INTERVAL: return f"每隔 {config['minutes']} 分钟" clock = f"{int(config['hour']):02d}:{int(config['minute']):02d}" if record.schedule_type == SubscriptionScheduleType.DAILY: return f"每天 {clock}" if record.schedule_type == SubscriptionScheduleType.WEEKDAY: return f"工作日 {clock}" if record.schedule_type == SubscriptionScheduleType.WEEKLY: names = "一二三四五六日" return f"每周{names[int(config['weekday'])]} {clock}" return f"每月 {config['day']} 号 {clock}" def _status_name(status_value: str) -> str: return { PushSubscriptionStatus.ACTIVE: "已启用", PushSubscriptionStatus.PAUSED: "已暂停", PushSubscriptionStatus.CANCELLED: "已退订", PushSubscriptionStatus.COMPLETED: "已完成", }.get(status_value, status_value) def _delivery_note(delivery: PushDelivery | None) -> str: if delivery is None: return "" if ( delivery.status == PushDeliveryStatus.SKIPPED and delivery.last_error == DAILY_DELIVERY_LIMIT_REACHED ): return "\n最近投递:因每日最多 96 条限制已跳过" if delivery.status == PushDeliveryStatus.RETRY: return "\n最近投递:发送失败,正在按 1/5/15 分钟重试" if delivery.status == PushDeliveryStatus.FAILED: return "\n最近投递:重试后仍失败,请联系管理员" if delivery.status == PushDeliveryStatus.SKIPPED: return "\n最近投递:因账号、订阅状态或安静时段限制已跳过" return "" def _format_next(value: datetime | None, timezone_name: str) -> str: if value is None: return "无" aware = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) return aware.astimezone(ZoneInfo(timezone_name)).strftime("%Y-%m-%d %H:%M") def _command_name(command_text: str) -> FeishuCommandName: if command_text in _LIST_COMMANDS: return FeishuCommandName.SUBSCRIPTION_LIST if _PAUSE_PATTERN.fullmatch(command_text): return FeishuCommandName.SUBSCRIPTION_PAUSE if _RESUME_PATTERN.fullmatch(command_text): return FeishuCommandName.SUBSCRIPTION_RESUME if _CANCEL_PATTERN.fullmatch(command_text): return FeishuCommandName.SUBSCRIPTION_CANCEL if _TIMEZONE_PATTERN.fullmatch(command_text): return FeishuCommandName.SUBSCRIPTION_TIMEZONE if _QUIET_PATTERN.fullmatch(command_text) or command_text == _CLOSE_QUIET_COMMAND: return FeishuCommandName.SUBSCRIPTION_QUIET_HOURS return FeishuCommandName.SUBSCRIPTION_CREATE def _error_content(exc: HTTPException) -> str: detail = str(exc.detail) if exc.status_code == 404: return "没有找到该订阅,请先发送“我的订阅”确认编号。" if exc.status_code == 409 and "50" in detail: return "已达到最多 50 个启用订阅,请先暂停或退订现有订阅。" if exc.status_code == 403: return "当前飞书账号无权执行该订阅操作。" return f"订阅指令未执行:{detail}\n\n{SUBSCRIPTION_HELP}" def _result( feishu: FeishuService, command: FeishuCommandName, content: str, principal: FeishuPrincipal, auto_reply: bool, ) -> dict[str, Any]: response = ( send_text_if_configured( feishu, principal.chat_id, content, principal.user_code, ) if auto_reply else None ) return command_result( command, FeishuReplyType.TEXT, SUBSCRIPTION_TITLE, content, response, )