Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21517e0948 | ||
|
|
f6d5d99287 | ||
|
|
a4ce40eaeb | ||
|
|
a01a08737d | ||
|
|
014da65f86 | ||
|
|
bba8d9ce99 | ||
|
|
106f801e3c | ||
|
|
0a6a2995fb | ||
|
|
af331e6d91 | ||
|
|
1ecfa01374 | ||
|
|
ad571e1a2a | ||
|
|
db7e4e308c | ||
|
|
645d000f8d | ||
|
|
51c27e76de | ||
|
|
3b2f5057da | ||
|
|
f161d6a46d | ||
|
|
f7844a5411 | ||
|
|
43d0d43f9d | ||
|
|
5bd4c26ffc | ||
|
|
444f145cf3 | ||
|
|
3a77a12447 | ||
|
|
58e343aae0 | ||
|
|
fe26760b84 | ||
|
|
c3d5c944ea | ||
|
|
56f8f2a1e1 | ||
|
|
505bf8d016 | ||
|
|
4765f9c7cf | ||
|
|
ec55994fc7 | ||
|
|
05fcfb6447 | ||
|
|
b1f3fc93ed | ||
|
|
6f8711c1a8 | ||
|
|
ab6812f3ee | ||
|
|
96a7820026 | ||
|
|
4415e890c9 | ||
|
|
02723c21a7 | ||
|
|
40231049eb | ||
|
|
9dfb8ba201 | ||
|
|
80806d18d8 | ||
|
|
c830ca73e6 | ||
|
|
c6bf30b5af | ||
|
|
566fb5e895 | ||
|
|
6ff0da31a3 | ||
|
|
7af833a4d7 | ||
|
|
03ed732f7a | ||
|
|
03774c54e3 | ||
|
|
71af41f35f | ||
|
|
ef5adf231a | ||
|
|
62b8b1c14b | ||
|
|
91c638186b | ||
|
|
95fc8a4578 | ||
|
|
a937500d9b |
No files matched your search
@@ -62,9 +62,6 @@ FIREFLY_PAT=
|
|||||||
# Календари: name=url,name=url (пусто - без календарей).
|
# Календари: name=url,name=url (пусто - без календарей).
|
||||||
# В url лежит приватный ical-фид (google `/private-<токен>/`, usos `key=`) -
|
# В url лежит приватный ical-фид (google `/private-<токен>/`, usos `key=`) -
|
||||||
# это секрет: утёк url = утёк календарь, лечится перевыпуском фида.
|
# это секрет: утёк url = утёк календарь, лечится перевыпуском фида.
|
||||||
# Модели календари даются MCP-руками, а справка конверта раз в день читает
|
|
||||||
# тот же ical напрямую - урл фида она берёт из параметра `icsUrl` этих же
|
|
||||||
# записей; записи без `icsUrl` остаются рукой, но в справку не попадают.
|
|
||||||
CALENDAR_MCPS=
|
CALENDAR_MCPS=
|
||||||
# MCP beavergram (telegram-history)
|
# MCP beavergram (telegram-history)
|
||||||
BEAVERGRAM_MCP=
|
BEAVERGRAM_MCP=
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
|||||||
from beaver_gateway.agents.base import ExposedMcp
|
from beaver_gateway.agents.base import ExposedMcp
|
||||||
from beaver_gateway.mcp.types import McpServer, McpServerT
|
from beaver_gateway.mcp.types import McpServer, McpServerT
|
||||||
|
|
||||||
from beaver_agent.hands.calendars import Calendars, calendars, feeds
|
from beaver_agent.hands.calendars import calendars
|
||||||
from beaver_agent.hands.homeassistant import HomeAssistant
|
from beaver_agent.hands.homeassistant import HomeAssistant
|
||||||
from beaver_agent.hands.komodo import Komodo
|
from beaver_agent.hands.komodo import Komodo
|
||||||
from beaver_agent.hands.vibegram import Vibegram
|
from beaver_agent.hands.vibegram import Vibegram
|
||||||
@@ -52,10 +52,6 @@ telegram = (
|
|||||||
calendar_servers = calendars(env("CALENDAR_MCPS", ""))
|
calendar_servers = calendars(env("CALENDAR_MCPS", ""))
|
||||||
calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_servers)
|
calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_servers)
|
||||||
|
|
||||||
CALENDAR_FEEDS = feeds(env("CALENDAR_MCPS", ""))
|
|
||||||
CALENDARS = Calendars(CALENDAR_FEEDS, tz=TZ) if CALENDAR_FEEDS else None
|
|
||||||
"""Те же фиды, но напрямую: ближайшие события едут в справку без модели."""
|
|
||||||
|
|
||||||
KOMODO = (
|
KOMODO = (
|
||||||
Komodo(
|
Komodo(
|
||||||
url=os.environ["KOMODO_URL"],
|
url=os.environ["KOMODO_URL"],
|
||||||
|
|||||||
@@ -1,70 +1,9 @@
|
|||||||
"""Календари: MCP-руки для модели и тот же ical-фид, читаемый gateway напрямую.
|
"""Календари из `CALENDAR_MCPS=name=url,name=url`; в url - приватный фид с токеном."""
|
||||||
|
|
||||||
`CALENDAR_MCPS=name=url,name=url`; в url - приватный фид с токеном, и там же,
|
|
||||||
параметром `icsUrl`, лежит сам ical. Модель ходит в календари через MCP, а
|
|
||||||
справка конверта читает фид сама: канал, о котором никто не спрашивает, молчит
|
|
||||||
незаметно - так экзаменационная сессия и пролежала невидимой.
|
|
||||||
|
|
||||||
Парсер здесь свой и маленький: в образе gateway ical-библиотеки нет, а нужно
|
|
||||||
немного - когда начинается, как называется, где. Повторы разворачиваются по
|
|
||||||
FREQ=DAILY/WEEKLY/MONTHLY/YEARLY с INTERVAL, BYDAY, BYMONTH, COUNT и UNTIL;
|
|
||||||
EXDATE и перенесённые экземпляры (RECURRENCE-ID) вычитаются. Шаг считается по
|
|
||||||
настенным часам события, а не в абсолютном времени: еженедельная встреча
|
|
||||||
переживает перевод часов. Событие, порождённое ежедневным правилом, помечается
|
|
||||||
`routine`: в справке такие едут одной строкой, чтобы не вытеснять разовые.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from calendar import monthrange
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import UTC, date, datetime, timedelta
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from urllib.parse import parse_qs, urlsplit
|
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
||||||
|
|
||||||
from beaver_gateway.mcp.types import HttpMcp, McpServer
|
from beaver_gateway.mcp.types import HttpMcp, McpServer
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from datetime import tzinfo
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Calendars",
|
|
||||||
"Event",
|
|
||||||
"Feed",
|
|
||||||
"Upcoming",
|
|
||||||
"calendars",
|
|
||||||
"feeds",
|
|
||||||
"is_routine",
|
|
||||||
"short",
|
|
||||||
]
|
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
USER_AGENT = "beaver-gateway (ical)"
|
|
||||||
MAX_BYTES = 4_000_000
|
|
||||||
"""Больше четырёх мегабайт фида - это уже не календарь, а чей-то экспорт всего."""
|
|
||||||
STEPS_CAP = 20_000
|
|
||||||
"""Потолок шагов разворачивания одного повтора: страховка от вечного цикла."""
|
|
||||||
PLACE_MAX = 70
|
|
||||||
TITLE_MAX = 90
|
|
||||||
ERROR_MAX = 90
|
|
||||||
NO_TITLE = "(без названия)"
|
|
||||||
|
|
||||||
WEEKDAYS = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
|
|
||||||
DT_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$")
|
|
||||||
BYDAY_RE = re.compile(r"^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$")
|
|
||||||
ESCAPE_RE = re.compile(r"\\([\\;,nN])")
|
|
||||||
PLACE_RE = re.compile(
|
|
||||||
r"^\s*(?:sala|room|зал|аудитория)\b\s*[:.]?\s*(.+)$", re.IGNORECASE | re.MULTILINE
|
|
||||||
)
|
|
||||||
"""Зал USOS живёт в описании (`Sala: 315`), а LOCATION - это адрес корпуса."""
|
|
||||||
|
|
||||||
|
|
||||||
def calendars(raw: str) -> list[HttpMcp]:
|
def calendars(raw: str) -> list[HttpMcp]:
|
||||||
servers: list[HttpMcp] = []
|
servers: list[HttpMcp] = []
|
||||||
@@ -78,457 +17,3 @@ def calendars(raw: str) -> list[HttpMcp]:
|
|||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip()))
|
servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip()))
|
||||||
return servers
|
return servers
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Feed:
|
|
||||||
name: str
|
|
||||||
url: str
|
|
||||||
"""Ical по подписке: приватный, с токеном внутри - в логи не писать."""
|
|
||||||
|
|
||||||
|
|
||||||
def feeds(raw: str) -> tuple[Feed, ...]:
|
|
||||||
"""Ical-фиды из тех же записей `CALENDAR_MCPS`: урл лежит в `icsUrl`.
|
|
||||||
|
|
||||||
Запись без ical-урла просто не даёт фида: MCP-рука у модели остаётся,
|
|
||||||
справка про этот календарь молчит - конфигурация, а не отказ канала.
|
|
||||||
"""
|
|
||||||
out: list[Feed] = []
|
|
||||||
for raw_entry in raw.split(","):
|
|
||||||
name, sep, url = raw_entry.strip().partition("=")
|
|
||||||
if not sep or not url.strip():
|
|
||||||
continue
|
|
||||||
ics = _ics_url(url.strip())
|
|
||||||
if ics:
|
|
||||||
out.append(Feed(name=name.strip(), url=ics))
|
|
||||||
return tuple(out)
|
|
||||||
|
|
||||||
|
|
||||||
def _ics_url(url: str) -> str | None:
|
|
||||||
inner = parse_qs(urlsplit(url).query).get("icsUrl") or []
|
|
||||||
if inner and inner[0].strip():
|
|
||||||
return inner[0].strip()
|
|
||||||
return url if urlsplit(url).path.endswith(".ics") else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Event:
|
|
||||||
start: datetime
|
|
||||||
"""С зоной: у события своя (TZID), плавающее время читается в зоне агента."""
|
|
||||||
title: str
|
|
||||||
place: str
|
|
||||||
all_day: bool
|
|
||||||
routine: bool = False
|
|
||||||
"""Порождено правилом, повторяющимся раз в день или чаще: см. `is_routine`."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Upcoming:
|
|
||||||
at: datetime
|
|
||||||
"""Момент запроса - его и показывает справка: «данные на …»."""
|
|
||||||
events: tuple[Event, ...] = ()
|
|
||||||
errors: tuple[str, ...] = ()
|
|
||||||
"""По строке на фид, который не ответил; пустое окно - это не ошибка."""
|
|
||||||
read: int = 0
|
|
||||||
"""Сколько фидов ответило: ноль - показывать нечего, кроме отказа."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Calendars:
|
|
||||||
"""Ближайшие события всех фидов - одним списком, отсортированным по началу."""
|
|
||||||
|
|
||||||
feeds: tuple[Feed, ...]
|
|
||||||
tz: str = "Europe/Warsaw"
|
|
||||||
timeout: float = 8.0
|
|
||||||
days: int = 7
|
|
||||||
|
|
||||||
def upcoming(self, now: datetime) -> Upcoming:
|
|
||||||
"""Окно `days` от `now`; отказ фида - строка в `errors`, не исключение."""
|
|
||||||
zone = ZoneInfo(self.tz)
|
|
||||||
since = now.astimezone(zone)
|
|
||||||
events: list[Event] = []
|
|
||||||
errors: list[str] = []
|
|
||||||
read = 0
|
|
||||||
for feed in self.feeds:
|
|
||||||
try:
|
|
||||||
text = self.fetch(feed.url)
|
|
||||||
found = events_in(text, zone=zone, since=since, days=self.days)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
_log.warning("календарь %s: %s", feed.name, short(exc), exc_info=True)
|
|
||||||
errors.append(f"{feed.name}: {short(exc)}")
|
|
||||||
continue
|
|
||||||
read += 1
|
|
||||||
events.extend(found)
|
|
||||||
events.sort(key=lambda e: (e.start, e.title))
|
|
||||||
return Upcoming(at=since, events=tuple(events), errors=tuple(errors), read=read)
|
|
||||||
|
|
||||||
def fetch(self, url: str) -> str:
|
|
||||||
if urlsplit(url).scheme not in ("http", "https"):
|
|
||||||
msg = "фид не по http(s)"
|
|
||||||
raise ValueError(msg)
|
|
||||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310
|
|
||||||
with urllib.request.urlopen(request, timeout=self.timeout) as response: # noqa: S310
|
|
||||||
raw: bytes = response.read(MAX_BYTES)
|
|
||||||
return raw.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def short(exc: BaseException) -> str:
|
|
||||||
"""Причина отказа одной строкой: в конверт едет она, урл фида - никогда."""
|
|
||||||
if isinstance(exc, urllib.error.HTTPError):
|
|
||||||
reason = f"HTTP {exc.code}"
|
|
||||||
elif isinstance(exc, TimeoutError):
|
|
||||||
reason = "таймаут"
|
|
||||||
elif isinstance(exc, urllib.error.URLError):
|
|
||||||
reason = f"сеть: {exc.reason}"
|
|
||||||
else:
|
|
||||||
reason = f"{type(exc).__name__}: {exc}".strip(": ")
|
|
||||||
return " ".join(reason.split())[:ERROR_MAX]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Prop:
|
|
||||||
value: str
|
|
||||||
params: dict[str, str] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
Vevent = dict[str, list[Prop]]
|
|
||||||
|
|
||||||
|
|
||||||
def unfold(text: str) -> list[str]:
|
|
||||||
"""Ical-строки, склеенные обратно: продолжение начинается с пробела или таба."""
|
|
||||||
lines: list[str] = []
|
|
||||||
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
|
||||||
if lines and raw[:1] in (" ", "\t"):
|
|
||||||
lines[-1] += raw[1:]
|
|
||||||
else:
|
|
||||||
lines.append(raw)
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def _split(line: str) -> tuple[str, str] | None:
|
|
||||||
"""`NAME;PARAM=x:значение` - двоеточие вне кавычек делит имя и значение."""
|
|
||||||
quoted = False
|
|
||||||
for i, char in enumerate(line):
|
|
||||||
if char == '"':
|
|
||||||
quoted = not quoted
|
|
||||||
elif char == ":" and not quoted:
|
|
||||||
return line[:i], line[i + 1 :]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _prop(line: str) -> tuple[str, Prop] | None:
|
|
||||||
parts = _split(line)
|
|
||||||
if parts is None:
|
|
||||||
return None
|
|
||||||
head, value = parts
|
|
||||||
chunks = head.split(";")
|
|
||||||
name = chunks[0].strip().upper()
|
|
||||||
if not name:
|
|
||||||
return None
|
|
||||||
params: dict[str, str] = {}
|
|
||||||
for chunk in chunks[1:]:
|
|
||||||
key, sep, raw = chunk.partition("=")
|
|
||||||
if sep:
|
|
||||||
params[key.strip().upper()] = raw.strip().strip('"')
|
|
||||||
return name, Prop(value=value, params=params)
|
|
||||||
|
|
||||||
|
|
||||||
def vevents(lines: list[str]) -> list[Vevent]:
|
|
||||||
"""Только свойства самих VEVENT: VALARM внутри них пропускается целиком."""
|
|
||||||
out: list[Vevent] = []
|
|
||||||
current: Vevent | None = None
|
|
||||||
depth = 0
|
|
||||||
for line in lines:
|
|
||||||
parsed = _prop(line)
|
|
||||||
if parsed is None:
|
|
||||||
continue
|
|
||||||
name, prop = parsed
|
|
||||||
block = prop.value.strip().upper()
|
|
||||||
if name == "BEGIN":
|
|
||||||
if block == "VEVENT" and current is None:
|
|
||||||
current = {}
|
|
||||||
elif current is not None:
|
|
||||||
depth += 1
|
|
||||||
continue
|
|
||||||
if name == "END":
|
|
||||||
if block == "VEVENT" and current is not None and depth == 0:
|
|
||||||
out.append(current)
|
|
||||||
current = None
|
|
||||||
elif current is not None and depth:
|
|
||||||
depth -= 1
|
|
||||||
continue
|
|
||||||
if current is not None and depth == 0:
|
|
||||||
current.setdefault(name, []).append(prop)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def unescape(value: str) -> str:
|
|
||||||
return ESCAPE_RE.sub(lambda m: "\n" if m.group(1) in "nN" else m.group(1), value)
|
|
||||||
|
|
||||||
|
|
||||||
def _flat(value: str, limit: int) -> str:
|
|
||||||
flat = " ".join(unescape(value).split())
|
|
||||||
return flat if len(flat) <= limit else flat[: limit - 1] + "…"
|
|
||||||
|
|
||||||
|
|
||||||
def _first(event: Vevent, name: str) -> Prop | None:
|
|
||||||
props = event.get(name)
|
|
||||||
return props[0] if props else None
|
|
||||||
|
|
||||||
|
|
||||||
def _zone_of(prop: Prop, default: tzinfo) -> tzinfo:
|
|
||||||
tzid = prop.params.get("TZID")
|
|
||||||
if not tzid:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return ZoneInfo(tzid)
|
|
||||||
except (ZoneInfoNotFoundError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def moment(prop: Prop, value: str, zone: tzinfo) -> tuple[datetime, bool] | None:
|
|
||||||
"""Значение DTSTART/EXDATE в момент времени; второе - «на весь день»."""
|
|
||||||
match = DT_RE.match(value.strip())
|
|
||||||
if match is None:
|
|
||||||
return None
|
|
||||||
year, month, day = (int(match.group(i)) for i in (1, 2, 3))
|
|
||||||
if match.group(4) is None or prop.params.get("VALUE", "").upper() == "DATE":
|
|
||||||
return datetime(year, month, day, tzinfo=zone), True
|
|
||||||
hour, minute, second = (int(match.group(i)) for i in (4, 5, 6))
|
|
||||||
where = UTC if match.group(7) else _zone_of(prop, zone)
|
|
||||||
return datetime(year, month, day, hour, minute, second, tzinfo=where), False
|
|
||||||
|
|
||||||
|
|
||||||
def _instants(event: Vevent, name: str, zone: tzinfo) -> set[datetime]:
|
|
||||||
"""Даты-исключения в UTC: значений в одной строке может быть несколько."""
|
|
||||||
out: set[datetime] = set()
|
|
||||||
for prop in event.get(name, []):
|
|
||||||
for chunk in prop.value.split(","):
|
|
||||||
parsed = moment(prop, chunk, zone)
|
|
||||||
if parsed is not None:
|
|
||||||
out.add(parsed[0].astimezone(UTC))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _rule(value: str) -> dict[str, str]:
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
for chunk in value.split(";"):
|
|
||||||
key, sep, raw = chunk.partition("=")
|
|
||||||
if sep:
|
|
||||||
out[key.strip().upper()] = raw.strip()
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _int(value: str | None, default: int) -> int:
|
|
||||||
try:
|
|
||||||
return int(value) if value else default
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _byday(value: str) -> list[tuple[int, int]]:
|
|
||||||
"""`-1SU,2TU` → [(-1, воскресенье), (2, вторник)]; без числа - каждый такой день."""
|
|
||||||
out: list[tuple[int, int]] = []
|
|
||||||
for chunk in value.split(","):
|
|
||||||
match = BYDAY_RE.match(chunk.strip().upper())
|
|
||||||
if match:
|
|
||||||
out.append((_int(match.group(1), 0), WEEKDAYS[match.group(2)]))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _month_days(
|
|
||||||
year: int, month: int, days: list[tuple[int, int]], clock: datetime
|
|
||||||
) -> list[datetime]:
|
|
||||||
total = monthrange(year, month)[1]
|
|
||||||
out: list[datetime] = []
|
|
||||||
for nth, weekday in days:
|
|
||||||
matching = [
|
|
||||||
day
|
|
||||||
for day in range(1, total + 1)
|
|
||||||
if date(year, month, day).weekday() == weekday
|
|
||||||
]
|
|
||||||
if not nth:
|
|
||||||
picked = matching
|
|
||||||
elif abs(nth) <= len(matching):
|
|
||||||
picked = [matching[nth - 1] if nth > 0 else matching[nth]]
|
|
||||||
else:
|
|
||||||
picked = []
|
|
||||||
out += [
|
|
||||||
datetime(year, month, day, clock.hour, clock.minute, clock.second) # noqa: DTZ001
|
|
||||||
for day in picked
|
|
||||||
]
|
|
||||||
return sorted(out)
|
|
||||||
|
|
||||||
|
|
||||||
def _candidates(
|
|
||||||
naive: datetime, freq: str, interval: int, days: list[tuple[int, int]], month: int
|
|
||||||
) -> Iterator[datetime]:
|
|
||||||
"""Настенные времена повтора после первого - по возрастанию, бесконечно."""
|
|
||||||
if freq == "DAILY":
|
|
||||||
moving = naive
|
|
||||||
while True:
|
|
||||||
moving += timedelta(days=interval)
|
|
||||||
yield moving
|
|
||||||
elif freq == "WEEKLY" and not days:
|
|
||||||
moving = naive
|
|
||||||
while True:
|
|
||||||
moving += timedelta(weeks=interval)
|
|
||||||
yield moving
|
|
||||||
elif freq == "WEEKLY":
|
|
||||||
start = naive.date() - timedelta(days=naive.weekday())
|
|
||||||
step = 0
|
|
||||||
while True:
|
|
||||||
base = start + timedelta(weeks=interval * step)
|
|
||||||
for _, weekday in sorted(days, key=lambda d: d[1]):
|
|
||||||
yield datetime.combine(base + timedelta(days=weekday), naive.time())
|
|
||||||
step += 1
|
|
||||||
elif freq in ("MONTHLY", "YEARLY"):
|
|
||||||
step = 0
|
|
||||||
while True:
|
|
||||||
step += 1
|
|
||||||
if freq == "MONTHLY":
|
|
||||||
index = naive.year * 12 + naive.month - 1 + interval * step
|
|
||||||
year, number = divmod(index, 12)
|
|
||||||
number += 1
|
|
||||||
else:
|
|
||||||
year, number = naive.year + interval * step, month or naive.month
|
|
||||||
if days:
|
|
||||||
yield from _month_days(year, number, days, naive)
|
|
||||||
elif naive.day <= monthrange(year, number)[1]:
|
|
||||||
yield naive.replace(year=year, month=number)
|
|
||||||
|
|
||||||
|
|
||||||
def occurrences(
|
|
||||||
start: datetime, rule: dict[str, str], zone: tzinfo, *, end: datetime
|
|
||||||
) -> list[datetime]:
|
|
||||||
"""Начала повтора от `start` до `end`; правило, которого мы не знаем, - разовое."""
|
|
||||||
freq = rule.get("FREQ", "").upper()
|
|
||||||
if freq not in ("DAILY", "WEEKLY", "MONTHLY", "YEARLY"):
|
|
||||||
return [start]
|
|
||||||
where = start.tzinfo or zone
|
|
||||||
naive = start.replace(tzinfo=None)
|
|
||||||
interval = max(_int(rule.get("INTERVAL"), 1), 1)
|
|
||||||
count = _int(rule.get("COUNT"), 0)
|
|
||||||
until = _until(rule.get("UNTIL"), where)
|
|
||||||
days = _byday(rule.get("BYDAY", ""))
|
|
||||||
out = [start]
|
|
||||||
stream = _candidates(naive, freq, interval, days, _int(rule.get("BYMONTH"), 0))
|
|
||||||
for step, candidate in enumerate(stream):
|
|
||||||
if step > STEPS_CAP:
|
|
||||||
break
|
|
||||||
if candidate <= naive:
|
|
||||||
continue
|
|
||||||
moving = candidate.replace(tzinfo=where)
|
|
||||||
if moving > end or (until is not None and moving > until):
|
|
||||||
break
|
|
||||||
out.append(moving)
|
|
||||||
if count and len(out) >= count:
|
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _until(value: str | None, zone: tzinfo) -> datetime | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
parsed = moment(Prop(value=value), value, zone)
|
|
||||||
if parsed is None:
|
|
||||||
return None
|
|
||||||
stop, all_day = parsed
|
|
||||||
return stop + timedelta(days=1) if all_day else stop
|
|
||||||
|
|
||||||
|
|
||||||
ROUTINE_DAYS = 5
|
|
||||||
"""Со скольких дней недели в BYDAY повтор перестаёт быть событием и делается бытом."""
|
|
||||||
|
|
||||||
|
|
||||||
def is_routine(rule: dict[str, str]) -> bool:
|
|
||||||
"""Правило, срабатывающее раз в день или чаще: завтрак, зарядка, wind down.
|
|
||||||
|
|
||||||
Такие повторы за неделю дают десятки строк и топят в себе то единственное,
|
|
||||||
ради чего справку и читают, - экзамен, встречу, приём. В конверт они едут
|
|
||||||
одной свёрнутой строкой, а не списком.
|
|
||||||
"""
|
|
||||||
freq = rule.get("FREQ", "").upper()
|
|
||||||
if max(_int(rule.get("INTERVAL"), 1), 1) != 1:
|
|
||||||
return False
|
|
||||||
if freq == "DAILY":
|
|
||||||
return True
|
|
||||||
return freq == "WEEKLY" and len(_byday(rule.get("BYDAY", ""))) >= ROUTINE_DAYS
|
|
||||||
|
|
||||||
|
|
||||||
def place_of(event: Vevent) -> str:
|
|
||||||
"""Зал из описания и адрес из LOCATION - то, что помогает дойти."""
|
|
||||||
parts: list[str] = []
|
|
||||||
description = _first(event, "DESCRIPTION")
|
|
||||||
if description is not None:
|
|
||||||
match = PLACE_RE.search(unescape(description.value))
|
|
||||||
if match:
|
|
||||||
parts.append(" ".join(match.group(0).split()))
|
|
||||||
location = _first(event, "LOCATION")
|
|
||||||
if location is not None:
|
|
||||||
flat = " ".join(unescape(location.value).split())
|
|
||||||
if flat:
|
|
||||||
parts.append(flat)
|
|
||||||
return _flat(", ".join(parts), PLACE_MAX)
|
|
||||||
|
|
||||||
|
|
||||||
def events_in(text: str, *, zone: tzinfo, since: datetime, days: int) -> list[Event]:
|
|
||||||
"""События фида, начинающиеся в окне `[since, since + days]`, с повторами."""
|
|
||||||
end = since + timedelta(days=days)
|
|
||||||
day_start = since.astimezone(zone).replace(
|
|
||||||
hour=0, minute=0, second=0, microsecond=0
|
|
||||||
)
|
|
||||||
parsed = vevents(unfold(text))
|
|
||||||
moved: dict[str, set[datetime]] = {}
|
|
||||||
for event in parsed:
|
|
||||||
uid = _first(event, "UID")
|
|
||||||
if uid is not None and "RECURRENCE-ID" in event:
|
|
||||||
moved.setdefault(uid.value, set()).update(
|
|
||||||
_instants(event, "RECURRENCE-ID", zone)
|
|
||||||
)
|
|
||||||
out: list[Event] = []
|
|
||||||
for event in parsed:
|
|
||||||
status = _first(event, "STATUS")
|
|
||||||
dtstart = _first(event, "DTSTART")
|
|
||||||
if dtstart is None or (
|
|
||||||
status is not None and status.value.upper() == "CANCELLED"
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
parsed_start = moment(dtstart, dtstart.value, zone)
|
|
||||||
if parsed_start is None:
|
|
||||||
continue
|
|
||||||
start, all_day = parsed_start
|
|
||||||
rrule = _first(event, "RRULE")
|
|
||||||
if rrule is None or "RECURRENCE-ID" in event:
|
|
||||||
starts = [start]
|
|
||||||
skip: set[datetime] = set()
|
|
||||||
routine = False
|
|
||||||
else:
|
|
||||||
rule = _rule(rrule.value)
|
|
||||||
starts = occurrences(start, rule, zone, end=end)
|
|
||||||
routine = is_routine(rule)
|
|
||||||
uid = _first(event, "UID")
|
|
||||||
skip = _instants(event, "EXDATE", zone) | moved.get(
|
|
||||||
uid.value if uid else "", set()
|
|
||||||
)
|
|
||||||
title = _flat(_text(event, "SUMMARY"), TITLE_MAX) or NO_TITLE
|
|
||||||
place = place_of(event)
|
|
||||||
for moving in starts:
|
|
||||||
floor = day_start if all_day else since
|
|
||||||
if moving < floor or moving > end or moving.astimezone(UTC) in skip:
|
|
||||||
continue
|
|
||||||
out.append(
|
|
||||||
Event(
|
|
||||||
start=moving,
|
|
||||||
title=title,
|
|
||||||
place=place,
|
|
||||||
all_day=all_day,
|
|
||||||
routine=routine,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _text(event: Vevent, name: str) -> str:
|
|
||||||
prop = _first(event, name)
|
|
||||||
return prop.value if prop is not None else ""
|
|
||||||
@@ -3,8 +3,7 @@
|
|||||||
Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать
|
Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать
|
||||||
сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого
|
сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого
|
||||||
человека, его записки о нём (`мета/бобер/наблюдения/<имя> - наблюдения.md`),
|
человека, его записки о нём (`мета/бобер/наблюдения/<имя> - наблюдения.md`),
|
||||||
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и
|
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок.
|
||||||
ближайшая неделя календарей.
|
|
||||||
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
|
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
|
||||||
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны:
|
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны:
|
||||||
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
|
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
|
||||||
@@ -12,7 +11,6 @@ Gateway путей не знает; здесь - что искать по тек
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -21,25 +19,14 @@ from typing import TYPE_CHECKING
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from beaver_gateway.conversations.envelope import RecallContext
|
from beaver_gateway.conversations.envelope import RecallContext
|
||||||
from beaver_gateway.conversations.texts import UserSaid
|
from beaver_gateway.conversations.texts import UserSaid
|
||||||
|
|
||||||
from beaver_agent.hands.calendars import Calendars, Event, Upcoming
|
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):"
|
LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):"
|
||||||
VOWELS = "аеёиоуыэюя"
|
VOWELS = "аеёиоуыэюя"
|
||||||
KINDS = {"master": "мастер", "branch": "ветка"}
|
KINDS = {"master": "мастер", "branch": "ветка"}
|
||||||
CALENDAR = "🗓 календарь"
|
|
||||||
CALENDAR_LIMIT = 40
|
|
||||||
"""Предохранитель на разовые события; повседневные повторы под него не идут."""
|
|
||||||
CALENDAR_NAMES = 5
|
|
||||||
"""Сколько названий показывать в свёрнутой строке повседневных повторов."""
|
|
||||||
CALENDAR_ERROR_MAX = 90
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -213,14 +200,11 @@ class Recall:
|
|||||||
"""Сколько последних дней дневника просматривать на упоминания."""
|
"""Сколько последних дней дневника просматривать на упоминания."""
|
||||||
mute: frozenset[str] = frozenset()
|
mute: frozenset[str] = frozenset()
|
||||||
"""Агенты, которым справку не показывают: чужие люди, чужие уши."""
|
"""Агенты, которым справку не показывают: чужие люди, чужие уши."""
|
||||||
calendar: Calendars | None = None
|
|
||||||
"""Ical-фиды календарей; None - календари не настроены, секции нет вовсе."""
|
|
||||||
_people: People | None = field(default=None, init=False, repr=False)
|
_people: People | None = field(default=None, init=False, repr=False)
|
||||||
_diary_cache: dict[str, tuple[float, list[str]]] = field(
|
_diary_cache: dict[str, tuple[float, list[str]]] = field(
|
||||||
default_factory=dict, init=False, repr=False
|
default_factory=dict, init=False, repr=False
|
||||||
)
|
)
|
||||||
_deadlines_day: date | None = field(default=None, init=False, repr=False)
|
_deadlines_day: date | None = field(default=None, init=False, repr=False)
|
||||||
_calendar_day: date | None = field(default=None, init=False, repr=False)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def people(self) -> People:
|
def people(self) -> People:
|
||||||
@@ -235,7 +219,6 @@ class Recall:
|
|||||||
lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())]
|
lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())]
|
||||||
if ctx.kind == "master":
|
if ctx.kind == "master":
|
||||||
lines += self.deadlines(today)
|
lines += self.deadlines(today)
|
||||||
lines += self.calendar_lines(ctx.now, today)
|
|
||||||
if not lines:
|
if not lines:
|
||||||
return None
|
return None
|
||||||
return "\n".join([LABEL, *lines])
|
return "\n".join([LABEL, *lines])
|
||||||
@@ -319,72 +302,6 @@ class Recall:
|
|||||||
found.sort()
|
found.sort()
|
||||||
return [f"📆 {due:%m-%d} {board}: {task}" for due, board, task in found[:limit]]
|
return [f"📆 {due:%m-%d} {board}: {task}" for due, board, task in found[:limit]]
|
||||||
|
|
||||||
def calendar_lines(self, now: datetime, today: date) -> list[str]:
|
|
||||||
"""Ближайшая неделя календарей - раз в день, следом за сроками досок.
|
|
||||||
|
|
||||||
Секция печатается всегда, когда до неё дошло: пустое окно - строкой
|
|
||||||
«событий нет», упавший фид - строкой «ОШИБКА» и записью в лог. Молча
|
|
||||||
пропасть она не может, иначе отказ канала неотличим от спокойного
|
|
||||||
календаря - так однажды и пролежала незамеченной сессия в USOS.
|
|
||||||
"""
|
|
||||||
if self.calendar is None or self._calendar_day == today:
|
|
||||||
return []
|
|
||||||
self._calendar_day = today
|
|
||||||
try:
|
|
||||||
found = self.calendar.upcoming(now)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
_log.exception("календарь: справка не собралась")
|
|
||||||
reason = " ".join(f"{type(exc).__name__}: {exc}".split())
|
|
||||||
return [f"{CALENDAR}: ОШИБКА {reason[:CALENDAR_ERROR_MAX]}"]
|
|
||||||
lines = [f"{CALENDAR}: ОШИБКА {reason}" for reason in found.errors]
|
|
||||||
if found.read:
|
|
||||||
lines += self.calendar_window(found, self.calendar.days)
|
|
||||||
return lines or [f"{CALENDAR}: ОШИБКА фиды не ответили"]
|
|
||||||
|
|
||||||
def calendar_window(self, found: Upcoming, days: int) -> list[str]:
|
|
||||||
"""Окно ответивших фидов: разовые событиями, повседневный быт - одной строкой.
|
|
||||||
|
|
||||||
Разовых за неделю физически мало, и ровно они и есть сигнал: экзамен,
|
|
||||||
встреча, приём. Их видно целиком; лимит тут - предохранитель от абсурда,
|
|
||||||
а не способ уместиться. Ежедневные повторы считаются и называются, но
|
|
||||||
своими строками разовые не вытесняют - иначе секция выглядит здоровой и
|
|
||||||
при этом показывает двое суток вместо недели.
|
|
||||||
"""
|
|
||||||
stamp = f"{found.at:%Y-%m-%d %H:%M}"
|
|
||||||
if not found.events:
|
|
||||||
return [f"{CALENDAR} ({days} дней): событий нет, данные на {stamp}"]
|
|
||||||
zone = ZoneInfo(self.tz)
|
|
||||||
singles = [event for event in found.events if not event.routine]
|
|
||||||
routine = [event for event in found.events if event.routine]
|
|
||||||
shown = singles[:CALENDAR_LIMIT]
|
|
||||||
head = (
|
|
||||||
f"{CALENDAR} ({days} дней), разовых {len(singles)}, "
|
|
||||||
f"повседневных {len(routine)}, данные на {stamp}:"
|
|
||||||
)
|
|
||||||
lines = [head]
|
|
||||||
lines += [f" - {event_line(event, zone)}" for event in shown]
|
|
||||||
if len(singles) > len(shown):
|
|
||||||
lines.append(f" … и ещё {len(singles) - len(shown)} разовых в окне")
|
|
||||||
if routine:
|
|
||||||
lines.append(
|
|
||||||
f" - повседневных повторов: {len(routine)} ({routine_names(routine)})"
|
|
||||||
)
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def routine_names(events: list[Event], *, limit: int = CALENDAR_NAMES) -> str:
|
|
||||||
"""Уникальные названия бытовых повторов по первому появлению, до `limit`."""
|
|
||||||
names = list(dict.fromkeys(event.title for event in events))
|
|
||||||
return ", ".join(names[:limit]) + (" и др." if len(names) > limit else "")
|
|
||||||
|
|
||||||
|
|
||||||
def event_line(event: Event, zone: ZoneInfo) -> str:
|
|
||||||
"""Дата, начало, название и место - одной строкой в зоне агента."""
|
|
||||||
start = event.start.astimezone(zone)
|
|
||||||
when = "весь день" if event.all_day else f"{start:%H:%M}"
|
|
||||||
place = f" · {event.place}" if event.place else ""
|
|
||||||
return f"{start:%m-%d} {when} {event.title}{place}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ReplyLog:
|
class ReplyLog:
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from beaver_gateway.vault.watch import VaultWatch, WatchRules
|
from beaver_gateway.vault.watch import VaultWatch, WatchRules
|
||||||
|
|
||||||
from beaver_agent.hands import CALENDARS
|
|
||||||
from beaver_agent.memory.curator import Watched
|
from beaver_agent.memory.curator import Watched
|
||||||
from beaver_agent.memory.recall import Recall, ReplyLog
|
from beaver_agent.memory.recall import Recall, ReplyLog
|
||||||
from beaver_agent.vault import (
|
from beaver_agent.vault import (
|
||||||
@@ -55,7 +54,6 @@ recall = Recall(
|
|||||||
boards_dir=BOARDS,
|
boards_dir=BOARDS,
|
||||||
tz=TZ,
|
tz=TZ,
|
||||||
mute=frozenset({VOICE}),
|
mute=frozenset({VOICE}),
|
||||||
calendar=CALENDARS,
|
|
||||||
)
|
)
|
||||||
"""Голосовому агенту справки нет: за колонкой может стоять кто угодно."""
|
"""Голосовому агенту справки нет: за колонкой может стоять кто угодно."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import urllib.error
|
|
||||||
from email.message import Message
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from beaver_agent.hands.calendars import (
|
|
||||||
Calendars,
|
|
||||||
Feed,
|
|
||||||
calendars,
|
|
||||||
events_in,
|
|
||||||
feeds,
|
|
||||||
is_routine,
|
|
||||||
short,
|
|
||||||
unfold,
|
|
||||||
)
|
|
||||||
|
|
||||||
ZONE = ZoneInfo("Europe/Warsaw")
|
|
||||||
NOW = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) # 14:00 в Варшаве
|
|
||||||
|
|
||||||
MCPS = (
|
|
||||||
"personal=https://calendar-mcp.com/api/mcp?email=b%40b.com"
|
|
||||||
"&icsUrl=https%3A%2F%2Fcalendar.google.com%2Fical%2Fprivate-abc%2Fbasic.ics,"
|
|
||||||
"student=https://calendar-mcp.com/api/mcp?icsUrl=https%3A%2F%2Fusos.pl%2Fics%3Fkey%3Dz,"
|
|
||||||
"bare=https://calendar-mcp.com/mcp/AAAA"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ics(*events: str) -> str:
|
|
||||||
body = "".join(f"BEGIN:VEVENT\n{e.strip()}\nEND:VEVENT\n" for e in events)
|
|
||||||
return f"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//test//EN\n{body}END:VCALENDAR\n"
|
|
||||||
|
|
||||||
|
|
||||||
def starts(text: str, *, days: int = 7) -> list[str]:
|
|
||||||
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=days)
|
|
||||||
return [
|
|
||||||
f"{e.start.astimezone(ZONE):%m-%d %H:%M} {e.title}"
|
|
||||||
for e in sorted(found, key=lambda e: e.start)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_feeds_take_the_ical_url_out_of_the_same_mcp_urls():
|
|
||||||
assert [f.name for f in feeds(MCPS)] == ["personal", "student"]
|
|
||||||
assert feeds(MCPS)[0].url == (
|
|
||||||
"https://calendar.google.com/ical/private-abc/basic.ics"
|
|
||||||
)
|
|
||||||
assert feeds(MCPS)[1].url == "https://usos.pl/ics?key=z"
|
|
||||||
assert feeds("") == ()
|
|
||||||
assert feeds("plain=https://example.com/plan.ics")[0].url.endswith("plan.ics")
|
|
||||||
# MCP-рука остаётся у модели, даже если ical-фида в записи нет
|
|
||||||
assert [m.name for m in calendars(MCPS)] == [
|
|
||||||
"calendar-personal",
|
|
||||||
"calendar-student",
|
|
||||||
"calendar-bare",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_folded_lines_are_glued_back():
|
|
||||||
assert unfold("SUMMARY:длин\r\n ное\r\nUID:x") == ["SUMMARY:длинное", "UID:x"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_window_sorts_by_start_and_keeps_only_the_next_days():
|
|
||||||
text = ics(
|
|
||||||
"SUMMARY:позже\nDTSTART:20260904T080000Z",
|
|
||||||
"SUMMARY:раньше\nDTSTART:20260902T080000Z",
|
|
||||||
"SUMMARY:за окном\nDTSTART:20260920T080000Z",
|
|
||||||
"SUMMARY:уже прошло\nDTSTART:20260801T080000Z",
|
|
||||||
)
|
|
||||||
assert starts(text) == ["09-02 10:00 раньше", "09-04 10:00 позже"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_day_event_of_today_still_counts_as_upcoming():
|
|
||||||
text = ics("SUMMARY:отпуск\nDTSTART;VALUE=DATE:20260901\nDTEND;VALUE=DATE:20260902")
|
|
||||||
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)
|
|
||||||
assert len(found) == 1
|
|
||||||
assert found[0].all_day
|
|
||||||
assert f"{found[0].start:%H:%M}" == "00:00"
|
|
||||||
|
|
||||||
|
|
||||||
def test_place_takes_the_room_from_the_description_and_the_address_from_location():
|
|
||||||
text = ics(
|
|
||||||
"SUMMARY:Analiza matematyczna II - Egzamin\n"
|
|
||||||
"DTSTART;VALUE=DATE-TIME:20260903T110000\n"
|
|
||||||
"DESCRIPTION:Sala: 3\\nA23 - CW - Centrum Wykładowe\\n\n"
|
|
||||||
"LOCATION:ul. Piotrowo 2\\, 61-138 Poznań\\, Polska"
|
|
||||||
)
|
|
||||||
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)
|
|
||||||
assert found[0].place == "Sala: 3, ul. Piotrowo 2, 61-138 Poznań, Polska"
|
|
||||||
# плавающее время читается в зоне агента, а не в UTC
|
|
||||||
assert f"{found[0].start:%H:%M}" == "11:00"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cancelled_events_are_skipped():
|
|
||||||
text = ics("SUMMARY:отменено\nDTSTART:20260902T080000Z\nSTATUS:CANCELLED")
|
|
||||||
assert starts(text) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_weekly_repeat_expands_and_exdate_with_moved_instance_are_subtracted():
|
|
||||||
series = (
|
|
||||||
"UID:w1\nSUMMARY:польский\nDTSTART;TZID=Europe/Warsaw:20260824T100000\n"
|
|
||||||
"RRULE:FREQ=WEEKLY;BYDAY=MO,WE"
|
|
||||||
)
|
|
||||||
assert starts(ics(series)) == ["09-02 10:00 польский", "09-07 10:00 польский"]
|
|
||||||
moved = ics(
|
|
||||||
series + "\nEXDATE;TZID=Europe/Warsaw:20260902T100000",
|
|
||||||
"UID:w1\nSUMMARY:польский (перенос)\n"
|
|
||||||
"RECURRENCE-ID;TZID=Europe/Warsaw:20260907T100000\n"
|
|
||||||
"DTSTART;TZID=Europe/Warsaw:20260907T173000",
|
|
||||||
)
|
|
||||||
assert starts(moved) == ["09-07 17:30 польский (перенос)"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_daily_repeat_stops_on_until_and_count():
|
|
||||||
until = ics(
|
|
||||||
"SUMMARY:завтрак\nDTSTART:20260801T060000Z\n"
|
|
||||||
"RRULE:FREQ=DAILY;UNTIL=20260903T060000Z"
|
|
||||||
)
|
|
||||||
assert starts(until) == ["09-02 08:00 завтрак", "09-03 08:00 завтрак"]
|
|
||||||
counted = ics("SUMMARY:курс\nDTSTART:20260902T060000Z\nRRULE:FREQ=DAILY;COUNT=3")
|
|
||||||
assert len(starts(counted)) == 3
|
|
||||||
yearly = ics(
|
|
||||||
"SUMMARY:часы\nDTSTART:20200906T060000Z\nRRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=1SU"
|
|
||||||
)
|
|
||||||
assert starts(yearly) == ["09-06 08:00 часы"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_working_week_is_routine_but_one_weekday_a_week_is_an_event():
|
|
||||||
text = ics(
|
|
||||||
"SUMMARY:пара\nDTSTART;TZID=Europe/Warsaw:20260831T100000\n"
|
|
||||||
"RRULE:FREQ=WEEKLY;BYDAY=MO",
|
|
||||||
"SUMMARY:зарядка\nDTSTART;TZID=Europe/Warsaw:20260831T070000\n"
|
|
||||||
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
|
|
||||||
)
|
|
||||||
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)
|
|
||||||
assert {e.title for e in found if e.routine} == {"зарядка"}
|
|
||||||
assert {e.title for e in found if not e.routine} == {"пара"}
|
|
||||||
assert len([e for e in found if e.routine]) == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_routine_is_a_rule_that_fires_once_a_day_or_more_often():
|
|
||||||
assert is_routine({"FREQ": "DAILY"})
|
|
||||||
assert is_routine({"FREQ": "WEEKLY", "BYDAY": "MO,TU,WE,TH,FR,SA"})
|
|
||||||
assert not is_routine({"FREQ": "WEEKLY", "BYDAY": "MO,WE,FR"})
|
|
||||||
assert not is_routine({"FREQ": "WEEKLY"})
|
|
||||||
assert not is_routine({"FREQ": "MONTHLY"})
|
|
||||||
# через день - это уже не быт, такое видно поимённо
|
|
||||||
assert not is_routine({"FREQ": "DAILY", "INTERVAL": "2"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_single_event_is_never_routine():
|
|
||||||
text = ics("SUMMARY:Egzamin\nDTSTART:20260903T090000Z")
|
|
||||||
assert not events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)[0].routine
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_feed_that_fails_becomes_a_line_not_an_exception():
|
|
||||||
class Broken(Calendars):
|
|
||||||
def fetch(self, url: str) -> str:
|
|
||||||
raise urllib.error.HTTPError(url, 403, "Forbidden", Message(), None)
|
|
||||||
|
|
||||||
broken = Broken((Feed("student", "https://usos.pl/ics"),), tz="Europe/Warsaw")
|
|
||||||
found = broken.upcoming(NOW)
|
|
||||||
assert found.read == 0
|
|
||||||
assert found.events == ()
|
|
||||||
assert found.errors == ("student: HTTP 403",)
|
|
||||||
|
|
||||||
|
|
||||||
def test_short_reason_is_one_line_and_never_carries_the_url():
|
|
||||||
assert short(TimeoutError()) == "таймаут"
|
|
||||||
assert short(urllib.error.URLError("no route")).startswith("сеть:")
|
|
||||||
assert short(ValueError("а\nб")) == "ValueError: а б"
|
|
||||||
+1
-130
@@ -4,7 +4,6 @@ from pathlib import Path
|
|||||||
from beaver_gateway.conversations.texts import UserSaid
|
from beaver_gateway.conversations.texts import UserSaid
|
||||||
from beaver_gateway.conversations.envelope import RecallContext
|
from beaver_gateway.conversations.envelope import RecallContext
|
||||||
|
|
||||||
from beaver_agent.hands.calendars import Calendars, Event, Feed
|
|
||||||
from beaver_agent.memory.recall import (
|
from beaver_agent.memory.recall import (
|
||||||
LABEL,
|
LABEL,
|
||||||
Recall,
|
Recall,
|
||||||
@@ -12,7 +11,6 @@ from beaver_agent.memory.recall import (
|
|||||||
frontmatter_aliases,
|
frontmatter_aliases,
|
||||||
mentions,
|
mentions,
|
||||||
notes_head,
|
notes_head,
|
||||||
routine_names,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,7 +22,7 @@ def _card(path: Path, aliases: list[str] | None = None) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _vault(tmp_path: Path, calendar: Calendars | None = None) -> Recall:
|
def _vault(tmp_path: Path) -> Recall:
|
||||||
people = tmp_path / "👤 люди"
|
people = tmp_path / "👤 люди"
|
||||||
_card(people / "личное" / "Зина.md", ["Зина"])
|
_card(people / "личное" / "Зина.md", ["Зина"])
|
||||||
_card(people / "личное" / "Прохор.md", ["Прохор Тестов"])
|
_card(people / "личное" / "Прохор.md", ["Прохор Тестов"])
|
||||||
@@ -70,7 +68,6 @@ def _vault(tmp_path: Path, calendar: Calendars | None = None) -> Recall:
|
|||||||
diary_dir=diary,
|
diary_dir=diary,
|
||||||
boards_dir=boards,
|
boards_dir=boards,
|
||||||
tz="Europe/Warsaw",
|
tz="Europe/Warsaw",
|
||||||
calendar=calendar,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -189,129 +186,3 @@ def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None:
|
|||||||
assert notes_head(recall.notes_dir / "нет.md", ("сейчас",)) is None
|
assert notes_head(recall.notes_dir / "нет.md", ("сейчас",)) is None
|
||||||
short = notes_head(path, ("сейчас", "паттерны"), cap=2)
|
short = notes_head(path, ("сейчас", "паттерны"), cap=2)
|
||||||
assert short is not None and short.endswith("… ещё 3 строк в файле")
|
assert short is not None and short.endswith("… ещё 3 строк в файле")
|
||||||
|
|
||||||
|
|
||||||
def _ics(*events: str) -> str:
|
|
||||||
body = "".join(f"BEGIN:VEVENT\n{e}\nEND:VEVENT\n" for e in events)
|
|
||||||
return f"BEGIN:VCALENDAR\nVERSION:2.0\n{body}END:VCALENDAR\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _calendar(text: str | None) -> Calendars:
|
|
||||||
"""Фид, который отдаёт готовый ical; None - фид, который падает."""
|
|
||||||
|
|
||||||
class Fake(Calendars):
|
|
||||||
def fetch(self, url: str) -> str:
|
|
||||||
if text is None:
|
|
||||||
raise OSError("фид не ответил")
|
|
||||||
return text
|
|
||||||
|
|
||||||
return Fake((Feed("личный", "https://cal.example/x.ics"),), tz="Europe/Warsaw")
|
|
||||||
|
|
||||||
|
|
||||||
def _daily(title: str, clock: str) -> str:
|
|
||||||
return f"SUMMARY:{title}\nDTSTART;TZID=Europe/Warsaw:20260902T{clock}\nRRULE:FREQ=DAILY"
|
|
||||||
|
|
||||||
|
|
||||||
SINGLES = (
|
|
||||||
"SUMMARY:Созвон\nDTSTART:20260902T160000Z",
|
|
||||||
"SUMMARY:Врач\nDTSTART:20260903T090000Z",
|
|
||||||
"SUMMARY:Analiza matematyczna II - Egzamin\nDTSTART;VALUE=DATE-TIME:20260905T110000\n"
|
|
||||||
"DESCRIPTION:Sala: 3\\nA23 - CW\\n\nLOCATION:ul. Piotrowo 2\\, Poznań",
|
|
||||||
"SUMMARY:за окном\nDTSTART:20260925T080000Z",
|
|
||||||
)
|
|
||||||
ROUTINE = (
|
|
||||||
_daily("Завтрак", "080000"),
|
|
||||||
_daily("Обед", "150000"),
|
|
||||||
_daily("Ужин", "200000"),
|
|
||||||
_daily("Перекус", "180000"),
|
|
||||||
_daily("Wind down", "223000"),
|
|
||||||
)
|
|
||||||
BUSY = _ics(*SINGLES, *ROUTINE)
|
|
||||||
|
|
||||||
|
|
||||||
def _section(recall: Recall, now: datetime, text: str = "привет") -> list[str]:
|
|
||||||
block = recall.block(RecallContext(text=text, kind="master", now=now))
|
|
||||||
assert block is not None
|
|
||||||
return [ln for ln in block.splitlines() if "календар" in ln or ln.startswith(" -")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_shows_every_single_event_and_folds_the_daily_routine(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
recall = _vault(tmp_path, _calendar(BUSY))
|
|
||||||
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
|
||||||
lines = _section(recall, now)
|
|
||||||
assert lines == [
|
|
||||||
"🗓 календарь (7 дней), разовых 3, повседневных 31, данные на 2026-09-01 14:00:",
|
|
||||||
" - 09-02 18:00 Созвон",
|
|
||||||
" - 09-03 11:00 Врач",
|
|
||||||
" - 09-05 11:00 Analiza matematyczna II - Egzamin · Sala: 3, ul. Piotrowo 2, Poznań",
|
|
||||||
" - повседневных повторов: 31 (Завтрак, Обед, Перекус, Ужин, Wind down)",
|
|
||||||
]
|
|
||||||
# секция дневная, как и сроки досок: второй конверт за те же сутки её не несёт
|
|
||||||
again = recall.block(RecallContext(text="и ещё", kind="master", now=now))
|
|
||||||
assert again is None or "календарь" not in again
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_amount_of_routine_can_push_out_a_single_event(tmp_path: Path) -> None:
|
|
||||||
noise = tuple(_daily(f"быт {i}", f"{i:02d}0000") for i in range(9, 24))
|
|
||||||
recall = _vault(tmp_path, _calendar(_ics(*SINGLES, *noise)))
|
|
||||||
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
|
||||||
lines = _section(recall, now)
|
|
||||||
assert lines[0].startswith("🗓 календарь (7 дней), разовых 3, повседневных 96")
|
|
||||||
assert [ln for ln in lines if "Egzamin" in ln]
|
|
||||||
assert (
|
|
||||||
len([ln for ln in lines if ln.startswith(" - ")]) == 4
|
|
||||||
) # 3 разовых + свёртка
|
|
||||||
assert lines[-1].startswith(" - повседневных повторов: 96")
|
|
||||||
assert "и др." in lines[-1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_routine_line_counts_and_names_up_to_five(tmp_path: Path) -> None:
|
|
||||||
events = [
|
|
||||||
Event(
|
|
||||||
start=datetime(2026, 9, 2, 8, tzinfo=UTC),
|
|
||||||
title=t,
|
|
||||||
place="",
|
|
||||||
all_day=False,
|
|
||||||
routine=True,
|
|
||||||
)
|
|
||||||
for t in ("Завтрак", "Обед", "Завтрак", "Ужин")
|
|
||||||
]
|
|
||||||
assert routine_names(events) == "Завтрак, Обед, Ужин"
|
|
||||||
assert routine_names(events, limit=2) == "Завтрак, Обед и др."
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_section_is_printed_even_when_the_week_is_empty(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
recall = _vault(
|
|
||||||
tmp_path, _calendar(_ics("SUMMARY:потом\nDTSTART:20261101T080000Z"))
|
|
||||||
)
|
|
||||||
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
|
||||||
block = recall.block(RecallContext(text="просто привет", kind="master", now=now))
|
|
||||||
assert block is not None
|
|
||||||
assert (
|
|
||||||
"🗓 календарь (7 дней): событий нет, данные на 2026-09-01 14:00"
|
|
||||||
in block.splitlines()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_failure_shows_up_in_the_envelope_instead_of_disappearing(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
recall = _vault(tmp_path, _calendar(None))
|
|
||||||
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
|
||||||
block = recall.block(RecallContext(text="просто привет", kind="master", now=now))
|
|
||||||
assert block is not None
|
|
||||||
assert "🗓 календарь: ОШИБКА личный: OSError: фид не ответил" in block.splitlines()
|
|
||||||
# ветке справка календарь не носит, как и сроки досок
|
|
||||||
branch = recall.block(RecallContext(text="просто привет", kind="branch", now=now))
|
|
||||||
assert branch is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_without_configured_feeds_there_is_no_calendar_section(tmp_path: Path) -> None:
|
|
||||||
recall = _vault(tmp_path)
|
|
||||||
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
|
||||||
block = recall.block(RecallContext(text="просто привет", kind="master", now=now))
|
|
||||||
assert block is not None and "календарь" not in block
|
|
||||||
Reference in new issue
Block a user