1
0
Fork 0
mirror of https://github.com/EDeev/y.calendarkin.git synced 2026-10-08 00:39:27 +03:00
y.calendarkin/code/handlers.py
Egor Deev 15bd36580e Исправлены ошибки и уязвимость, улучшены уведомления
Ошибки:
- исключение в фоновых циклах (сбой сети при обновлении календаря, заблокировавший бота
  пользователь, битый календарь) останавливало весь бот — теперь ошибка у одного
  пользователя логируется и не мешает остальным;
- повторяющиеся события с 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.
2026-10-05 19:28:43 +00:00

238 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
import os
from aiogram import F, Router
from aiogram.types import Message, CallbackQuery, ContentType, InlineKeyboardMarkup, InlineKeyboardButton, FSInputFile
from aiogram.filters import Command
from init import du, dc
from script import ICAL_DIR, download_ical, dt_now, message_form, parse_ical_url, text_ical
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'))
async def helps(msg: Message) -> None:
user_id_of(msg)
buttons = [[InlineKeyboardButton(text="КОМАНДЫ", callback_data="com"),
InlineKeyboardButton(text="АВТОР", callback_data="auth")]]
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await msg.answer(text="<b>Я.Календаркин</b> - бот для оповещения о событиях из <b>Яндекс.Календаря</b>. "
"Для начала работы вам нужно всего лишь прислать в чат ссылку экспорта календаря в "
"<b>формате ICal</b> (как её получить — /link). После получения ссылки, бот начнёт "
"оповещать о всех новых событиях и появится возможность настройки оповещений. О том, "
"какие команды есть для настройки, вы можете ознакомиться по кнопке <b>КОМАНДЫ</b>",
reply_markup=keyboard)
@router.callback_query(F.data == "auth")
async def author(call: CallbackQuery) -> None:
await call.message.answer(text='<b>| АВТОР |</b>\n\n<b>>></b> Этот бот не коммерческий проект, для упрощенного '
'получения уведомлений о событиях в Яндекс.Календаре. Не многим этот бот будет '
'полезен, но людям, чья работа подразумевает его использование, он станет лишь '
'удобным инструментом. Я же пишу подобные небольшие проекты, о которых вы можете '
'узнать больше на моём <a href="https://github.com/EDeev">GitHub</a>.')
@router.callback_query(F.data == "com")
async def commands(call: CallbackQuery) -> None:
await call.message.answer(text='<b>| КОМАНДЫ |</b>\n\n'
'<b>/help</b> - вспомогательная функция для уточнения работы команд\n'
'<b>/link</b> - как получить ссылку на календарь (прислать её можно в любой момент)\n'
'<b>/list</b> - список событий календаря, запланированных на сегодняшний день\n'
'<b>/notif</b> - команда, отключающая рассылку уведомлений, даже при наличии событий в календаре\n'
'<b>/daily</b> - оповещение в 8 утра по вашему часовому поясу со списком событий на день\n'
'<b>/moment</b> - напоминание, приходящее в момент начала события\n\n'
'<b>/get_alarm</b> - информация о времени на которое настроены оповещения\n'
'<b>/edit_alarm</b> - изменение времени оповещений\n'
'<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'))
async def check_list(msg: Message) -> None:
user_id = user_id_of(msg)
if du.url_exists(user_id):
txt = "<b>Имеющиеся события на сегодня</b>\n\n"
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()
counter = 0
for event in lst_events:
if event[0] == today:
counter += 1
txt += message_form(counter, event[3])
if counter: await msg.answer(text=txt, disable_web_page_preview=True)
else: await msg.answer("<b>В данный момент</b> событий на сегодня найдено не было!")
else: await msg.answer(NO_LINK)
@router.message(Command('notif'))
async def notif_up(msg: Message) -> None:
user_id = user_id_of(msg)
if du.url_exists(user_id):
if du.get_status(user_id): await msg.answer("Уведомления о событиях выключены!")
else: await msg.answer("Уведомления о событиях включены!")
du.update_status(user_id)
else: await msg.answer(NO_LINK)
@router.message(Command('daily'))
async def daily_up(msg: Message) -> None:
user_id = user_id_of(msg)
if dc.clock_exists(user_id):
if dc.get_daily(user_id): await msg.answer("Ежедневные утренние уведомления выключены!")
else: await msg.answer("Ежедневные утренние уведомления включены!")
dc.update_daily(user_id)
else: await msg.answer(NO_LINK)
@router.message(Command('moment'))
async def moment_up(msg: Message) -> None:
user_id = user_id_of(msg)
if dc.clock_exists(user_id):
if dc.get_start(user_id): await msg.answer("Уведомления в момент события выключены!")
else: await msg.answer("Уведомления в момент события включены!")
dc.update_start(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)
if dc.clock_exists(user_id):
alarms = dc.get_alarm(user_id)
start = dc.get_start(user_id)
status2 = dc.get_status2(user_id)
txt = ""
if status2: txt += "<b>У вас работает два оповещения"
else: txt += "<b>У вас работает лишь первое оповещение"
if start: txt += " и сообщение в момент начала события!</b>"
else: txt += "!</b>"
await msg.answer(txt + f"\n\n<b>Первое оповещение</b> приходит за {alarms[0]} минут\n"
f"<b>Второе оповещение</b> приходит за {alarms[1]} минут")
else:
await msg.answer(NO_LINK)
@router.message(Command('edit_alarm'))
async def alarm_edit(msg: Message) -> None:
user_id = user_id_of(msg)
if dc.clock_exists(user_id):
await msg.answer_photo(photo=FSInputFile(path="../data/photo_edit_alarm.jpg"), caption=EDIT_ALARM_TEXT)
else:
await msg.answer(NO_LINK)
@router.message(Command('stop_alarm'))
async def alarm_stop(msg: Message) -> None:
user_id = user_id_of(msg)
if dc.clock_exists(user_id):
if dc.get_status2(user_id): await msg.answer("Второе уведомление выключено!")
else: await msg.answer("Второе уведомление включено!")
dc.update_status2(user_id)
else: await msg.answer(NO_LINK)
# ЗАГРУЗКА ССЫЛКИ И ИЗМЕНЕНИЕ ВРЕМЕНИ ОПОВЕЩЕНИЙ
@router.message(F.content_type == ContentType.TEXT)
async def downloading_file_ics(msg: Message) -> None:
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
try:
await download_ical(url, user_id)
except Exception as err:
logging.warning("Не удалось скачать календарь пользователя %s: %s", user_id, err)
await msg.answer("Ошибка скачивания! Проверьте правильность ссылки и пришлите ещё раз")
return
if not du.url_exists(user_id):
du.add_url(user_id, url, time_zone)
dc.add_clock(user_id)
else:
du.update_url(user_id, url, time_zone)
await msg.answer("Ссылка успешно добавлена! Уведомления уже включены!")
return
if msg.reply_to_message is not None and msg.reply_to_message.caption == EDIT_ALARM_TEXT \
and dc.clock_exists(user_id):
alarm_new = msg.text.split()
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_alarm2(user_id, int(alarm_new[1]))
await msg.answer("Время отправки уведомлений успешно обновлено!")
else:
await msg.answer("Время отправки уведомлений должно быть от 1 до 59 минут!")
return
await msg.answer("Не понял сообщение. Пришлите ссылку на календарь (/link) или посмотрите команды — /help")