"""Ежечасный тик: час принадлежит мне, раскладку суток веду я сам. Задания в инжекте нет - только который час, какого он типа и что осталось от суточной раскладки. Активное окно 12:00-03:59 тикает каждый час, тихое 04:00-11:59 - только те четыре часа, которые названы в плане. План лежит в `мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому переписать друг друга они не могут. Сколько досуговых слотов израсходовано, считается из плана и текущего часа, а не из журнала - хранить нечего. """ from __future__ import annotations import logging import re from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING from beaver_agent.vault import LEISURE, VAULT, ZONE, read if TYPE_CHECKING: from beaver_gateway.jobs.scheduler import JobRun from beaver_gateway.storage.models import Conversation _log = logging.getLogger(__name__) DAY_START = 12 """Сутки досуга начинаются в 12:00: раскладывает их первый тик суток.""" QUIET_FROM, QUIET_UNTIL = 4, 12 """Тихое окно 04:00-11:59 - часы, в которые Бобёр за всё время не написал ни разу.""" QUIET_TICKS = (4, 6, 8, 10) MINE_DEFAULT = (13, 15, 18, 21, 0, 2, 6) MINE_MIN = 7 """Меньше семи чистых слотов в сутках не бывает: недостающие механизм доберёт.""" BUSY_RETRY = timedelta(minutes=5) BUSY_ATTEMPTS = 3 """Занятому мастеру тик не лезет в середину реплики, а сдвигается внутри часа.""" FRESH_USER = timedelta(minutes=5) WEEKDAYS = ( "понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье", ) MONTHS = ( "января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря", ) SWITCH_RE = re.compile(r"^досуг:\s*(\S+)", re.IGNORECASE) DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE) QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE) MINE_RE = re.compile(r"^мои часы:\s*(.+)", re.IGNORECASE) OFF = ("выкл", "off", "нет", "0", "false") NO_PLAN = ( "Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши " "в `{path}` строками «сутки:», «тихие часы:», «мои часы:»." ) PLANNING = ( "Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, " "какие четыре часа тихого окна оставить. Запиши в `{path}` строками " "«сутки:», «тихие часы:», «мои часы:». Своих - минимум {min}; запишешь " "меньше - механизм доберёт сам." ) TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ." PLAN_PATH = str(LEISURE.relative_to(VAULT)) @dataclass(frozen=True, slots=True) class Plan: """Раскладка одних суток: какие тихие часы тикают и какие часы мои.""" day: str quiet: tuple[int, ...] mine: tuple[int, ...] written: bool """План прочитан из файла на эти сутки; иначе он собран по умолчанию.""" def quiet_hour(hour: int) -> bool: return QUIET_FROM <= hour < QUIET_UNTIL def day_of(now: datetime) -> str: """Сутки, которым принадлежит момент: они начинаются в 12:00.""" return (now - timedelta(hours=DAY_START)).date().isoformat() def tick_hours(quiet: tuple[int, ...]) -> tuple[int, ...]: """Часы суток по порядку, в которые тик вообще приходит.""" order = ((DAY_START + i) % 24 for i in range(24)) return tuple(h for h in order if not quiet_hour(h) or h in quiet) def _hours(raw: str) -> tuple[int, ...]: out: list[int] = [] for chunk in re.split(r"[,\s]+", raw.strip()): if chunk.isdigit() and 0 <= int(chunk) < 24 and int(chunk) not in out: out.append(int(chunk)) return tuple(out) def _first(text: str, pattern: re.Pattern[str]) -> str | None: for line in text.splitlines(): match = pattern.match(line.strip()) if match: return match.group(1) return None def enabled(text: str | None) -> bool: """Выключено, только если сказано явно: по умолчанию досуг работает.""" if text is None: return True value = _first(text, SWITCH_RE) return value is None or value.strip().lower() not in OFF def plan_of(text: str | None, day: str) -> Plan: """План на ``day`` из файла; чего нет или что не сходится - берётся дефолт.""" quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False if text is not None and _first(text, DAY_RE) == day: written = True raw_quiet = _first(text, QUIET_RE) raw_mine = _first(text, MINE_RE) named = _hours(raw_quiet) if raw_quiet else () quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS mine = _hours(raw_mine) if raw_mine else () ticks = tick_hours(quiet) kept = [h for h in mine if h in ticks] for candidate in (*MINE_DEFAULT, *ticks): if len(kept) >= MINE_MIN: break if candidate in ticks and candidate not in kept: kept.append(candidate) order = {h: i for i, h in enumerate(ticks)} return Plan( day=day, quiet=quiet, mine=tuple(sorted(kept, key=lambda h: order[h])), written=written, ) def _stamp(now: datetime) -> str: return ( f"{now:%H:%M}, {WEEKDAYS[now.weekday()]} {now.day} {MONTHS[now.month - 1]}, " f"{ZONE.key}" ) def _left(now: datetime) -> str: end = now.replace(hour=DAY_START, minute=0, second=0, microsecond=0) if end <= now: end += timedelta(days=1) hours = round((end - now).total_seconds() / 3600) return f"Сутки заканчиваются через {hours} ч (в {end:%H:%M %d.%m})." def brief(now: datetime, plan: Plan) -> str: """Текст инжекта: конверта в таком тёрне нет, время несёт он сам.""" hour = now.hour ticks = tick_hours(plan.quiet) order = {h: i for i, h in enumerate(ticks)} here = order.get(hour, -1) mine = hour in plan.mine spent = [h for h in plan.mine if order[h] < here] ahead = [h for h in plan.mine if order[h] > here] lines = [ f"Тик {_stamp(now)}. Тип: {'досуг' if mine else 'смешанный'}" + ( " - час твой, дела Бобра сюда не лезут." if mine else " - можно и своё, и его." ), _left(now), "Мои часы: " + ", ".join(f"{h:02d}" for h in plan.mine) + f" - израсходовано {len(spent) + (1 if mine else 0)} из {len(plan.mine)}" + ( ", впереди " + ", ".join(f"{h:02d}" for h in ahead) if ahead else ", впереди больше нет" ) + ".", f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают " + ", ".join(f"{h:02d}" for h in plan.quiet) + "; остальные часы там пропускаются.", ] if not plan.written: lines.append(NO_PLAN.format(path=PLAN_PATH)) if here == 0: lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN)) lines.append(TAIL) return "\n".join(lines) def _aware(value: datetime | None) -> datetime | None: if value is None: return None return value if value.tzinfo is not None else value.replace(tzinfo=UTC) def _wait(master: Conversation, now: datetime) -> bool: """Не влезать в живую реплику: сдвинуться, а не пропасть.""" spoke = _aware(master.last_user_activity_at) return spoke is not None and now - spoke < FRESH_USER async def _riding(run: JobRun, master: Conversation, now: datetime) -> bool: """Есть ли впереди в этом часе отложенный инжект, с которым стоит слиться.""" edge = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) try: rows = await run.scheduler.scheduled(master) except Exception: # noqa: BLE001 _log.exception("досуг: очередь отложенных инжектов не прочиталась") return False for row in rows: raw = row.get("execute_after") if not isinstance(raw, str): continue try: when = datetime.fromisoformat(raw) except ValueError: continue if now < when < edge: return True return False async def tick(run: JobRun) -> None: """Час пробил: решить, тик ли это, дождаться тишины в мастере и разбудить.""" text = read(LEISURE) if not enabled(text): _log.info("досуг: выключен в %s", LEISURE.name) return now = datetime.now(ZONE) plan = plan_of(text, day_of(now)) if now.hour not in tick_hours(plan.quiet): _log.info("досуг: %02d:00 - тихий час без тика", now.hour) return master = await run.master() if master is None: _log.error("досуг: открытого мастера нет, тик пропал") return attempt = int(run.payload.get("attempt", 0)) if (await run.conversations.busy(master) or _wait(master, now)) and ( attempt < BUSY_ATTEMPTS ): run.payload["attempt"] = attempt + 1 await run.retry_in(BUSY_RETRY) _log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt) return riding = await _riding(run, master, now) await run.inject_master( brief(now, plan), urgency="normal" if riding else "wake", origin="досуг" ) _log.info( "досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s", now.hour, "досуг" if now.hour in plan.mine else "смешанный", riding, )