import re from calendar import monthrange from dataclasses import dataclass from datetime import UTC, date, datetime, time, timedelta from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from app.modules.subscriptions.constants import ( INVALID_SCHEDULE, INVALID_TIMEZONE, MIN_INTERVAL_MINUTES, SubscriptionScheduleType, ) _WEEKDAYS = { "一": 0, "二": 1, "三": 2, "四": 3, "五": 4, "六": 5, "日": 6, "天": 6, } _WEEKDAY_NAMES = ("一", "二", "三", "四", "五", "六", "日") _INTERVAL_PATTERN = re.compile(r"每隔\s*(?P\d+)\s*(?P分钟|小时)") _DAILY_PATTERN = re.compile(r"每天\s*(?P.+)") _WEEKDAY_PATTERN = re.compile(r"(?:每个)?工作日\s*(?P.+)") _WEEKLY_PATTERN = re.compile(r"每周(?P[一二三四五六日天])\s*(?P.+)") _MONTHLY_PATTERN = re.compile( r"每月\s*(?P\d{1,2})\s*(?:号|日)\s*(?P.+)" ) _RELATIVE_PATTERN = re.compile(r"(?P今天|明天)\s*(?P.+)") _ISO_DATE_PATTERN = re.compile( r"(?P\d{4})[-/](?P\d{1,2})[-/](?P\d{1,2})" r"\s+(?P.+)" ) _CHINESE_DATE_PATTERN = re.compile( r"(?P\d{4})年(?P\d{1,2})月(?P\d{1,2})[日号]" r"\s*(?P.+)" ) _COLON_CLOCK_PATTERN = re.compile(r"(?P\d{1,2}):(?P\d{1,2})") _CHINESE_CLOCK_PATTERN = re.compile( r"(?P\d{1,2})点(?:(?P半)|(?P\d{1,2})分?)?" ) class ScheduleParseError(ValueError): """Raised when a controlled schedule expression cannot be normalized.""" @dataclass(frozen=True, slots=True) class NormalizedSchedule: schedule_type: str schedule_config: dict[str, Any] timezone: str next_run_at: datetime display: str def parse_schedule( expression: str, timezone_name: str, *, now: datetime | None = None, ) -> NormalizedSchedule: """Parse the supported Chinese schedule grammar into a UTC plan.""" text = _normalize_expression(expression) zone = validate_timezone(timezone_name) now_utc = _as_utc(now) local_now = now_utc.astimezone(zone) match = _INTERVAL_PATTERN.fullmatch(text) if match: value = int(match.group("value")) minutes = value * (60 if match.group("unit") == "小时" else 1) if minutes < MIN_INTERVAL_MINUTES: raise ScheduleParseError(f"订阅间隔不得短于 {MIN_INTERVAL_MINUTES} 分钟") try: next_run = now_utc + timedelta(minutes=minutes) except OverflowError as exc: raise ScheduleParseError("订阅间隔过大") from exc config = { "minutes": minutes, "anchor_at": _to_naive_utc(next_run).isoformat(), } display_value = ( f"每隔 {value} 小时" if match.group("unit") == "小时" else f"每隔 {value} 分钟" ) return NormalizedSchedule( schedule_type=SubscriptionScheduleType.INTERVAL, schedule_config=config, timezone=timezone_name, next_run_at=_to_naive_utc(next_run), display=display_value, ) match = _DAILY_PATTERN.fullmatch(text) if match: hour, minute = _parse_clock(match.group("clock")) config = {"hour": hour, "minute": minute} return _recurring_schedule( SubscriptionScheduleType.DAILY, config, timezone_name, now_utc, f"每天 {hour:02d}:{minute:02d}", ) match = _WEEKDAY_PATTERN.fullmatch(text) if match: hour, minute = _parse_clock(match.group("clock")) config = {"hour": hour, "minute": minute} return _recurring_schedule( SubscriptionScheduleType.WEEKDAY, config, timezone_name, now_utc, f"工作日 {hour:02d}:{minute:02d}", ) match = _WEEKLY_PATTERN.fullmatch(text) if match: hour, minute = _parse_clock(match.group("clock")) weekday = _WEEKDAYS[match.group("weekday")] config = {"weekday": weekday, "hour": hour, "minute": minute} return _recurring_schedule( SubscriptionScheduleType.WEEKLY, config, timezone_name, now_utc, f"每周{_WEEKDAY_NAMES[weekday]} {hour:02d}:{minute:02d}", ) match = _MONTHLY_PATTERN.fullmatch(text) if match: day = int(match.group("day")) if not 1 <= day <= 31: raise ScheduleParseError("每月日期必须在 1 到 31 之间") hour, minute = _parse_clock(match.group("clock")) config = {"day": day, "hour": hour, "minute": minute} return _recurring_schedule( SubscriptionScheduleType.MONTHLY, config, timezone_name, now_utc, f"每月 {day} 号 {hour:02d}:{minute:02d}", ) match = _RELATIVE_PATTERN.fullmatch(text) if match: hour, minute = _parse_clock(match.group("clock")) offset = 1 if match.group("day") == "明天" else 0 target_date = local_now.date() + timedelta(days=offset) return _once_schedule( target_date, hour, minute, timezone_name, now_utc, f"{match.group('day')} {hour:02d}:{minute:02d}", ) match = _ISO_DATE_PATTERN.fullmatch(text) or _CHINESE_DATE_PATTERN.fullmatch(text) if match: try: target_date = date( int(match.group("year")), int(match.group("month")), int(match.group("day")), ) except ValueError as exc: raise ScheduleParseError("日期不存在") from exc hour, minute = _parse_clock(match.group("clock")) return _once_schedule( target_date, hour, minute, timezone_name, now_utc, f"{target_date.isoformat()} {hour:02d}:{minute:02d}", ) raise ScheduleParseError( f"{INVALID_SCHEDULE}。示例:每天 09:00、每周一 18:00、每隔 30 分钟" ) def next_occurrence( schedule_type: str, schedule_config: dict[str, Any], timezone_name: str, *, after: datetime, ) -> datetime | None: """Return the first UTC occurrence strictly after ``after``.""" zone = validate_timezone(timezone_name) after_utc = _as_utc(after) plan_type = SubscriptionScheduleType(schedule_type) if plan_type == SubscriptionScheduleType.ONCE: run_at = _parse_stored_utc(schedule_config["run_at"]) return _to_naive_utc(run_at) if run_at > after_utc else None if plan_type == SubscriptionScheduleType.INTERVAL: interval = timedelta(minutes=int(schedule_config["minutes"])) anchor = _parse_stored_utc(schedule_config["anchor_at"]) if anchor > after_utc: return _to_naive_utc(anchor) elapsed = after_utc - anchor steps = elapsed // interval + 1 return _to_naive_utc(anchor + interval * steps) hour = int(schedule_config["hour"]) minute = int(schedule_config["minute"]) local_after = after_utc.astimezone(zone) if plan_type == SubscriptionScheduleType.DAILY: return _next_daily(local_after, hour, minute, zone) if plan_type == SubscriptionScheduleType.WEEKDAY: return _next_weekday(local_after, hour, minute, zone) if plan_type == SubscriptionScheduleType.WEEKLY: weekday = int(schedule_config["weekday"]) return _next_weekly(local_after, weekday, hour, minute, zone) if plan_type == SubscriptionScheduleType.MONTHLY: day = int(schedule_config["day"]) return _next_monthly(local_after, day, hour, minute, zone) raise ScheduleParseError(INVALID_SCHEDULE) def is_in_quiet_hours( current: datetime, timezone_name: str, quiet_start: time | str | None, quiet_end: time | str | None, ) -> bool: if quiet_start is None or quiet_end is None: return False start = _coerce_time(quiet_start) end = _coerce_time(quiet_end) if start == end: return False local_time = _as_utc(current).astimezone(validate_timezone(timezone_name)).time() local_time = local_time.replace(tzinfo=None) if start < end: return start <= local_time < end return local_time >= start or local_time < end def next_quiet_end( current: datetime, timezone_name: str, quiet_start: time | str, quiet_end: time | str, ) -> datetime: """Return quiet-window end as a naive UTC timestamp.""" zone = validate_timezone(timezone_name) now_local = _as_utc(current).astimezone(zone) start = _coerce_time(quiet_start) end = _coerce_time(quiet_end) end_date = now_local.date() if start > end and now_local.time().replace(tzinfo=None) >= start: end_date += timedelta(days=1) candidate = _local_candidate(end_date, end.hour, end.minute, zone) if candidate is None: candidate = _first_valid_local_after(end_date, end.hour, end.minute, zone) return _to_naive_utc(candidate) def validate_timezone(timezone_name: str) -> ZoneInfo: try: return ZoneInfo(timezone_name) except (ZoneInfoNotFoundError, ValueError, TypeError) as exc: raise ScheduleParseError(INVALID_TIMEZONE) from exc def parse_quiet_clock(value: str) -> time: hour, minute = _parse_clock(_normalize_expression(value)) return time(hour=hour, minute=minute) def _recurring_schedule( schedule_type: str, config: dict[str, Any], timezone_name: str, now_utc: datetime, display: str, ) -> NormalizedSchedule: next_run = next_occurrence( schedule_type, config, timezone_name, after=now_utc, ) if next_run is None: raise ScheduleParseError(INVALID_SCHEDULE) return NormalizedSchedule( schedule_type=schedule_type, schedule_config=config, timezone=timezone_name, next_run_at=next_run, display=display, ) def _once_schedule( target_date: date, hour: int, minute: int, timezone_name: str, now_utc: datetime, display: str, ) -> NormalizedSchedule: zone = validate_timezone(timezone_name) target = _local_candidate(target_date, hour, minute, zone) if target is None: raise ScheduleParseError("该本地时间不存在") if target <= now_utc: raise ScheduleParseError("执行时间必须晚于当前时间") run_at = _to_naive_utc(target) return NormalizedSchedule( schedule_type=SubscriptionScheduleType.ONCE, schedule_config={"run_at": run_at.isoformat()}, timezone=timezone_name, next_run_at=run_at, display=display, ) def _next_daily(local_after: datetime, hour: int, minute: int, zone: ZoneInfo) -> datetime: for offset in range(0, 370): candidate = _local_candidate(local_after.date() + timedelta(days=offset), hour, minute, zone) if candidate is not None and candidate > local_after.astimezone(UTC): return _to_naive_utc(candidate) raise ScheduleParseError(INVALID_SCHEDULE) def _next_weekday(local_after: datetime, hour: int, minute: int, zone: ZoneInfo) -> datetime: for offset in range(0, 14): target_date = local_after.date() + timedelta(days=offset) if target_date.weekday() >= 5: continue candidate = _local_candidate(target_date, hour, minute, zone) if candidate is not None and candidate > local_after.astimezone(UTC): return _to_naive_utc(candidate) raise ScheduleParseError(INVALID_SCHEDULE) def _next_weekly( local_after: datetime, weekday: int, hour: int, minute: int, zone: ZoneInfo, ) -> datetime: offset = (weekday - local_after.weekday()) % 7 for weeks in range(0, 3): target_date = local_after.date() + timedelta(days=offset + weeks * 7) candidate = _local_candidate(target_date, hour, minute, zone) if candidate is not None and candidate > local_after.astimezone(UTC): return _to_naive_utc(candidate) raise ScheduleParseError(INVALID_SCHEDULE) def _next_monthly( local_after: datetime, day: int, hour: int, minute: int, zone: ZoneInfo, ) -> datetime: year = local_after.year month = local_after.month for _ in range(0, 240): if day <= monthrange(year, month)[1]: candidate = _local_candidate(date(year, month, day), hour, minute, zone) if candidate is not None and candidate > local_after.astimezone(UTC): return _to_naive_utc(candidate) month += 1 if month == 13: year += 1 month = 1 raise ScheduleParseError(INVALID_SCHEDULE) def _normalize_expression(expression: str) -> str: text = re.sub(r"\s+", " ", str(expression or "").strip()).replace(":", ":") if not text: raise ScheduleParseError(INVALID_SCHEDULE) return text def _parse_clock(value: str) -> tuple[int, int]: text = value.strip().replace(":", ":") match = _COLON_CLOCK_PATTERN.fullmatch(text) if match: hour = int(match.group("hour")) minute = int(match.group("minute")) else: match = _CHINESE_CLOCK_PATTERN.fullmatch(text) if not match: raise ScheduleParseError("时间必须使用 HH:MM 或 H点M分") hour = int(match.group("hour")) minute = 30 if match.group("half") else int(match.group("minute") or 0) if not 0 <= hour <= 23 or not 0 <= minute <= 59: raise ScheduleParseError("时间超出有效范围") return hour, minute def _local_candidate( target_date: date, hour: int, minute: int, zone: ZoneInfo, ) -> datetime | None: naive = datetime.combine(target_date, time(hour=hour, minute=minute)) aware = naive.replace(tzinfo=zone) roundtrip = aware.astimezone(UTC).astimezone(zone).replace(tzinfo=None) if roundtrip != naive: return None return aware.astimezone(UTC) def _first_valid_local_after( target_date: date, hour: int, minute: int, zone: ZoneInfo, ) -> datetime: base = datetime.combine(target_date, time(hour=hour, minute=minute)) for offset in range(0, 181): candidate = base + timedelta(minutes=offset) aware = _local_candidate(candidate.date(), candidate.hour, candidate.minute, zone) if aware is not None: return aware raise ScheduleParseError("安静时段结束时间无效") def _coerce_time(value: time | str) -> time: if isinstance(value, time): return value.replace(tzinfo=None, second=0, microsecond=0) return parse_quiet_clock(value) def _as_utc(value: datetime | None) -> datetime: if value is None: return datetime.now(UTC) if value.tzinfo is None: return value.replace(tzinfo=UTC) return value.astimezone(UTC) def _to_naive_utc(value: datetime) -> datetime: return _as_utc(value).replace(tzinfo=None) def _parse_stored_utc(value: str | datetime) -> datetime: parsed = value if isinstance(value, datetime) else datetime.fromisoformat(str(value)) return _as_utc(parsed)