Ctrl K
ComunidadGuías…

utils.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Shared utilities for BrainRotGuard."""

import logging
import re
from datetime import datetime, timezone

from i18n import format_time as locale_format_time, normalize_locale, t

logger = logging.getLogger(__name__)

DAY_NAMES = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
DAY_GROUPS = {"weekdays": DAY_NAMES[:5], "weekend": DAY_NAMES[5:]}
CAT_LABELS = {"edu": "Educational", "fun": "Entertainment"}


def get_weekday(tz_name: str = "") -> str:
    """Get today's short day name (mon-sun) in the given timezone.

    Falls back to UTC if tz_name is empty or invalid.
    """
    if tz_name:
        try:
            from zoneinfo import ZoneInfo
            tz = ZoneInfo(tz_name)
            return DAY_NAMES[datetime.now(tz).weekday()]
        except Exception:
            logger.warning("Invalid timezone %r, falling back to UTC", tz_name)
    return DAY_NAMES[datetime.now(timezone.utc).weekday()]

# Matches: 800, 0800, 8:00, 800am, 8:00am, 800pm, 8:00PM, 2000, 20:00
_TIME_RE = re.compile(
    r'^(\d{1,2}):?(\d{2})\s*(am|pm)?$',
    re.IGNORECASE,
)
# Matches hour-only with am/pm: 8am, 12pm, 9PM
_TIME_HOUR_RE = re.compile(
    r'^(\d{1,2})\s*(am|pm)$',
    re.IGNORECASE,
)


def get_today_str(tz_name: str = "") -> str:
    """Get today's date as YYYY-MM-DD in the given timezone.

    Falls back to UTC if tz_name is empty or invalid.
    """
    if tz_name:
        try:
            from zoneinfo import ZoneInfo
            tz = ZoneInfo(tz_name)
            return datetime.now(tz).strftime("%Y-%m-%d")
        except Exception:
            logger.warning("Invalid timezone %r, falling back to UTC", tz_name)
    return datetime.now(timezone.utc).strftime("%Y-%m-%d")


def get_day_utc_bounds(date_str: str, tz_name: str = "") -> tuple[str, str]:
    """Convert a local date (YYYY-MM-DD) to UTC start/end timestamps.

    Returns (start_utc, end_utc) as ISO strings for use in SQL queries
    against UTC-stored watched_at timestamps.
    """
    from datetime import timedelta
    local_date = datetime.strptime(date_str, "%Y-%m-%d")
    if tz_name:
        try:
            from zoneinfo import ZoneInfo
            tz = ZoneInfo(tz_name)
            start_local = local_date.replace(tzinfo=tz)
            end_local = (local_date + timedelta(days=1)).replace(tzinfo=tz)
            start_utc = start_local.astimezone(timezone.utc)
            end_utc = end_local.astimezone(timezone.utc)
            return (start_utc.strftime("%Y-%m-%d %H:%M:%S"),
                    end_utc.strftime("%Y-%m-%d %H:%M:%S"))
        except Exception:
            logger.warning("Invalid timezone %r for day bounds, using date as-is", tz_name)
    next_day = (local_date + timedelta(days=1)).strftime("%Y-%m-%d")
    return (date_str, next_day)


def parse_time_input(raw: str) -> str | None:
    """Parse flexible time input into 24-hour "HH:MM" format.

    Accepts: 800, 0800, 8:00, 800am, 8:00am, 800pm, 8:00PM, 2000, 20:00
    Returns normalized "HH:MM" string or None if invalid.
    """
    raw = raw.strip()
    m = _TIME_RE.match(raw)
    if m:
        hour = int(m.group(1))
        minute = int(m.group(2))
        meridiem = (m.group(3) or "").lower()
    else:
        m = _TIME_HOUR_RE.match(raw)
        if not m:
            return None
        hour = int(m.group(1))
        minute = 0
        meridiem = m.group(2).lower()

    if meridiem == "am":
        if hour == 12:
            hour = 0
        elif hour > 12:
            return None
    elif meridiem == "pm":
        if hour == 12:
            pass  # 12pm = 12
        elif hour > 12:
            return None
        else:
            hour += 12
    else:
        # 24-hour format
        if hour > 23:
            return None

    if minute > 59:
        return None

    return f"{hour:02d}:{minute:02d}"


def format_time_12h(hhmm: str) -> str:
    """Convert "HH:MM" to human-readable 12-hour format.

    "08:00" -> "8 AM", "08:30" -> "8:30 AM", "20:00" -> "8 PM", "00:00" -> "12 AM"
    Omits minutes when they are :00 for shorter display.
    """
    try:
        h, m = map(int, hhmm.split(":"))
    except (ValueError, AttributeError):
        return hhmm
    suffix = "AM" if h < 12 else "PM"
    display_h = h % 12 or 12
    if m == 0:
        return f"{display_h} {suffix}"
    return f"{display_h}:{m:02d} {suffix}"


def is_within_schedule(
    start_str: str,
    end_str: str,
    tz_name: str = "",
    locale: str = "en",
    time_format: str | None = None,
) -> tuple[bool, str]:
    """Check if current time falls within the scheduled access window.

    Returns (allowed, unlock_time_display).
    - Both empty → (True, "")
    - Only start set → allowed from start to midnight (blocked before start)
    - Only end set → allowed from midnight to end (blocked after end)
    - Both set → allowed within [start, end), handles overnight wrap
    """
    if not start_str and not end_str:
        return (True, "")

    locale = normalize_locale(locale)

    # Get current local time
    if tz_name:
        try:
            from zoneinfo import ZoneInfo
            now = datetime.now(ZoneInfo(tz_name))
        except Exception:
            now = datetime.now(timezone.utc)
    else:
        now = datetime.now(timezone.utc)

    now_minutes = now.hour * 60 + now.minute

    # Only start set: blocked before start, allowed from start onward
    if start_str and not end_str:
        try:
            sh, sm = map(int, start_str.split(":"))
        except (ValueError, AttributeError):
            return (True, "")
        allowed = now_minutes >= sh * 60 + sm
        unlock_time = (
            t(locale, "at {time}", time=locale_format_time(start_str, locale, time_format=time_format))
            if not allowed else ""
        )
        return (allowed, unlock_time)

    # Only end set: allowed until end, blocked after
    if end_str and not start_str:
        try:
            eh, em = map(int, end_str.split(":"))
        except (ValueError, AttributeError):
            return (True, "")
        allowed = now_minutes < eh * 60 + em
        unlock_time = t(locale, "tomorrow") if not allowed else ""
        return (allowed, unlock_time)

    # Both set
    try:
        start_h, start_m = map(int, start_str.split(":"))
        end_h, end_m = map(int, end_str.split(":"))
    except (ValueError, AttributeError):
        return (True, "")

    start_minutes = start_h * 60 + start_m
    end_minutes = end_h * 60 + end_m

    if start_minutes <= end_minutes:
        # Normal range (e.g. 08:00 - 20:00)
        allowed = start_minutes <= now_minutes < end_minutes
    else:
        # Overnight range (e.g. 22:00 - 06:00)
        allowed = now_minutes >= start_minutes or now_minutes < end_minutes

    unlock_time = (
        t(locale, "at {time}", time=locale_format_time(start_str, locale, time_format=time_format))
        if not allowed else ""
    )
    return (allowed, unlock_time)


def get_bonus_minutes(store, today_str: str) -> int:
    """Get today's bonus minutes from store. Returns 0 if none or date mismatch."""
    bonus_date = store.get_setting("daily_bonus_date", "")
    if bonus_date == today_str:
        return int(store.get_setting("daily_bonus_minutes", "0") or "0")
    return 0


def resolve_setting(base_key: str, store, tz_name: str = "", default: str = "") -> str:
    """Resolve a setting with per-day override support.

    Checks {day}_{base_key} first; falls back to {base_key}.
    """
    day = get_weekday(tz_name)
    day_val = store.get_setting(f"{day}_{base_key}", "")
    if day_val:
        return day_val
    return store.get_setting(base_key, default)