import os
import re
from html import escape
from datetime import datetime, time
from urllib.parse import parse_qs, urlparse
import aiohttp
import icalendar
import pytz
from dateutil.parser import isoparse, parse
from dateutil.rrule import rrulestr
ICAL_DIR = "../data/icals"
# только экспорт Яндекс.Календаря: произвольная ссылка позволила бы заставить бота ходить по любым
# адресам, в том числе внутренним адресам сервера
YANDEX_HOST = re.compile(r"^calendar\.yandex\.(ru|com|by|kz|uz|com\.tr)$")
def parse_ical_url(url):
"""Проверяет ссылку экспорта и возвращает часовой пояс из неё или None, если ссылка не подходит"""
parts = urlparse(url.strip())
if parts.scheme != "https" or not YANDEX_HOST.match(parts.hostname or ""):
return None
tz = parse_qs(parts.query).get("tz_id", ["Europe/Moscow"])[0]
return tz if tz in pytz.all_timezones_set else "Europe/Moscow"
async def download_ical(url, user_id):
"""Скачивает календарь, проверяет, что это iCal, и только потом заменяет старый файл"""
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, allow_redirects=False) as response:
response.raise_for_status()
data = await response.read()
icalendar.Calendar.from_ical(data) # бросит исключение, если пришёл не календарь
os.makedirs(ICAL_DIR, exist_ok=True)
path_new = f"{ICAL_DIR}/{user_id}_new.ics"
with open(path_new, "wb") as f:
f.write(data)
os.replace(path_new, f"{ICAL_DIR}/{user_id}.ics")
def text_ical(user_id, tz):
date = dt_now(tz)
path = f"{ICAL_DIR}/{user_id}.ics"
if not os.path.exists(path):
return []
with open(path, "rb") as e:
ecal = icalendar.Calendar.from_ical(e.read())
events = []
for i, component in enumerate(ecal.walk()):
if component.name == "VEVENT":
# НАЧАЛО
dt = icalendar.vDDDTypes.to_ical(component.get('dtstart')).decode('utf-8')
dt = dt.split("T")
dt_start = component.decoded("dtstart")
dt_end = component.decoded("dtend")
all_day = len(dt) == 1
if all_day:
dt_start = datetime.combine(dt_start, time(minute=1))
dt_end = datetime.combine(dt_end, time(minute=1))
else:
dt_start.replace(tzinfo=None)
dt_start = dt_start.astimezone(tz)
dt_end.replace(tzinfo=None)
dt_end = dt_end.astimezone(tz)
# НАСТРОЙКА
r_rule = component.get('rrule')
if r_rule:
list_rrule = icalendar.vRecur.to_ical(r_rule).decode('utf-8').split(';')
until, u_flag = [elem for elem in list_rrule if 'UNTIL' in elem], True
list_rrule = [elem for elem in list_rrule if 'UNTIL' not in elem]
if until:
until_old = until[0].split('=')[1]
until = isoparse(until_old).replace(tzinfo=None)
if until.replace(tzinfo=None) <= date.replace(tzinfo=None):
until, u_flag = "UNTIL=" + until_utc(until_old, tz), False
if u_flag:
until = "UNTIL=" + "".join(date.date().isoformat().split("-")) + "T235900Z"
list_rrule.append(until)
list_rrule = ";".join(list_rrule)
date_iso = "".join(dt_start.isoformat().split("-"))
iso = "".join(date_iso.split(":")).split("+")[0] + "Z"
dates = list(map(lambda x: x.replace(tzinfo=None), list(rrulestr(list_rrule, dtstart=parse(iso)))))
if dates:
dif = dt_end - dt_start
dt_start = dates[-1]
dt_end = dt_start + dif
if date.date() == dt_start.date():
org = component.get("organizer")
desc = component.get("description")
event = {"name": component.get('summary'), "desc": str(desc).strip() if desc else "",
"org": str(org).removeprefix("mailto:") if org else "", "all_day": all_day,
"datetime": [dt_start, dt_end]}
events.append([dt_start.date(), dt_start.time(), i, event])
return events
def until_utc(value, tz):
"""UNTIL в UTC: dateutil требует его, когда начало события задано с часовым поясом,
а Яндекс иногда отдаёт UNTIL местным временем или просто датой"""
if value.endswith("Z"):
return value
until = isoparse(value)
if "T" not in value: # только дата — повторения включительно по этот день
until = until.replace(hour=23, minute=59, second=59)
if until.tzinfo is None:
until = tz.localize(until)
return until.astimezone(pytz.utc).strftime("%Y%m%dT%H%M%SZ")
def message_form(k, event):
txt = ""
if k:
txt += "-------\n"
txt += f"{k}. {escape(str(event['name']))}\n"
else:
txt += f"{escape(str(event['name']))}\n"
# пустые описание и организатора не показываем
if event["desc"]:
txt += f"Описание: {escape(event['desc'])}\n"
if event["org"]:
txt += f"Организатор: {escape(event['org'])}\n"
# события всегда сегодняшние, поэтому дата не нужна — только время
start, end = event["datetime"]
if event["all_day"]:
txt += "\nВесь день\n"
elif end.date() != start.date():
txt += f"\n{start.strftime('%H:%M')} — {end.strftime('%d.%m %H:%M')}\n"
else:
txt += f"\n{start.strftime('%H:%M')} — {end.strftime('%H:%M')}\n"
return txt
def delta_time(d_event, start, end):
d_start = datetime.combine(d_event.date(), time(hour=0, minute=start))
d_end = datetime.combine(d_event.date(), time(hour=0, minute=end))
zero = datetime.combine(d_event.date(), time(0, 0, 0, 0))
dt_start = zero + (d_event - d_start)
dt_end = zero + (d_event - d_end)
return [dt_start.time(), dt_end.time()]
def dt_now(tz):
return datetime.now(tz=tz)