1
0
Fork 0
mirror of https://github.com/EDeev/y.calendarkin.git synced 2026-10-08 00:39:27 +03:00

Исправлены ошибки и уязвимость, улучшены уведомления

Ошибки:
- исключение в фоновых циклах (сбой сети при обновлении календаря, заблокировавший бота
  пользователь, битый календарь) останавливало весь бот — теперь ошибка у одного
  пользователя логируется и не мешает остальным;
- повторяющиеся события с UNTIL местным временем или датой роняли разбор календаря
  (ValueError из dateutil) — UNTIL приводится к UTC;
- названия и описания событий подставлялись в HTML без экранирования: «<» или «&» ломали
  отправку уведомления;
- сообщение до /start и кривой ввод времени напоминаний роняли обработчик;
- при отсутствии файла календаря разбор падал.

Безопасность: ссылка скачивалась без проверки (wget), бота можно было заставить ходить по
любым адресам, включая внутренние. Теперь принимается только https://calendar.yandex.*,
скачивание — через aiohttp с таймаутом и без редиректов, содержимое проверяется как iCal
до замены старого файла.

Уведомления (по issue #3): без даты и года — только время, пустые описание и организатор
не показываются, организатор без «mailto:», для событий на весь день — «Весь день» и без
напоминаний в 00:01; новые команды /link и /unsubscribe.

Прочее: токен из BOT_TOKEN, таблицы SQLite создаются при первом запуске (пустые базы
убраны из репозитория), ссылка «АВТОР» — на EDeev вместо чужого теперь профиля IGlek.
This commit is contained in:
Деев Егор Викторович 2026-10-05 19:28:43 +00:00
parent 18e79e24a5
commit 15bd36580e
10 changed files with 348 additions and 208 deletions

View file

@ -1,22 +1,18 @@
import asyncio, logging, time, wget, os import asyncio
import logging
from datetime import datetime, time
from init import * from init import bot, dp, du, dc
from script import * from script import delta_time, download_ical, dt_now, message_form, text_ical
from handlers import router from handlers import router
# ПРОВЕРКА НА СОБЫТИЕ async def send(tg_id, text):
async def alarm(wait_for): await bot.send_message(chat_id=tg_id, disable_web_page_preview=True, text=text)
while True:
await asyncio.sleep(wait_for)
users_id = du.all_users()
for user_id in users_id:
user_id = user_id[0]
if dc.clock_exists(user_id): async def check_user(user_id):
if du.get_status(user_id): events = sorted(text_ical(user_id, du.get_tz(user_id)), key=lambda e: (e[0], e[1], e[2]))
events = sorted(text_ical(user_id, du.get_tz(user_id)))
tg_id = str(du.get_first_user_id(user_id)) tg_id = str(du.get_first_user_id(user_id))
start = dc.get_start(user_id) start = dc.get_start(user_id)
@ -44,51 +40,64 @@ async def alarm(wait_for):
counter += 1 counter += 1
txt += message_form(counter, event[3]) txt += message_form(counter, event[3])
# для событий на весь день напоминания по времени не шлём
if event[3]["all_day"]:
continue
delta_start = delta_time(d_event, 1, 0) delta_start = delta_time(d_event, 1, 0)
if start and delta_start[0] < time_check <= delta_start[1]: if start and delta_start[0] < time_check <= delta_start[1]:
await bot.send_message(chat_id=tg_id, disable_web_page_preview=True, await send(tg_id, "<b>Событие начинается!</b>\n\n" + message_form(0, event[3]))
text=f"<b>Событие начинается!</b>\n\n" + message_form(0, event[3]))
delta_alarm1 = delta_time(d_event, alarm[0], alarm[0] - 1) delta_alarm1 = delta_time(d_event, alarm[0], alarm[0] - 1)
if delta_alarm1[0] < time_check <= delta_alarm1[1]: if delta_alarm1[0] < time_check <= delta_alarm1[1]:
await bot.send_message(chat_id=tg_id, disable_web_page_preview=True, await send(tg_id, f"<b>Напоминаю!</b>\n<i>Через {alarm[0]} минут "
text=f"<b>Напоминаю!</b>\n<i>Через {alarm[0]} минут "
f"будет событие:</i>\n\n{message_form(0, event[3])}") f"будет событие:</i>\n\n{message_form(0, event[3])}")
if alarm2_status: if alarm2_status:
delta_alarm2 = delta_time(d_event, alarm[1], alarm[1] - 1) delta_alarm2 = delta_time(d_event, alarm[1], alarm[1] - 1)
if delta_alarm2[0] < time_check <= delta_alarm2[1]: if delta_alarm2[0] < time_check <= delta_alarm2[1]:
await bot.send_message(chat_id=tg_id, disable_web_page_preview=True, await send(tg_id, f"<b>Напоминаю!</b>\n<i>Через {alarm[1]} минут "
text=f"<b>Напоминаю!</b>\n<i>Через {alarm[1]} минут "
f"будет событие:</i>\n\n{message_form(0, event[3])}") f"будет событие:</i>\n\n{message_form(0, event[3])}")
if daily and delta_daily1 <= time_check < delta_daily2: if daily and delta_daily1 <= time_check < delta_daily2:
if counter: if counter:
await bot.send_message(chat_id=tg_id, disable_web_page_preview=True, text=txt) await send(tg_id, txt)
else: else:
await bot.send_message(chat_id=tg_id, disable_web_page_preview=True, text=f"Сегодня событий <b>нет</b>") await send(tg_id, "Сегодня событий <b>нет</b>")
# ПРОВЕРКА НА СОБЫТИЕ
async def alarm(wait_for):
while True:
await asyncio.sleep(wait_for)
for (user_id,) in du.all_users():
# ошибка у одного пользователя (заблокировал бота, битый календарь) не должна
# останавливать рассылку остальным и весь бот
try:
if dc.clock_exists(user_id) and du.get_status(user_id):
await check_user(user_id)
except Exception:
logging.exception("Ошибка проверки событий пользователя %s", user_id)
async def update(wait_for): async def update(wait_for):
while True: while True:
await asyncio.sleep(wait_for) await asyncio.sleep(wait_for)
users_id = du.all_users() for (user_id,) in du.all_users():
for user_id in users_id: try:
user_id = user_id[0] if du.url_exists(user_id) and du.get_status(user_id):
await download_ical(du.get_url(user_id), user_id)
if du.url_exists(user_id): except Exception as err:
if du.get_status(user_id): # при сбое остаётся прошлая версия календаря
wget.download(du.get_url(user_id), f'../data/icals/{str(user_id)}_new.ics') logging.warning("Не удалось обновить календарь пользователя %s: %s", user_id, err)
os.remove(f'../data/icals/{str(user_id)}.ics')
os.rename(f'../data/icals/{str(user_id)}_new.ics', f'../data/icals/{str(user_id)}.ics')
async def main() -> None: async def main() -> None:
dp.include_router(router) dp.include_router(router)
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types(), skipUpdates=True) await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
async def process() -> None: async def process() -> None:

