diff --git a/README.en.md b/README.en.md
index e2b089e..2940791 100644
--- a/README.en.md
+++ b/README.en.md
@@ -21,6 +21,7 @@ bot speaks Russian.
- A day summary at 8:00 in the calendar's time zone (`/daily`)
- Two reminders before an event: 15 and 5 minutes by default, changed with `/edit_alarm`
- A message when an event starts (`/moment`)
+- A notification when an event is added, or an upcoming one is deleted or moved (`/changes`)
- The calendar is refreshed every 13 minutes; if a refresh fails, the previous version stays
## Commands
@@ -31,6 +32,7 @@ bot speaks Russian.
| `/list` | today's events |
| `/daily` | toggle the morning summary |
| `/moment` | toggle the message at event start |
+| `/changes` | toggle notifications about added, deleted and moved events |
| `/get_alarm`, `/edit_alarm` | view and change reminder times |
| `/stop_alarm` | toggle the second reminder |
| `/notif` | pause or resume all notifications |
@@ -55,6 +57,7 @@ Without Docker: Python 3.10+, PostgreSQL, `pip install -r requirements.txt`, the
```
code/bot.py entry point and two background loops: event checks every minute, calendar refresh
+code/changes.py compares the old and new download: added, deleted and moved events
code/handlers.py commands and receiving the link
code/script.py link validation, download, iCal and recurrence parsing, notification text
code/sql.py users, subscriptions and reminder settings (PostgreSQL)
@@ -72,7 +75,7 @@ ruff check --select E9,F code tests && pytest
```
The tests (storage on PostgreSQL from `TEST_DATABASE_URL`) cover calendar parsing: today's events, recurrences, `UNTIL` in different formats, link
-validation and the notification text. The Docker image is built on `v*` tags and published to GitHub
+validation, the notification text and change detection between downloads. The Docker image is built on `v*` tags and published to GitHub
Packages and `git.deev.su`.
## License
diff --git a/README.md b/README.md
index 11882b2..ae0f125 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ Telegram-бот, который присылает уведомления о с
- Сводка на день в 8:00 по часовому поясу календаря (`/daily`)
- Два напоминания перед событием: по умолчанию за 15 и 5 минут, меняются в `/edit_alarm`
- Сообщение в момент начала события (`/moment`)
+- Уведомление, если в календаре появилось новое событие, а предстоящее удалили или перенесли (`/changes`)
- Календарь обновляется раз в 13 минут; если обновление не удалось, остаётся прошлая версия
Пример уведомления:
@@ -42,6 +43,7 @@ Telegram-бот, который присылает уведомления о с
| `/list` | события на сегодня |
| `/daily` | включить или выключить утреннюю сводку |
| `/moment` | включить или выключить сообщение в момент начала |
+| `/changes` | включить или выключить уведомления о новых, удалённых и перенесённых событиях |
| `/get_alarm`, `/edit_alarm` | посмотреть и изменить время напоминаний |
| `/stop_alarm` | включить или выключить второе напоминание |
| `/notif` | приостановить или возобновить все уведомления |
@@ -66,6 +68,7 @@ docker compose up -d
```
code/bot.py запуск и два фоновых цикла: проверка событий раз в минуту, обновление календарей
+code/changes.py сравнение старой и новой выгрузки: новые, удалённые и перенесённые события
code/handlers.py команды и приём ссылки
code/script.py проверка ссылки, скачивание, разбор iCal и повторений, текст уведомления
code/sql.py пользователи, подписки и настройки напоминаний (PostgreSQL)
@@ -84,7 +87,7 @@ ruff check --select E9,F code tests && pytest
```
Тесты (хранилище — на PostgreSQL из `TEST_DATABASE_URL`) проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку
-ссылки и текст уведомления. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и
+ссылки, текст уведомления и поиск изменений между выгрузками. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и
`git.deev.su`.
## Лицензия
diff --git a/code/bot.py b/code/bot.py
index 31a328c..ba270c5 100644
--- a/code/bot.py
+++ b/code/bot.py
@@ -3,7 +3,8 @@ import logging
from datetime import datetime, time
from init import bot, dp, du, dc
-from script import delta_time, download_ical, dt_now, message_form, text_ical
+import changes
+from script import delta_time, download_ical, dt_now, message_form, read_ical, text_ical
from handlers import router
@@ -87,11 +88,22 @@ async def update(wait_for):
for (user_id,) in du.all_users():
try:
- if du.url_exists(user_id) and du.get_status(user_id):
- await download_ical(du.get_url(user_id), user_id)
+ if not (du.url_exists(user_id) and du.get_status(user_id)):
+ continue
+ old = read_ical(user_id)
+ new = await download_ical(du.get_url(user_id), user_id)
except Exception as err:
# при сбое остаётся прошлая версия календаря
logging.warning("Не удалось обновить календарь пользователя %s: %s", user_id, err)
+ continue
+
+ try:
+ if old and old != new and dc.clock_exists(user_id) and dc.get_changes(user_id):
+ text = changes.report(old, new, du.get_tz(user_id))
+ if text:
+ await send(str(du.get_first_user_id(user_id)), text)
+ except Exception:
+ logging.exception("Ошибка уведомления об изменениях пользователя %s", user_id)
async def main() -> None:
diff --git a/code/changes.py b/code/changes.py
new file mode 100644
index 0000000..bae6dbe
--- /dev/null
+++ b/code/changes.py
@@ -0,0 +1,165 @@
+"""Уведомления об изменениях в календаре: новые, удалённые и перенесённые события.
+
+При каждом обновлении календаря старая и новая выгрузки сравниваются по UID события.
+Отдельное вхождение повторяющегося события Яндекс выгружает так: перенос — отдельным VEVENT
+с тем же UID и RECURRENCE-ID (исходное время), удаление — датой в EXDATE основного события.
+Яндекс отдаёт всю историю календаря, поэтому прошедшие события не учитываются."""
+from datetime import date, datetime, timedelta
+from html import escape
+
+import icalendar
+import pytz
+
+MAX_ITEMS = 10
+WEEKDAYS = ("пн", "вт", "ср", "чт", "пт", "сб", "вс")
+
+
+def _norm(value, tz):
+ """Дата остаётся датой, время приводится к часовому поясу календаря"""
+ if isinstance(value, datetime):
+ if value.tzinfo is None: # «плавающее» время — считаем временем календаря
+ value = tz.localize(value)
+ return value.astimezone(tz)
+ return value
+
+
+def _exdates(component, tz):
+ raw = component.get("exdate")
+ if raw is None:
+ return set()
+ lists = raw if isinstance(raw, list) else [raw]
+ return {_norm(item.dt, tz) for lst in lists for item in lst.dts}
+
+
+def snapshot(data, tz):
+ """События выгрузки: {(UID, исходное время вхождения или None): описание}"""
+ events = {}
+ for component in icalendar.Calendar.from_ical(data).walk("VEVENT"):
+ if component.get("dtstart") is None:
+ continue
+ start = _norm(component.decoded("dtstart"), tz)
+ if component.get("dtend") is not None:
+ end = _norm(component.decoded("dtend"), tz)
+ elif component.get("duration") is not None:
+ end = start + component.decoded("duration")
+ else:
+ end = start + timedelta(days=1) if not isinstance(start, datetime) else start
+
+ rrule = component.get("rrule")
+ until = rrule.get("UNTIL") if rrule else None
+ rid = _norm(component.decoded("recurrence-id"), tz) if component.get("recurrence-id") is not None else None
+
+ events[(str(component.get("uid", "")), rid)] = {
+ "name": str(component.get("summary", "")).strip() or "Без названия",
+ "start": start, "end": end, "all_day": not isinstance(start, datetime),
+ "recurring": rrule is not None, "until": _norm(until[0], tz) if until else None,
+ "exdates": _exdates(component, tz),
+ }
+ return events
+
+
+def _after(value, now):
+ """Момент value ещё не наступил (для дат — день не закончился)"""
+ if isinstance(value, datetime):
+ return value > now
+ return value >= now.date()
+
+
+def _upcoming(event, now):
+ if event["recurring"]:
+ return event["until"] is None or _after(event["until"], now)
+ end = event["end"]
+ if event["all_day"] and isinstance(end, date) and not isinstance(end, datetime):
+ end -= timedelta(days=1) # DTEND у событий на весь день — следующий день
+ return _after(end, now)
+
+
+def diff(old, new, now):
+ """Список изменений: (вид, событие, прежнее событие или None); вид — added, deleted, moved"""
+ changes = []
+
+ for key, event in new.items():
+ uid, rid = key
+ before = old.get(key)
+ if before is not None:
+ moved = (before["start"], before["end"]) != (event["start"], event["end"])
+ if moved and (_upcoming(before, now) or _upcoming(event, now)):
+ changes.append(("moved", event, before))
+ elif event["recurring"]:
+ # удалённые вхождения повторяющегося события
+ for day in sorted(event["exdates"] - before["exdates"], key=str):
+ if (uid, day) not in new and (uid, day) not in old and _after(day, now):
+ changes.append(("deleted", _occurrence(event, day), None))
+ continue
+
+ if rid is not None and (uid, None) in old:
+ # вхождение повторяющегося события изменили отдельно — важно, только если сдвинули время
+ if event["start"] != rid and (_after(rid, now) or _after(event["start"], now)):
+ changes.append(("moved", event, _occurrence(old[(uid, None)], rid)))
+ elif _upcoming(event, now):
+ changes.append(("added", event, None))
+
+ for key, event in old.items():
+ if key in new:
+ continue
+ uid, rid = key
+ if rid is not None and (uid, None) in new:
+ master = new[(uid, None)]
+ if rid in master["exdates"]:
+ if _after(event["start"], now):
+ changes.append(("deleted", event, None))
+ elif event["start"] != rid and (_after(rid, now) or _after(event["start"], now)):
+ changes.append(("moved", _occurrence(master, rid), event)) # вернули на исходное время
+ elif _upcoming(event, now):
+ changes.append(("deleted", event, None))
+
+ changes.sort(key=lambda c: _sort_key(c[1]["start"]))
+ return changes
+
+
+def _occurrence(master, start):
+ """Отдельное вхождение повторяющегося события, начинающееся в start"""
+ return {**master, "start": start, "end": start + (master["end"] - master["start"]),
+ "recurring": False, "exdates": set()}
+
+
+def _sort_key(value):
+ if isinstance(value, datetime):
+ return value.astimezone(pytz.utc).replace(tzinfo=None)
+ return datetime.combine(value, datetime.min.time())
+
+
+def when(event):
+ """Когда событие: «чт 08.10 10:00 — 11:00», «чт 08.10, весь день»"""
+ start, end = event["start"], event["end"]
+ day = f"{WEEKDAYS[start.weekday()]} {start:%d.%m}"
+ if event["all_day"]:
+ last = end - timedelta(days=1) if end > start else start
+ text = f"{day}, весь день" if last == start else f"{day} — {WEEKDAYS[last.weekday()]} {last:%d.%m}, весь день"
+ elif end.date() != start.date():
+ text = f"{day} {start:%H:%M} — {WEEKDAYS[end.weekday()]} {end:%d.%m %H:%M}"
+ else:
+ text = f"{day} {start:%H:%M} — {end:%H:%M}"
+ return text + (", повторяется" if event["recurring"] else "")
+
+
+def report(old_data, new_data, tz, now=None):
+ """Текст уведомления об изменениях между двумя выгрузками или None, если важных изменений нет"""
+ now = now or datetime.now(tz)
+ changes = diff(snapshot(old_data, tz), snapshot(new_data, tz), now)
+ if not changes:
+ return None
+
+ parts = []
+ for kind, event, before in changes[:MAX_ITEMS]:
+ name = escape(event["name"])
+ if kind == "added":
+ parts.append(f"Новое событие: {name}\n{when(event)}")
+ elif kind == "deleted":
+ parts.append(f"Удалено: {name}\n{when(event)}")
+ else:
+ parts.append(f"Перенесено: {name}\nбыло: {when(before)}\nстало: {when(event)}")
+ if len(changes) > MAX_ITEMS:
+ parts.append(f"…и ещё {len(changes) - MAX_ITEMS}")
+
+ return "Изменения в календаре\n\n" + "\n\n".join(parts)
diff --git a/code/handlers.py b/code/handlers.py
index d5abe4a..60fb73e 100644
--- a/code/handlers.py
+++ b/code/handlers.py
@@ -58,7 +58,8 @@ async def commands(call: CallbackQuery) -> None:
'/list - список событий календаря, запланированных на сегодняшний день\n'
'/notif - команда, отключающая рассылку уведомлений, даже при наличии событий в календаре\n'
'/daily - оповещение в 8 утра по вашему часовому поясу со списком событий на день\n'
- '/moment - напоминание, приходящее в момент начала события\n\n'
+ '/moment - напоминание, приходящее в момент начала события\n'
+ '/changes - уведомления о новых, удалённых и перенесённых событиях\n\n'
'/get_alarm - информация о времени на которое настроены оповещения\n'
'/edit_alarm - изменение времени оповещений\n'
'/stop_alarm - команда, отключающая второе оповещение о событии\n\n'
@@ -147,6 +148,18 @@ async def moment_up(msg: Message) -> None:
else: await msg.answer(NO_LINK)
+@router.message(Command('changes'))
+async def changes_up(msg: Message) -> None:
+ user_id = user_id_of(msg)
+
+ if dc.clock_exists(user_id):
+ if dc.get_changes(user_id): await msg.answer("Уведомления об изменениях в календаре выключены!")
+ else: await msg.answer("Уведомления об изменениях в календаре включены!")
+
+ dc.update_changes(user_id)
+ else: await msg.answer(NO_LINK)
+
+
@router.message(Command('get_alarm'))
async def alarm_get(msg: Message) -> None:
user_id = user_id_of(msg)
diff --git a/code/script.py b/code/script.py
index 8b5111b..4c19132 100644
--- a/code/script.py
+++ b/code/script.py
@@ -28,7 +28,7 @@ def parse_ical_url(url):
async def download_ical(url, user_id):
- """Скачивает календарь, проверяет, что это iCal, и только потом заменяет старый файл"""
+ """Скачивает календарь, проверяет, что это iCal, и только потом заменяет старый файл; возвращает содержимое"""
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, allow_redirects=False) as response:
@@ -42,6 +42,16 @@ async def download_ical(url, user_id):
with open(path_new, "wb") as f:
f.write(data)
os.replace(path_new, f"{ICAL_DIR}/{user_id}.ics")
+ return data
+
+
+def read_ical(user_id):
+ """Сохранённая выгрузка календаря или None"""
+ try:
+ with open(f"{ICAL_DIR}/{user_id}.ics", "rb") as f:
+ return f.read()
+ except FileNotFoundError:
+ return None
def text_ical(user_id, tz):
diff --git a/code/sql.py b/code/sql.py
index 4cf208c..eed0f1c 100644
--- a/code/sql.py
+++ b/code/sql.py
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS alarms (
alarm_2 INTEGER,
status_2 BOOLEAN NOT NULL DEFAULT TRUE
);
+ALTER TABLE alarms ADD COLUMN IF NOT EXISTS changes BOOLEAN NOT NULL DEFAULT TRUE;
"""
@@ -132,6 +133,10 @@ class Clock(_Base):
def get_status2(self, user_id):
return self._one("SELECT status_2 FROM alarms WHERE user_id = %s", (user_id,))
+ def get_changes(self, user_id):
+ """Присылать ли уведомления о новых, удалённых и перенесённых событиях"""
+ return self._one("SELECT changes FROM alarms WHERE user_id = %s", (user_id,))
+
# ПЕРЕКЛЮЧАТЕЛИ
def update_daily(self, user_id):
self._run("UPDATE alarms SET daily = NOT daily WHERE user_id = %s", (user_id,))
@@ -147,3 +152,6 @@ class Clock(_Base):
def update_status2(self, user_id):
self._run("UPDATE alarms SET status_2 = NOT status_2 WHERE user_id = %s", (user_id,))
+
+ def update_changes(self, user_id):
+ self._run("UPDATE alarms SET changes = NOT changes WHERE user_id = %s", (user_id,))
diff --git a/tests/test_changes.py b/tests/test_changes.py
new file mode 100644
index 0000000..af5e5bf
--- /dev/null
+++ b/tests/test_changes.py
@@ -0,0 +1,126 @@
+from datetime import datetime, timedelta
+
+import pytz
+
+import changes
+
+TZ = pytz.timezone("Europe/Moscow")
+NOW = TZ.localize(datetime(2026, 10, 7, 12, 0)) # среда
+
+
+def ics(*events):
+ body = "\r\n".join(events)
+ return ("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n" + body + "\r\nEND:VCALENDAR\r\n").encode()
+
+
+def vevent(uid, start, minutes=60, summary="Встреча", extra=""):
+ end = start + timedelta(minutes=minutes)
+ return (f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261007T090000Z\r\n"
+ f"DTSTART;TZID=Europe/Moscow:{start:%Y%m%dT%H%M%S}\r\nDTEND;TZID=Europe/Moscow:{end:%Y%m%dT%H%M%S}\r\n"
+ f"SUMMARY:{summary}\r\n{extra}END:VEVENT")
+
+
+def at(day, hour, minute=0):
+ return datetime(2026, 10, day, hour, minute)
+
+
+def report(old, new):
+ return changes.report(ics(*old), ics(*new), TZ, now=NOW)
+
+
+def test_no_changes_and_dtstamp_ignored():
+ event = vevent("a", at(8, 10))
+ assert report([event], [event.replace("DTSTAMP:20261007T090000Z", "DTSTAMP:20261007T091300Z")]) is None
+
+
+def test_renamed_event_is_not_reported():
+ assert report([vevent("a", at(8, 10), summary="Старое")], [vevent("a", at(8, 10), summary="Новое")]) is None
+
+
+def test_added_event():
+ text = report([], [vevent("a", at(8, 10), summary="Созвон <важный> & срочный")])
+ assert text.startswith("Изменения в календаре")
+ assert "Новое событие: Созвон <важный> & срочный" in text
+ assert "чт 08.10 10:00 — 11:00" in text
+
+
+def test_past_events_are_ignored():
+ # Яндекс выгружает всю историю: правка старого события не должна приходить уведомлением
+ assert report([], [vevent("old", at(1, 10))]) is None
+ assert report([vevent("old", at(1, 10))], []) is None
+ assert report([vevent("old", at(1, 10))], [vevent("old", at(2, 10))]) is None
+
+
+def test_event_in_progress_counts_as_upcoming():
+ assert "Удалено" in report([vevent("now", at(7, 11, 30))], [])
+
+
+def test_deleted_event():
+ text = report([vevent("a", at(9, 15), summary="Планёрка")], [])
+ assert "Удалено: Планёрка\nпт 09.10 15:00 — 16:00" in text
+
+
+def test_moved_event():
+ text = report([vevent("a", at(8, 10), summary="Планёрка")], [vevent("a", at(9, 14, 30), summary="Планёрка")])
+ assert "Перенесено: Планёрка\nбыло: чт 08.10 10:00 — 11:00\nстало: пт 09.10 14:30 — 15:30" in text
+
+
+def test_moved_from_past_to_future():
+ assert "Перенесено" in report([vevent("a", at(6, 10))], [vevent("a", at(12, 10))])
+
+
+def test_all_day_event():
+ event = ("BEGIN:VEVENT\r\nUID:day\r\nDTSTART;VALUE=DATE:20261010\r\nDTEND;VALUE=DATE:20261011\r\n"
+ "SUMMARY:Выходной\r\nEND:VEVENT")
+ assert "сб 10.10, весь день" in report([], [event])
+
+ today = event.replace("20261010", "20261007").replace("20261011", "20261008")
+ assert "ср 07.10, весь день" in report([today], []) # день ещё не закончился
+
+
+def test_recurring_occurrence_deleted():
+ weekly = vevent("w", at(1, 9), summary="Стендап", extra="RRULE:FREQ=WEEKLY\r\n")
+ without = weekly.replace("RRULE:FREQ=WEEKLY\r\n", "RRULE:FREQ=WEEKLY\r\nEXDATE;TZID=Europe/Moscow:20261015T090000\r\n")
+ text = report([weekly], [without])
+ assert "Удалено: Стендап\nчт 15.10 09:00 — 10:00" in text
+ assert "повторяется" not in text
+
+
+def test_recurring_occurrence_moved_and_moved_back():
+ weekly = vevent("w", at(1, 9), summary="Стендап", extra="RRULE:FREQ=WEEKLY\r\n")
+ override = vevent("w", at(15, 11), summary="Стендап", extra="RECURRENCE-ID;TZID=Europe/Moscow:20261015T090000\r\n")
+
+ text = report([weekly], [weekly, override])
+ assert "было: чт 15.10 09:00 — 10:00\nстало: чт 15.10 11:00 — 12:00" in text
+
+ text = report([weekly, override], [weekly])
+ assert "было: чт 15.10 11:00 — 12:00\nстало: чт 15.10 09:00 — 10:00" in text
+
+
+def test_recurring_override_deleted():
+ override = vevent("w", at(15, 11), extra="RECURRENCE-ID;TZID=Europe/Moscow:20261015T090000\r\n")
+ weekly = vevent("w", at(1, 9), extra="RRULE:FREQ=WEEKLY\r\n")
+ without = weekly.replace("RRULE:FREQ=WEEKLY\r\n", "RRULE:FREQ=WEEKLY\r\nEXDATE;TZID=Europe/Moscow:20261015T090000\r\n")
+ text = report([weekly, override], [without])
+ assert text.count("Удалено") == 1 and "чт 15.10 11:00 — 12:00" in text
+
+
+def test_recurring_series():
+ weekly = vevent("w", at(1, 9), summary="Стендап", extra="RRULE:FREQ=WEEKLY\r\n")
+ assert "чт 01.10 09:00 — 10:00, повторяется" in report([], [weekly])
+ assert "Удалено" in report([weekly], [])
+
+ ended = vevent("e", at(1, 9), extra="RRULE:FREQ=WEEKLY;UNTIL=20261005T060000Z\r\n")
+ assert report([ended], []) is None
+
+
+def test_many_changes_are_capped():
+ text = report([], [vevent(f"e{i}", at(10, 8) + timedelta(hours=i)) for i in range(13)])
+ assert text.count("Новое событие") == changes.MAX_ITEMS
+ assert text.endswith("…и ещё 3")
+ assert text.index("08:00") < text.index("09:00") # по времени события
+
+
+def test_floating_time_uses_calendar_zone():
+ event = "BEGIN:VEVENT\r\nUID:f\r\nDTSTART:20261008T100000\r\nDTEND:20261008T110000\r\nSUMMARY:X\r\nEND:VEVENT"
+ assert "чт 08.10 10:00 — 11:00" in report([], [event])
diff --git a/tests/test_sql.py b/tests/test_sql.py
index 7aeafc4..9fd4dda 100644
--- a/tests/test_sql.py
+++ b/tests/test_sql.py
@@ -37,6 +37,9 @@ def test_users_and_alarms(pool):
dc.update_alarm1(uid, 30)
dc.update_daily(uid)
assert dc.get_alarm(uid) == (30, 5) and dc.get_daily(uid) is True
+ assert dc.get_changes(uid) is True
+ dc.update_changes(uid)
+ assert dc.get_changes(uid) is False
du.delete_url(uid)
assert not du.url_exists(uid)
assert du.all_users() == [(uid,)]
@@ -65,5 +68,6 @@ def test_migration(pool, tmp_path):
du, dc = Users(pool), Clock(pool)
assert du.get_user_id(555) == 5 # внутренний номер сохранён — на него ссылаются файлы календарей
assert dc.get_alarm(5) == (20, 10) and dc.get_daily(5) is True and dc.get_start(5) is False
+ assert dc.get_changes(5) is True # новая настройка включена и у перенесённых пользователей
du.add_user(777)
assert du.get_user_id(777) == 6