feat(hands,memory): the recall carries the next week of both calendars daily

This commit is contained in:
hh
2026-09-09 08:21:03 +00:00
parent f3a625087f
commit 8d0f80440e
7 changed files with 775 additions and 4 deletions
+3
View File
@@ -62,6 +62,9 @@ FIREFLY_PAT=
# Календари: name=url,name=url (пусто - без календарей).
# В url лежит приватный ical-фид (google `/private-<токен>/`, usos `key=`) -
# это секрет: утёк url = утёк календарь, лечится перевыпуском фида.
# Модели календари даются MCP-руками, а справка конверта раз в день читает
# тот же ical напрямую - урл фида она берёт из параметра `icsUrl` этих же
# записей; записи без `icsUrl` остаются рукой, но в справку не попадают.
CALENDAR_MCPS=
# MCP beavergram (telegram-history)
BEAVERGRAM_MCP=
+5 -1
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from beaver_gateway.agents.base import ExposedMcp
from beaver_gateway.mcp.types import McpServer, McpServerT
from beaver_agent.hands.calendars import calendars
from beaver_agent.hands.calendars import Calendars, calendars, feeds
from beaver_agent.hands.homeassistant import HomeAssistant
from beaver_agent.hands.komodo import Komodo
from beaver_agent.hands.vibegram import Vibegram
@@ -52,6 +52,10 @@ telegram = (
calendar_servers = calendars(env("CALENDAR_MCPS", ""))
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(
url=os.environ["KOMODO_URL"],
+474 -1
View File
@@ -1,9 +1,60 @@
"""Календари из `CALENDAR_MCPS=name=url,name=url`; в url - приватный фид с токеном."""
"""Календари: MCP-руки для модели и тот же ical-фид, читаемый gateway напрямую.
`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) вычитаются. Шаг считается по
настенным часам события, а не в абсолютном времени: еженедельная встреча
переживает перевод часов.
"""
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
if TYPE_CHECKING:
from collections.abc import Iterator
from datetime import tzinfo
__all__ = ["Calendars", "Event", "Feed", "Upcoming", "calendars", "feeds", "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]:
servers: list[HttpMcp] = []
@@ -17,3 +68,425 @@ def calendars(raw: str) -> list[HttpMcp]:
raise ValueError(msg)
servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip()))
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
@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
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()
else:
starts = occurrences(start, _rule(rrule.value), zone, end=end)
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))
return out
def _text(event: Vevent, name: str) -> str:
prop = _first(event, name)
return prop.value if prop is not None else ""
+60 -1
View File
@@ -3,7 +3,8 @@
Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать
сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого
человека, его записки о нём (`мета/бобер/наблюдения/<имя> - наблюдения.md`),
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок.
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и
ближайшая неделя календарей.
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны:
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
@@ -11,6 +12,7 @@ Gateway путей не знает; здесь - что искать по тек
from __future__ import annotations
import logging
import re
import time
from dataclasses import dataclass, field
@@ -19,14 +21,22 @@ from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from datetime import datetime
from pathlib import Path
from beaver_gateway.conversations.envelope import RecallContext
from beaver_gateway.conversations.texts import UserSaid
from beaver_agent.hands.calendars import Calendars, Event, Upcoming
_log = logging.getLogger(__name__)
LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):"
VOWELS = "аеёиоуыэюя"
KINDS = {"master": "мастер", "branch": "ветка"}
CALENDAR = "🗓 календарь"
CALENDAR_LIMIT = 15
CALENDAR_ERROR_MAX = 90
@dataclass(frozen=True, slots=True)
@@ -200,11 +210,14 @@ class Recall:
"""Сколько последних дней дневника просматривать на упоминания."""
mute: frozenset[str] = frozenset()
"""Агенты, которым справку не показывают: чужие люди, чужие уши."""
calendar: Calendars | None = None
"""Ical-фиды календарей; None - календари не настроены, секции нет вовсе."""
_people: People | None = field(default=None, init=False, repr=False)
_diary_cache: dict[str, tuple[float, list[str]]] = field(
default_factory=dict, 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
def people(self) -> People:
@@ -219,6 +232,7 @@ class Recall:
lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())]
if ctx.kind == "master":
lines += self.deadlines(today)
lines += self.calendar_lines(ctx.now, today)
if not lines:
return None
return "\n".join([LABEL, *lines])
@@ -302,6 +316,51 @@ class Recall:
found.sort()
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)
shown = found.events[:CALENDAR_LIMIT]
lines = [
f"{CALENDAR} ({days} дней), событий {len(found.events)}, данные на {stamp}:"
]
lines += [f" - {event_line(event, zone)}" for event in shown]
if len(found.events) > len(shown):
lines.append(f" … и ещё {len(found.events) - len(shown)} в окне")
return lines
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
class ReplyLog:
+2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
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.recall import Recall, ReplyLog
from beaver_agent.vault import (
@@ -54,6 +55,7 @@ recall = Recall(
boards_dir=BOARDS,
tz=TZ,
mute=frozenset({VOICE}),
calendar=CALENDARS,
)
"""Голосовому агенту справки нет: за колонкой может стоять кто угодно."""
+140
View File
@@ -0,0 +1,140 @@
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,
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_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: а б"
+91 -1
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from beaver_gateway.conversations.texts import UserSaid
from beaver_gateway.conversations.envelope import RecallContext
from beaver_agent.hands.calendars import Calendars, Feed
from beaver_agent.memory.recall import (
LABEL,
Recall,
@@ -22,7 +23,7 @@ def _card(path: Path, aliases: list[str] | None = None) -> None:
)
def _vault(tmp_path: Path) -> Recall:
def _vault(tmp_path: Path, calendar: Calendars | None = None) -> Recall:
people = tmp_path / "👤 люди"
_card(people / "личное" / "Зина.md", ["Зина"])
_card(people / "личное" / "Прохор.md", ["Прохор Тестов"])
@@ -68,6 +69,7 @@ def _vault(tmp_path: Path) -> Recall:
diary_dir=diary,
boards_dir=boards,
tz="Europe/Warsaw",
calendar=calendar,
)
@@ -186,3 +188,91 @@ def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None:
assert notes_head(recall.notes_dir / "нет.md", ("сейчас",)) is None
short = notes_head(path, ("сейчас", "паттерны"), cap=2)
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")
BUSY = _ics(
"SUMMARY:Созвон\nDTSTART:20260902T160000Z",
"SUMMARY:Analiza matematyczna II - Egzamin\nDTSTART;VALUE=DATE-TIME:20260905T110000\n"
"DESCRIPTION:Sala: 3\\nA23 - CW\\n\nLOCATION:ul. Piotrowo 2\\, Poznań",
"SUMMARY:Врач\nDTSTART:20260903T090000Z",
"SUMMARY:за окном\nDTSTART:20260925T080000Z",
"SUMMARY:завтрак\nDTSTART;TZID=Europe/Warsaw:20260902T080000\nRRULE:FREQ=DAILY",
"SUMMARY:ужин\nDTSTART;TZID=Europe/Warsaw:20260902T200000\nRRULE:FREQ=DAILY",
)
def test_calendar_section_sorts_the_week_and_says_what_did_not_fit(
tmp_path: Path,
) -> None:
recall = _vault(tmp_path, _calendar(BUSY))
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
block = recall.block(RecallContext(text="привет", kind="master", now=now))
assert block is not None
lines = [ln for ln in block.splitlines() if "календар" in ln or ln.startswith(" ")]
assert lines[0] == ("🗓 календарь (7 дней), событий 16, данные на 2026-09-01 14:00:")
assert lines[1:4] == [
" - 09-02 08:00 завтрак",
" - 09-02 18:00 Созвон",
" - 09-02 20:00 ужин",
]
assert (
" - 09-05 11:00 Analiza matematyczna II - Egzamin · Sala: 3, ul. Piotrowo 2, Poznań"
in lines
)
assert len([ln for ln in lines if ln.startswith(" - ")]) == 15
assert lines[-1] == " … и ещё 1 в окне"
# секция дневная, как и сроки досок: второй конверт за те же сутки её не несёт
again = recall.block(RecallContext(text="и ещё", kind="master", now=now))
assert again is None or "календарь" not in again
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