View file

@ -1 +1,3 @@
TOKEN = "**********:***************************" import os
botToken = os.getenv("BOT_TOKEN", "XXXXXXXXXXXXXXXXXXXXXXXX") # @calendarkin_ybot

View file

@ -1,31 +1,44 @@
import logging
import os
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message, CallbackQuery, ContentType, InlineKeyboardMarkup, InlineKeyboardButton, FSInputFile from aiogram.types import Message, CallbackQuery, ContentType, InlineKeyboardMarkup, InlineKeyboardButton, FSInputFile
from aiogram.filters import Command from aiogram.filters import Command
import wget, os from init import du, dc
from script import ICAL_DIR, download_ical, dt_now, message_form, parse_ical_url, text_ical
from init import *
from script import *
router = Router() router = Router()
NO_LINK = "Для взаимодействия с событиями вы должны прислать ical-ссылку на календарь! Как её получить — /link"
EDIT_ALARM_TEXT = ("Для изменения времени вам надо в ответ на это сообщение прислать два числа через пробел: "
"разница времени первого и второго таймера по ходу времени соответственно")
def user_id_of(msg: Message) -> int:
"""Короткий id пользователя; заводит запись, если человек пишет боту, не нажав /start"""
tg_id = int(msg.chat.id)
if not du.user_exists(tg_id):
du.add_user(tg_id)
return du.get_user_id(tg_id)
# ПРИВЕТСТВЕННОЕ СООБЩЕНИЕ # ПРИВЕТСТВЕННОЕ СООБЩЕНИЕ
@router.message(Command('start', 'help')) @router.message(Command('start', 'help'))
async def helps(msg: Message) -> None: async def helps(msg: Message) -> None:
tg_id = int(msg.chat.id) user_id_of(msg)
if not du.user_exists(tg_id):
du.add_user(tg_id)
buttons = [[InlineKeyboardButton(text="КОМАНДЫ", callback_data="com"), buttons = [[InlineKeyboardButton(text="КОМАНДЫ", callback_data="com"),
InlineKeyboardButton(text="АВТОР", callback_data="auth")]] InlineKeyboardButton(text="АВТОР", callback_data="auth")]]
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons, row_width=2) keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await msg.answer(text="<b>Я.Календаркин</b> - бот для оповещения о событиях из <b>Яндекс.Календаря</b>. " await msg.answer(text="<b>Я.Календаркин</b> - бот для оповещения о событиях из <b>Яндекс.Календаря</b>. "
"Для начала работы вам нужно всего лишь прислать в чат ссылку экспорта календаря в " "Для начала работы вам нужно всего лишь прислать в чат ссылку экспорта календаря в "
"<b>формате ICal</b>. После получения ссылки, бот начнёт оповещать о всех новых событиях " "<b>формате ICal</b> (как её получить — /link). После получения ссылки, бот начнёт "
"и появится возможность настройки оповещений. О том, какие команды есть для настройки, " "оповещать о всех новых событиях и появится возможность настройки оповещений. О том, "
"вы можете ознакомиться по кнопке <b>КОМАНДЫ</b>", reply_markup=keyboard) "какие команды есть для настройки, вы можете ознакомиться по кнопке <b>КОМАНДЫ</b>",
reply_markup=keyboard)
@router.callback_query(F.data == "auth") @router.callback_query(F.data == "auth")
@ -34,31 +47,57 @@ async def author(call: CallbackQuery) -> None:
'получения уведомлений о событиях в Яндекс.Календаре. Не многим этот бот будет ' 'получения уведомлений о событиях в Яндекс.Календаре. Не многим этот бот будет '
'полезен, но людям, чья работа подразумевает его использование, он станет лишь ' 'полезен, но людям, чья работа подразумевает его использование, он станет лишь '
'удобным инструментом. Я же пишу подобные небольшие проекты, о которых вы можете ' 'удобным инструментом. Я же пишу подобные небольшие проекты, о которых вы можете '
'узнать больше на моём <a href="https://github.com/IGlek">GitHub</a>.') 'узнать больше на моём <a href="https://github.com/EDeev">GitHub</a>.')
@router.callback_query(F.data == "com") @router.callback_query(F.data == "com")
async def commands(call: CallbackQuery) -> None: async def commands(call: CallbackQuery) -> None:
await call.message.answer(text='<b>| КОМАНДЫ |</b>\n\n' await call.message.answer(text='<b>| КОМАНДЫ |</b>\n\n'
'<b>/help</b> - вспомогательная функция для уточнения работы команд\n' '<b>/help</b> - вспомогательная функция для уточнения работы команд\n'
'<b>/link</b> - как получить ссылку на календарь (прислать её можно в любой момент)\n'
'<b>/list</b> - список событий календаря, запланированных на сегодняшний день\n' '<b>/list</b> - список событий календаря, запланированных на сегодняшний день\n'
'<b>/notif</b> - команда, отключающая рассылку уведомлений, даже при наличии событий в календаре\n' '<b>/notif</b> - команда, отключающая рассылку уведомлений, даже при наличии событий в календаре\n'
'<b>/daily</b> - оповещение в 8 утра по вашему часовому поясу со списком событий на день\n' '<b>/daily</b> - оповещение в 8 утра по вашему часовому поясу со списком событий на день\n'
'<b>/moment</b> - напоминание, приходящее в момент начала события\n\n' '<b>/moment</b> - напоминание, приходящее в момент начала события\n\n'
'<b>/get_alarm</b> - информация о времени на которое настроены оповещения\n' '<b>/get_alarm</b> - информация о времени на которое настроены оповещения\n'
'<b>/edit_alarm</b> - изменение времени оповещений\n' '<b>/edit_alarm</b> - изменение времени оповещений\n'
'<b>/stop_alarm</b> - команда, отключающая второе оповещение о событии') '<b>/stop_alarm</b> - команда, отключающая второе оповещение о событии\n\n'
'<b>/unsubscribe</b> - удалить подписку на календарь')
# КОМАНДЫ # КОМАНДЫ
@router.message(Command('link'))
async def link_help(msg: Message) -> None:
await msg.answer("<b>Как получить ссылку</b>\n\n"
"1. Откройте Яндекс.Календарь в браузере и наведите на нужный календарь в списке слева\n"
"2. Нажмите на шестерёнку → <b>Экспорт</b>\n"
"3. Выберите формат <b>iCal</b> и скопируйте ссылку\n"
"4. Пришлите её сюда — в любой момент, новая ссылка заменит старую")
@router.message(Command('unsubscribe'))
async def unsubscribe(msg: Message) -> None:
user_id = user_id_of(msg)
if du.url_exists(user_id):
du.delete_url(user_id)
dc.delete_clock(user_id)
try: os.remove(f"{ICAL_DIR}/{user_id}.ics")
except FileNotFoundError: pass
await msg.answer("Подписка на календарь удалена, уведомлений больше не будет. "
"Чтобы подписаться снова, пришлите ссылку.")
else: await msg.answer("Подписки на календарь нет.")
@router.message(Command('list')) @router.message(Command('list'))
async def check_list(msg: Message) -> None: async def check_list(msg: Message) -> None:
user_id = du.get_user_id(int(msg.chat.id)) user_id = user_id_of(msg)
if du.url_exists(user_id): if du.url_exists(user_id):
txt = "<b>Имеющиеся события на сегодня</b>\n\n" txt = "<b>Имеющиеся события на сегодня</b>\n\n"
lst_events = sorted(text_ical(user_id, du.get_tz(user_id))) lst_events = sorted(text_ical(user_id, du.get_tz(user_id)), key=lambda e: (e[0], e[1], e[2]))
today = dt_now(du.get_tz(user_id)).date() today = dt_now(du.get_tz(user_id)).date()
counter = 0 counter = 0
@ -69,48 +108,48 @@ async def check_list(msg: Message) -> None:
if counter: await msg.answer(text=txt, disable_web_page_preview=True) if counter: await msg.answer(text=txt, disable_web_page_preview=True)
else: await msg.answer("<b>В данный момент</b> событий на сегодня найдено не было!") else: await msg.answer("<b>В данный момент</b> событий на сегодня найдено не было!")
else: await msg.answer("Для отображения событий вы должны прислать ical-ссылку на календарь!") else: await msg.answer(NO_LINK)
@router.message(Command('notif')) @router.message(Command('notif'))
async def notif_up(msg: Message) -> None: async def notif_up(msg: Message) -> None:
user_id = du.get_user_id(int(msg.chat.id)) user_id = user_id_of(msg)
if du.url_exists(user_id): if du.url_exists(user_id):
if du.get_status(user_id): await msg.answer("Уведомления о событиях выключены!") if du.get_status(user_id): await msg.answer("Уведомления о событиях выключены!")
else: await msg.answer("Уведомления о событиях включены!") else: await msg.answer("Уведомления о событиях включены!")
du.update_status(user_id) du.update_status(user_id)
else: await msg.answer("Для взаимодействия с событиями вы должны прислать ical-ссылку на календарь!") else: await msg.answer(NO_LINK)
@router.message(Command('daily')) @router.message(Command('daily'))
async def daily_up(msg: Message) -> None: async def daily_up(msg: Message) -> None:
user_id = du.get_user_id(int(msg.chat.id)) user_id = user_id_of(msg)
if dc.clock_exists(user_id): if dc.clock_exists(user_id):
if dc.get_daily(user_id): await msg.answer("Ежедневные утренние уведомления выключены!") if dc.get_daily(user_id): await msg.answer("Ежедневные утренние уведомления выключены!")
else: await msg.answer("Ежедневные утренние уведомления включены!") else: await msg.answer("Ежедневные утренние уведомления включены!")
dc.update_daily(user_id) dc.update_daily(user_id)
else: await msg.answer("Для взаимодействия с событиями вы должны прислать ical-ссылку на календарь!") else: await msg.answer(NO_LINK)
@router.message(Command('moment')) @router.message(Command('moment'))
async def moment_up(msg: Message) -> None: async def moment_up(msg: Message) -> None:
user_id = du.get_user_id(int(msg.chat.id)) user_id = user_id_of(msg)
if dc.clock_exists(user_id): if dc.clock_exists(user_id):
if dc.get_start(user_id): await msg.answer("Уведомления в момент события выключены!") if dc.get_start(user_id): await msg.answer("Уведомления в момент события выключены!")
else: await msg.answer("Уведомления в момент события включены!") else: await msg.answer("Уведомления в момент события включены!")
dc.update_start(user_id) dc.update_start(user_id)
else: await msg.answer("Для взаимодействия с событиями вы должны прислать ical-ссылку на календарь!") else: await msg.answer(NO_LINK)
@router.message(Command('get_alarm')) @router.message(Command('get_alarm'))
async def alarm_get(msg: Message) -> None: async def alarm_get(msg: Message) -> None:
user_id = du.get_user_id(int(msg.chat.id)) user_id = user_id_of(msg)
if dc.clock_exists(user_id): if dc.clock_exists(user_id):
alarms = dc.get_alarm(user_id) alarms = dc.get_alarm(user_id)
@ -128,70 +167,72 @@ async def alarm_get(msg: Message) -> None:
await msg.answer(txt + f"\n\n<b>Первое оповещение</b> приходит за {alarms[0]} минут\n" await msg.answer(txt + f"\n\n<b>Первое оповещение</b> приходит за {alarms[0]} минут\n"
f"<b>Второе оповещение</b> приходит за {alarms[1]} минут") f"<b>Второе оповещение</b> приходит за {alarms[1]} минут")
else: else:
await msg.answer("Для того, чтобы получить таймеры, вы должны прислать ical-ссылку на свой календарь!") await msg.answer(NO_LINK)
@router.message(Command('edit_alarm')) @router.message(Command('edit_alarm'))
async def alarm_edit(msg: Message) -> None: async def alarm_edit(msg: Message) -> None:
user_id = du.get_user_id(int(msg.chat.id)) user_id = user_id_of(msg)
if dc.clock_exists(user_id): if dc.clock_exists(user_id):
await msg.answer_photo(photo=FSInputFile(path="../data/photo_edit_alarm.jpg"), await msg.answer_photo(photo=FSInputFile(path="../data/photo_edit_alarm.jpg"), caption=EDIT_ALARM_TEXT)
caption="Для изменения времени вам надо в ответ на это сообщение прислать два "
"числа через пробел: разница времени первого и второго таймера по ходу "
"времени соответственно")
else: else:
await msg.answer("Для того, чтобы изменить таймеры, вы должны прислать ical-ссылку на свой календарь!") await msg.answer(NO_LINK)
@router.message(Command('stop_alarm')) @router.message(Command('stop_alarm'))
async def alarm_stop(msg: Message) -> None: async def alarm_stop(msg: Message) -> None:
user_id = du.get_user_id(int(msg.chat.id)) user_id = user_id_of(msg)
if dc.clock_exists(user_id): if dc.clock_exists(user_id):
if dc.get_status2(user_id): await msg.answer("Второе уведомление выключено!") if dc.get_status2(user_id): await msg.answer("Второе уведомление выключено!")
else: await msg.answer("Второе уведомление включено!") else: await msg.answer("Второе уведомление включено!")
dc.update_status2(user_id) dc.update_status2(user_id)
else: await msg.answer("Для взаимодействия с событиями вы должны прислать ical-ссылку на календарь!") else: await msg.answer(NO_LINK)
# ЗАГРУЗКА ССЫЛКИ # ЗАГРУЗКА ССЫЛКИ И ИЗМЕНЕНИЕ ВРЕМЕНИ ОПОВЕЩЕНИЙ
@router.message(F.content_type == ContentType.TEXT) @router.message(F.content_type == ContentType.TEXT)
async def downloading_file_ics(msg: Message) -> None: async def downloading_file_ics(msg: Message) -> None:
user_id = du.get_user_id(int(msg.chat.id)) user_id = user_id_of(msg)
if msg.text.startswith("http"):
url = msg.text.strip()
time_zone = parse_ical_url(url)
if time_zone is None:
await msg.answer("Это не ссылка экспорта Яндекс.Календаря. Как её получить — /link")
return
if msg.text[:5] == "https":
try: try:
wget.download(msg.text, f'../data/icals/{str(user_id)}_new.ics') await download_ical(url, user_id)
except Exception as err:
logging.warning("Не удалось скачать календарь пользователя %s: %s", user_id, err)
await msg.answer("Ошибка скачивания! Проверьте правильность ссылки и пришлите ещё раз")
return
try: os.remove(f'../data/icals/{str(user_id)}.ics')
except FileNotFoundError: pass
os.rename(f'../data/icals/{str(user_id)}_new.ics', f'../data/icals/{str(user_id)}.ics')
time_zone = msg.text.split("=")[-1]
if not du.url_exists(user_id): if not du.url_exists(user_id):
du.add_url(user_id, msg.text, time_zone) du.add_url(user_id, url, time_zone)
dc.add_clock(user_id) dc.add_clock(user_id)
else: else:
du.update_url(user_id, msg.text, time_zone) du.update_url(user_id, url, time_zone)
await msg.answer("Ссылка успешно добавлена! Уведомления уже включены!") await msg.answer("Ссылка успешно добавлена! Уведомления уже включены!")
except Exception: return
await msg.answer("Ошибка скачивания! Проверьте правильность ссылки и пришлите ещё раз")
if (msg.reply_to_message is not None) and dc.clock_exists(user_id): if msg.reply_to_message is not None and msg.reply_to_message.caption == EDIT_ALARM_TEXT \
text = "Для изменения времени вам надо в ответ на это сообщение прислать два числа через " \ and dc.clock_exists(user_id):
"пробел: разница времени первого и второго таймера по ходу времени соответственно"
if msg.reply_to_message.caption == text:
alarm_new = msg.text.split() alarm_new = msg.text.split()
if int(alarm_new[0]) < 60 and int(alarm_new[1]) < 60: if len(alarm_new) != 2 or not all(x.isdigit() for x in alarm_new):
await msg.answer("Пришлите два числа через пробел, например: <b>15 5</b>")
elif all(0 < int(x) < 60 for x in alarm_new):
dc.update_alarm1(user_id, int(alarm_new[0])) dc.update_alarm1(user_id, int(alarm_new[0]))
dc.update_alarm2(user_id, int(alarm_new[1])) dc.update_alarm2(user_id, int(alarm_new[1]))
await msg.answer("Время отправки уведомлений успешно обновлено!") await msg.answer("Время отправки уведомлений успешно обновлено!")
else: else:
await msg.answer("Время отправки уведомлений должно быть меньше 60 минут!") await msg.answer("Время отправки уведомлений должно быть от 1 до 59 минут!")
return
await msg.answer("Не понял сообщение. Пришлите ссылку на календарь (/link) или посмотрите команды — /help")

View file

@ -1,15 +1,16 @@
import os
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.enums.parse_mode import ParseMode from aiogram.enums.parse_mode import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.client.bot import DefaultBotProperties from aiogram.client.bot import DefaultBotProperties
from config import botToken from config import botToken
from sql import Users, Clock
bot = Bot(token=botToken, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) bot = Bot(token=botToken, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher(storage=MemoryStorage()) dp = Dispatcher(storage=MemoryStorage())
os.makedirs("../db", exist_ok=True)
from sql import Users, Clock
du = Users('../db/users.db') du = Users('../db/users.db')
dc = Clock('../db/clock.db') dc = Clock('../db/clock.db')

View file

@ -1,16 +1,57 @@
import os
import re
from html import escape
from datetime import datetime, time from datetime import datetime, time
import icalendar from urllib.parse import parse_qs, urlparse
from dateutil.rrule import * import aiohttp
from dateutil.parser import * 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): def text_ical(user_id, tz):
date = dt_now(tz) date = dt_now(tz)
path = f'../data/icals/{user_id}.ics' path = f"{ICAL_DIR}/{user_id}.ics"
e = open(path, 'rb') if not os.path.exists(path):
return []
with open(path, "rb") as e:
ecal = icalendar.Calendar.from_ical(e.read()) ecal = icalendar.Calendar.from_ical(e.read())
events = [] events = []
@ -24,7 +65,8 @@ def text_ical(user_id, tz):
dt_start = component.decoded("dtstart") dt_start = component.decoded("dtstart")
dt_end = component.decoded("dtend") dt_end = component.decoded("dtend")
if len(dt) == 1: all_day = len(dt) == 1
if all_day:
dt_start = datetime.combine(dt_start, time(minute=1)) dt_start = datetime.combine(dt_start, time(minute=1))
dt_end = datetime.combine(dt_end, time(minute=1)) dt_end = datetime.combine(dt_end, time(minute=1))
else: else:
@ -47,7 +89,7 @@ def text_ical(user_id, tz):
until = isoparse(until_old).replace(tzinfo=None) until = isoparse(until_old).replace(tzinfo=None)
if until.replace(tzinfo=None) <= date.replace(tzinfo=None): if until.replace(tzinfo=None) <= date.replace(tzinfo=None):
until, u_flag = "UNTIL=" + until_old, False until, u_flag = "UNTIL=" + until_utc(until_old, tz), False
if u_flag: if u_flag:
until = "UNTIL=" + "".join(date.date().isoformat().split("-")) + "T235900Z" until = "UNTIL=" + "".join(date.date().isoformat().split("-")) + "T235900Z"
@ -70,28 +112,52 @@ def text_ical(user_id, tz):
org = component.get("organizer") org = component.get("organizer")
desc = component.get("description") desc = component.get("description")
event = {"name": component.get('summary'), "desc": desc if desc else "отсутствует", event = {"name": component.get('summary'), "desc": str(desc).strip() if desc else "",
"org": org if org else "не назначен", "datetime": [dt_start, dt_end]} "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]) events.append([dt_start.date(), dt_start.time(), i, event])
e.close()
return events 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): def message_form(k, event):
txt = "" txt = ""
if k: if k:
txt += f"-------\n" txt += "-------\n"
txt += f"<b>{k}. {event['name']}</b>\n" txt += f"<b>{k}. {escape(str(event['name']))}</b>\n"
else: else:
txt += f"<b>{event['name']}</b>\n" txt += f"<b>{escape(str(event['name']))}</b>\n"
txt += f"<b>Описание:</b> {event['desc']}\n" # пустые описание и организатора не показываем
txt += f"<b>Организатор:</b> {event['org']}\n\n" if event["desc"]:
txt += f"<b>Начало: {event['datetime'][0].strftime('%H:%M - %d.%m.%Y года')}</b>\n" txt += f"<b>Описание:</b> {escape(event['desc'])}\n"
txt += f"<b>Конец: {event['datetime'][1].strftime('%H:%M - %d.%m.%Y года')}</b>\n" if event["org"]:
txt += f"<b>Организатор:</b> {escape(event['org'])}\n"
# события всегда сегодняшние, поэтому дата не нужна — только время
start, end = event["datetime"]
if event["all_day"]:
txt += "\n<b>Весь день</b>\n"
elif end.date() != start.date():
txt += f"\n<b>{start.strftime('%H:%M')} — {end.strftime('%d.%m %H:%M')}</b>\n"
else:
txt += f"\n<b>{start.strftime('%H:%M')} — {end.strftime('%H:%M')}</b>\n"
return txt return txt

View file

@ -8,50 +8,56 @@ class Users:
self.connection = sqlite3.connect(database) self.connection = sqlite3.connect(database)
self.cursor = self.connection.cursor() self.cursor = self.connection.cursor()
# на новой установке (например, в Docker с пустым томом) таблиц ещё нет
with self.connection:
self.cursor.execute("CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL)")
self.cursor.execute("CREATE TABLE IF NOT EXISTS url (user_id INTEGER NOT NULL, "
"status BOOLEAN NOT NULL DEFAULT (True), url_ical STRING, time_zone STRING)")
# КОМАНДЫ USER # КОМАНДЫ USER
def user_exists(self, user_id): def user_exists(self, user_id):
"""Проверяем, есть ли уже пользователь в базе""" """Проверяем, есть ли уже пользователь в базе"""
with self.connection: with self.connection:
result = self.cursor.execute(f'SELECT * FROM `user` WHERE `user_id` = ?', (user_id,)).fetchall() result = self.cursor.execute('SELECT * FROM `user` WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result)) return bool(len(result))
def all_users(self): def all_users(self):
"""Список айди""" """Список айди"""
with self.connection: with self.connection:
return self.cursor.execute(f'SELECT `id` FROM `user`').fetchall() return self.cursor.execute('SELECT `id` FROM `user`').fetchall()
def add_user(self, user_id): def add_user(self, user_id):
"""Добавляем нового пользователя""" """Добавляем нового пользователя"""
with self.connection: with self.connection:
return self.cursor.execute(f"INSERT INTO `user` (`user_id`) VALUES(?)", (user_id,)) return self.cursor.execute("INSERT INTO `user` (`user_id`) VALUES(?)", (user_id,))
def get_user_id(self, user_id): def get_user_id(self, user_id):
"""Получаем короткое айди юзера""" """Получаем короткое айди юзера"""
with self.connection: with self.connection:
return self.cursor.execute(f'SELECT `id` FROM `user` WHERE `user_id` = ?', (user_id,)).fetchone()[0] return self.cursor.execute('SELECT `id` FROM `user` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def get_first_user_id(self, user_id): def get_first_user_id(self, user_id):
"""Получаем длинное айди юзера""" """Получаем длинное айди юзера"""
with self.connection: with self.connection:
return self.cursor.execute(f'SELECT `user_id` FROM `user` WHERE `id` = ?', (user_id,)).fetchone()[0] return self.cursor.execute('SELECT `user_id` FROM `user` WHERE `id` = ?', (user_id,)).fetchone()[0]
# КОМАНДЫ URL # КОМАНДЫ URL
def url_exists(self, user_id): def url_exists(self, user_id):
"""Проверяем, есть ли данные уже в базе""" """Проверяем, есть ли данные уже в базе"""
with self.connection: with self.connection:
result = self.cursor.execute(f'SELECT * FROM `url` WHERE `user_id` = ?', (user_id,)).fetchall() result = self.cursor.execute('SELECT * FROM `url` WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result)) return bool(len(result))
def add_url(self, user_id, url_ical, time_zone): def add_url(self, user_id, url_ical, time_zone):
"""Добавляем ссылку на календарь""" """Добавляем ссылку на календарь"""
with self.connection: with self.connection:
return self.cursor.execute(f"INSERT INTO `url` (`user_id`, `url_ical`, `time_zone`) VALUES(?, ?, ?)", return self.cursor.execute("INSERT INTO `url` (`user_id`, `url_ical`, `time_zone`) VALUES(?, ?, ?)",
(user_id, url_ical, time_zone)) (user_id, url_ical, time_zone))
def update_status(self, user_id): def update_status(self, user_id):
"""Обновляем статус рассылки уведомлений""" """Обновляем статус рассылки уведомлений"""
with self.connection: with self.connection:
status = self.cursor.execute(f'SELECT `status` FROM `url` WHERE `user_id` = ?', (user_id,)).fetchone()[0] status = self.cursor.execute('SELECT `status` FROM `url` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `url` SET `status` = ? WHERE `user_id` = ?", (not status, user_id)) return self.cursor.execute("UPDATE `url` SET `status` = ? WHERE `user_id` = ?", (not status, user_id))
def update_url(self, user_id, url_ical, time_zone): def update_url(self, user_id, url_ical, time_zone):
@ -63,17 +69,22 @@ class Users:
def get_status(self, user_id): def get_status(self, user_id):
"""Получаем статус работы""" """Получаем статус работы"""
with self.connection: with self.connection:
return self.cursor.execute(f'SELECT `status` FROM `url` WHERE `user_id` = ?', (user_id,)).fetchone()[0] return self.cursor.execute('SELECT `status` FROM `url` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def get_url(self, user_id): def get_url(self, user_id):
"""Получаем ссылку на календарь""" """Получаем ссылку на календарь"""
with self.connection: with self.connection:
return self.cursor.execute(f'SELECT `url_ical` FROM `url` WHERE `user_id` = ?', (user_id,)).fetchone()[0] return self.cursor.execute('SELECT `url_ical` FROM `url` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def delete_url(self, user_id):
"""Удаляем подписку на календарь"""
with self.connection:
return self.cursor.execute("DELETE FROM `url` WHERE `user_id` = ?", (user_id,))
def get_tz(self, user_id): def get_tz(self, user_id):
"""Получаем указанный в календаре часовой пояс""" """Получаем указанный в календаре часовой пояс"""
with self.connection: with self.connection:
return timezone(self.cursor.execute(f'SELECT `time_zone` FROM `url` WHERE `user_id` = ?', return timezone(self.cursor.execute('SELECT `time_zone` FROM `url` WHERE `user_id` = ?',
(user_id,)).fetchone()[0]) (user_id,)).fetchone()[0])
# ЗАКРЫТИЕ ВЫЗОВА # ЗАКРЫТИЕ ВЫЗОВА
@ -88,52 +99,62 @@ class Clock:
self.connection = sqlite3.connect(database) self.connection = sqlite3.connect(database)
self.cursor = self.connection.cursor() self.cursor = self.connection.cursor()
with self.connection:
self.cursor.execute("CREATE TABLE IF NOT EXISTS alarm (user_id INTEGER NOT NULL, "
"daily BOOLEAN NOT NULL DEFAULT (False), start BOOLEAN NOT NULL DEFAULT (True), "
"alarm_1 INTEGER, alarm_2 INTEGER, status_2 BOOLEAN NOT NULL DEFAULT (True))")
# КОМАНДЫ ALARM # КОМАНДЫ ALARM
def clock_exists(self, user_id): def clock_exists(self, user_id):
"""Проверяем, есть ли данные уже в базе""" """Проверяем, есть ли данные уже в базе"""
with self.connection: with self.connection:
result = self.cursor.execute(f'SELECT * FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchall() result = self.cursor.execute('SELECT * FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result)) return bool(len(result))
def add_clock(self, user_id): def add_clock(self, user_id):
"""Добавляем параметры уведомления""" """Добавляем параметры уведомления"""
with self.connection: with self.connection:
return self.cursor.execute(f"INSERT INTO `alarm` (`user_id`, `alarm_1`, `alarm_2`) VALUES(?, ?, ?)", return self.cursor.execute("INSERT INTO `alarm` (`user_id`, `alarm_1`, `alarm_2`) VALUES(?, ?, ?)",
(user_id, 15, 5)) (user_id, 15, 5))
def delete_clock(self, user_id):
"""Удаляем настройки оповещений"""
with self.connection:
return self.cursor.execute("DELETE FROM `alarm` WHERE `user_id` = ?", (user_id,))
# КОМАНДЫ ПОЛУЧЕНИЯ ССЫЛОК # КОМАНДЫ ПОЛУЧЕНИЯ ССЫЛОК
def get_alarm(self, user_id): def get_alarm(self, user_id):
"""Получаем задержки таймера""" """Получаем задержки таймера"""
with self.connection: with self.connection:
return self.cursor.execute(f'SELECT `alarm_1`, `alarm_2` FROM `alarm` WHERE `user_id` = ?', return self.cursor.execute('SELECT `alarm_1`, `alarm_2` FROM `alarm` WHERE `user_id` = ?',
(user_id,)).fetchone() (user_id,)).fetchone()
def get_start(self, user_id): def get_start(self, user_id):
"""Получаем статус уведомления в момент исполнения""" """Получаем статус уведомления в момент исполнения"""
with self.connection: with self.connection:
return self.cursor.execute(f'SELECT `start` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0] return self.cursor.execute('SELECT `start` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def get_daily(self, user_id): def get_daily(self, user_id):
"""Получаем статус ежедневного уведомления""" """Получаем статус ежедневного уведомления"""
with self.connection: with self.connection:
return self.cursor.execute(f'SELECT `daily` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0] return self.cursor.execute('SELECT `daily` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def get_status2(self, user_id): def get_status2(self, user_id):
"""Получаем статус отправки второго уведомления""" """Получаем статус отправки второго уведомления"""
with self.connection: with self.connection:
return self.cursor.execute(f'SELECT `status_2` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0] return self.cursor.execute('SELECT `status_2` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
# КОМАНДЫ ОБНОВЛЕНИЯ БУЛЕВЫХ СТАТУСОВ # КОМАНДЫ ОБНОВЛЕНИЯ БУЛЕВЫХ СТАТУСОВ
def update_daily(self, user_id): def update_daily(self, user_id):
"""Обновляем статус ежедневного уведомления""" """Обновляем статус ежедневного уведомления"""
with self.connection: with self.connection:
daily = self.cursor.execute(f'SELECT `daily` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0] daily = self.cursor.execute('SELECT `daily` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `alarm` SET `daily` = ? WHERE `user_id` = ?", (not daily, user_id)) return self.cursor.execute("UPDATE `alarm` SET `daily` = ? WHERE `user_id` = ?", (not daily, user_id))
def update_start(self, user_id): def update_start(self, user_id):
"""Обновляем статус уведомления в момент исполнения""" """Обновляем статус уведомления в момент исполнения"""
with self.connection: with self.connection:
start = self.cursor.execute(f'SELECT `start` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0] start = self.cursor.execute('SELECT `start` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `alarm` SET `start` = ? WHERE `user_id` = ?", (not start, user_id)) return self.cursor.execute("UPDATE `alarm` SET `start` = ? WHERE `user_id` = ?", (not start, user_id))
# КОМАНДЫ ОБНОВЛЕНИЯ ВРЕМЕННОГО ДИАПАЗОНА # КОМАНДЫ ОБНОВЛЕНИЯ ВРЕМЕННОГО ДИАПАЗОНА
@ -150,7 +171,7 @@ class Clock:
def update_status2(self, user_id): def update_status2(self, user_id):
"""Обновляем статус отправки второго уведомления""" """Обновляем статус отправки второго уведомления"""
with self.connection: with self.connection:
status_2 = self.cursor.execute(f'SELECT `status_2` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0] status_2 = self.cursor.execute('SELECT `status_2` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `alarm` SET `status_2` = ? WHERE `user_id` = ?", (not status_2, user_id)) return self.cursor.execute("UPDATE `alarm` SET `status_2` = ? WHERE `user_id` = ?", (not status_2, user_id))
# ЗАКРЫТИЕ ВЫЗОВА # ЗАКРЫТИЕ ВЫЗОВА

0
data/icals/.gitkeep Normal file
View file

0
db/.gitkeep Normal file
View file

Binary file not shown.

Binary file not shown.