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:
parent
18e79e24a5
commit
15bd36580e
10 changed files with 348 additions and 208 deletions
73
code/bot.py
73
code/bot.py
|
|
@ -1,22 +1,18 @@
|
|||
import asyncio, logging, time, wget, os
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, time
|
||||
|
||||
from init import *
|
||||
from script import *
|
||||
from init import bot, dp, du, dc
|
||||
from script import delta_time, download_ical, dt_now, message_form, text_ical
|
||||
from handlers import router
|
||||
|
||||
|
||||
# ПРОВЕРКА НА СОБЫТИЕ
|
||||
async def alarm(wait_for):
|
||||
while True:
|
||||
await asyncio.sleep(wait_for)
|
||||
async def send(tg_id, text):
|
||||
await bot.send_message(chat_id=tg_id, disable_web_page_preview=True, text=text)
|
||||
|
||||
users_id = du.all_users()
|
||||
for user_id in users_id:
|
||||
user_id = user_id[0]
|
||||
|
||||
if dc.clock_exists(user_id):
|
||||
if du.get_status(user_id):
|
||||
events = sorted(text_ical(user_id, du.get_tz(user_id)))
|
||||
async def check_user(user_id):
|
||||
events = sorted(text_ical(user_id, du.get_tz(user_id)), key=lambda e: (e[0], e[1], e[2]))
|
||||
tg_id = str(du.get_first_user_id(user_id))
|
||||
|
||||
start = dc.get_start(user_id)
|
||||
|
|
@ -44,51 +40,64 @@ async def alarm(wait_for):
|
|||
counter += 1
|
||||
txt += message_form(counter, event[3])
|
||||
|
||||
# для событий на весь день напоминания по времени не шлём
|
||||
if event[3]["all_day"]:
|
||||
continue
|
||||
|
||||
delta_start = delta_time(d_event, 1, 0)
|
||||
if start and delta_start[0] < time_check <= delta_start[1]:
|
||||
await bot.send_message(chat_id=tg_id, disable_web_page_preview=True,
|
||||
text=f"<b>Событие начинается!</b>\n\n" + message_form(0, event[3]))
|
||||
await send(tg_id, "<b>Событие начинается!</b>\n\n" + message_form(0, event[3]))
|
||||
|
||||
delta_alarm1 = delta_time(d_event, alarm[0], alarm[0] - 1)
|
||||
if delta_alarm1[0] < time_check <= delta_alarm1[1]:
|
||||
await bot.send_message(chat_id=tg_id, disable_web_page_preview=True,
|
||||
text=f"<b>Напоминаю!</b>\n<i>Через {alarm[0]} минут "
|
||||
await send(tg_id, f"<b>Напоминаю!</b>\n<i>Через {alarm[0]} минут "
|
||||
f"будет событие:</i>\n\n{message_form(0, event[3])}")
|
||||
|
||||
if alarm2_status:
|
||||
delta_alarm2 = delta_time(d_event, alarm[1], alarm[1] - 1)
|
||||
if delta_alarm2[0] < time_check <= delta_alarm2[1]:
|
||||
await bot.send_message(chat_id=tg_id, disable_web_page_preview=True,
|
||||
text=f"<b>Напоминаю!</b>\n<i>Через {alarm[1]} минут "
|
||||
await send(tg_id, f"<b>Напоминаю!</b>\n<i>Через {alarm[1]} минут "
|
||||
f"будет событие:</i>\n\n{message_form(0, event[3])}")
|
||||
|
||||
if daily and delta_daily1 <= time_check < delta_daily2:
|
||||
if counter:
|
||||
await bot.send_message(chat_id=tg_id, disable_web_page_preview=True, text=txt)
|
||||
await send(tg_id, txt)
|
||||
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):
|
||||
while True:
|
||||
await asyncio.sleep(wait_for)
|
||||
|
||||
users_id = du.all_users()
|
||||
for user_id in users_id:
|
||||
user_id = user_id[0]
|
||||
|
||||
if du.url_exists(user_id):
|
||||
if du.get_status(user_id):
|
||||
wget.download(du.get_url(user_id), f'../data/icals/{str(user_id)}_new.ics')
|
||||
|
||||
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')
|
||||
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)
|
||||
except Exception as err:
|
||||
# при сбое остаётся прошлая версия календаря
|
||||
logging.warning("Не удалось обновить календарь пользователя %s: %s", user_id, err)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
dp.include_router(router)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
TOKEN = "**********:***************************"
|
||||
import os
|
||||
|
||||
botToken = os.getenv("BOT_TOKEN", "XXXXXXXXXXXXXXXXXXXXXXXX") # @calendarkin_ybot
|
||||
|
|
|
|||
147
code/handlers.py
147
code/handlers.py
|
|
@ -1,31 +1,44 @@
|
|||
import logging
|
||||
import os
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import Message, CallbackQuery, ContentType, InlineKeyboardMarkup, InlineKeyboardButton, FSInputFile
|
||||
from aiogram.filters import Command
|
||||
|
||||
import wget, os
|
||||
|
||||
from init import *
|
||||
from script import *
|
||||
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:
|
||||
tg_id = int(msg.chat.id)
|
||||
if not du.user_exists(tg_id):
|
||||
du.add_user(tg_id)
|
||||
user_id_of(msg)
|
||||
|
||||
buttons = [[InlineKeyboardButton(text="КОМАНДЫ", callback_data="com"),
|
||||
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>. "
|
||||
"Для начала работы вам нужно всего лишь прислать в чат ссылку экспорта календаря в "
|
||||
"<b>формате ICal</b>. После получения ссылки, бот начнёт оповещать о всех новых событиях "
|
||||
"и появится возможность настройки оповещений. О том, какие команды есть для настройки, "
|
||||
"вы можете ознакомиться по кнопке <b>КОМАНДЫ</b>", reply_markup=keyboard)
|
||||
"<b>формате ICal</b> (как её получить — /link). После получения ссылки, бот начнёт "
|
||||
"оповещать о всех новых событиях и появится возможность настройки оповещений. О том, "
|
||||
"какие команды есть для настройки, вы можете ознакомиться по кнопке <b>КОМАНДЫ</b>",
|
||||
reply_markup=keyboard)
|
||||
|
||||
|
||||
@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")
|
||||
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> - команда, отключающая второе оповещение о событии')
|
||||
'<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 = du.get_user_id(int(msg.chat.id))
|
||||
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)))
|
||||
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
|
||||
|
|
@ -69,48 +108,48 @@ async def check_list(msg: Message) -> None:
|
|||
|
||||
if counter: await msg.answer(text=txt, disable_web_page_preview=True)
|
||||
else: await msg.answer("<b>В данный момент</b> событий на сегодня найдено не было!")
|
||||
else: await msg.answer("Для отображения событий вы должны прислать ical-ссылку на календарь!")
|
||||
else: await msg.answer(NO_LINK)
|
||||
|
||||
|
||||
@router.message(Command('notif'))
|
||||
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.get_status(user_id): await msg.answer("Уведомления о событиях выключены!")
|
||||
else: await msg.answer("Уведомления о событиях включены!")
|
||||
|
||||
du.update_status(user_id)
|
||||
else: await msg.answer("Для взаимодействия с событиями вы должны прислать ical-ссылку на календарь!")
|
||||
else: await msg.answer(NO_LINK)
|
||||
|
||||
|
||||
@router.message(Command('daily'))
|
||||
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.get_daily(user_id): await msg.answer("Ежедневные утренние уведомления выключены!")
|
||||
else: await msg.answer("Ежедневные утренние уведомления включены!")
|
||||
|
||||
dc.update_daily(user_id)
|
||||
else: await msg.answer("Для взаимодействия с событиями вы должны прислать ical-ссылку на календарь!")
|
||||
else: await msg.answer(NO_LINK)
|
||||
|
||||
|
||||
@router.message(Command('moment'))
|
||||
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.get_start(user_id): await msg.answer("Уведомления в момент события выключены!")
|
||||
else: await msg.answer("Уведомления в момент события включены!")
|
||||
|
||||
dc.update_start(user_id)
|
||||
else: await msg.answer("Для взаимодействия с событиями вы должны прислать ical-ссылку на календарь!")
|
||||
else: await msg.answer(NO_LINK)
|
||||
|
||||
|
||||
@router.message(Command('get_alarm'))
|
||||
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):
|
||||
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"
|
||||
f"<b>Второе оповещение</b> приходит за {alarms[1]} минут")
|
||||
else:
|
||||
await msg.answer("Для того, чтобы получить таймеры, вы должны прислать ical-ссылку на свой календарь!")
|
||||
await msg.answer(NO_LINK)
|
||||
|
||||
|
||||
@router.message(Command('edit_alarm'))
|
||||
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):
|
||||
await msg.answer_photo(photo=FSInputFile(path="../data/photo_edit_alarm.jpg"),
|
||||
caption="Для изменения времени вам надо в ответ на это сообщение прислать два "
|
||||
"числа через пробел: разница времени первого и второго таймера по ходу "
|
||||
"времени соответственно")
|
||||
await msg.answer_photo(photo=FSInputFile(path="../data/photo_edit_alarm.jpg"), caption=EDIT_ALARM_TEXT)
|
||||
else:
|
||||
await msg.answer("Для того, чтобы изменить таймеры, вы должны прислать ical-ссылку на свой календарь!")
|
||||
await msg.answer(NO_LINK)
|
||||
|
||||
|
||||
@router.message(Command('stop_alarm'))
|
||||
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.get_status2(user_id): await msg.answer("Второе уведомление выключено!")
|
||||
else: await msg.answer("Второе уведомление включено!")
|
||||
|
||||
dc.update_status2(user_id)
|
||||
else: await msg.answer("Для взаимодействия с событиями вы должны прислать ical-ссылку на календарь!")
|
||||
else: await msg.answer(NO_LINK)
|
||||
|
||||
|
||||
# ЗАГРУЗКА ССЫЛКИ
|
||||
# ЗАГРУЗКА ССЫЛКИ И ИЗМЕНЕНИЕ ВРЕМЕНИ ОПОВЕЩЕНИЙ
|
||||
@router.message(F.content_type == ContentType.TEXT)
|
||||
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:
|
||||
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):
|
||||
du.add_url(user_id, msg.text, time_zone)
|
||||
du.add_url(user_id, url, time_zone)
|
||||
dc.add_clock(user_id)
|
||||
else:
|
||||
du.update_url(user_id, msg.text, time_zone)
|
||||
du.update_url(user_id, url, time_zone)
|
||||
|
||||
await msg.answer("Ссылка успешно добавлена! Уведомления уже включены!")
|
||||
except Exception:
|
||||
await msg.answer("Ошибка скачивания! Проверьте правильность ссылки и пришлите ещё раз")
|
||||
return
|
||||
|
||||
if (msg.reply_to_message is not None) and dc.clock_exists(user_id):
|
||||
text = "Для изменения времени вам надо в ответ на это сообщение прислать два числа через " \
|
||||
"пробел: разница времени первого и второго таймера по ходу времени соответственно"
|
||||
|
||||
if msg.reply_to_message.caption == text:
|
||||
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 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_alarm2(user_id, int(alarm_new[1]))
|
||||
|
||||
await msg.answer("Время отправки уведомлений успешно обновлено!")
|
||||
else:
|
||||
await msg.answer("Время отправки уведомлений должно быть меньше 60 минут!")
|
||||
await msg.answer("Время отправки уведомлений должно быть от 1 до 59 минут!")
|
||||
return
|
||||
|
||||
await msg.answer("Не понял сообщение. Пришлите ссылку на календарь (/link) или посмотрите команды — /help")
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import os
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.enums.parse_mode import ParseMode
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.client.bot import DefaultBotProperties
|
||||
|
||||
from config import botToken
|
||||
from sql import Users, Clock
|
||||
|
||||
bot = Bot(token=botToken, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
dp = Dispatcher(storage=MemoryStorage())
|
||||
|
||||
|
||||
from sql import Users, Clock
|
||||
|
||||
os.makedirs("../db", exist_ok=True)
|
||||
du = Users('../db/users.db')
|
||||
dc = Clock('../db/clock.db')
|
||||
|
|
|
|||
100
code/script.py
100
code/script.py
|
|
@ -1,16 +1,57 @@
|
|||
import os
|
||||
import re
|
||||
from html import escape
|
||||
from datetime import datetime, time
|
||||
import icalendar
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from dateutil.rrule import *
|
||||
from dateutil.parser import *
|
||||
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'../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())
|
||||
events = []
|
||||
|
||||
|
|
@ -24,7 +65,8 @@ def text_ical(user_id, tz):
|
|||
dt_start = component.decoded("dtstart")
|
||||
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_end = datetime.combine(dt_end, time(minute=1))
|
||||
else:
|
||||
|
|
@ -47,7 +89,7 @@ def text_ical(user_id, tz):
|
|||
until = isoparse(until_old).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:
|
||||
until = "UNTIL=" + "".join(date.date().isoformat().split("-")) + "T235900Z"
|
||||
|
|
@ -70,28 +112,52 @@ def text_ical(user_id, tz):
|
|||
org = component.get("organizer")
|
||||
desc = component.get("description")
|
||||
|
||||
event = {"name": component.get('summary'), "desc": desc if desc else "отсутствует",
|
||||
"org": org if org else "не назначен", "datetime": [dt_start, dt_end]}
|
||||
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])
|
||||
e.close()
|
||||
|
||||
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 += f"-------\n"
|
||||
txt += f"<b>{k}. {event['name']}</b>\n"
|
||||
txt += "-------\n"
|
||||
txt += f"<b>{k}. {escape(str(event['name']))}</b>\n"
|
||||
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"
|
||||
txt += f"<b>Начало: {event['datetime'][0].strftime('%H:%M - %d.%m.%Y года')}</b>\n"
|
||||
txt += f"<b>Конец: {event['datetime'][1].strftime('%H:%M - %d.%m.%Y года')}</b>\n"
|
||||
# пустые описание и организатора не показываем
|
||||
if event["desc"]:
|
||||
txt += f"<b>Описание:</b> {escape(event['desc'])}\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
|
||||
|
||||
|
|
|
|||
61
code/sql.py
61
code/sql.py
|
|
@ -8,50 +8,56 @@ class Users:
|
|||
self.connection = sqlite3.connect(database)
|
||||
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
|
||||
def user_exists(self, user_id):
|
||||
"""Проверяем, есть ли уже пользователь в базе"""
|
||||
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))
|
||||
|
||||
def all_users(self):
|
||||
"""Список айди"""
|
||||
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):
|
||||
"""Добавляем нового пользователя"""
|
||||
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):
|
||||
"""Получаем короткое айди юзера"""
|
||||
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):
|
||||
"""Получаем длинное айди юзера"""
|
||||
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
|
||||
def url_exists(self, user_id):
|
||||
"""Проверяем, есть ли данные уже в базе"""
|
||||
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))
|
||||
|
||||
def add_url(self, user_id, url_ical, time_zone):
|
||||
"""Добавляем ссылку на календарь"""
|
||||
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))
|
||||
|
||||
def update_status(self, user_id):
|
||||
"""Обновляем статус рассылки уведомлений"""
|
||||
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))
|
||||
|
||||
def update_url(self, user_id, url_ical, time_zone):
|
||||
|
|
@ -63,17 +69,22 @@ class Users:
|
|||
def get_status(self, user_id):
|
||||
"""Получаем статус работы"""
|
||||
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):
|
||||
"""Получаем ссылку на календарь"""
|
||||
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):
|
||||
"""Получаем указанный в календаре часовой пояс"""
|
||||
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])
|
||||
|
||||
# ЗАКРЫТИЕ ВЫЗОВА
|
||||
|
|
@ -88,52 +99,62 @@ class Clock:
|
|||
self.connection = sqlite3.connect(database)
|
||||
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
|
||||
def clock_exists(self, user_id):
|
||||
"""Проверяем, есть ли данные уже в базе"""
|
||||
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))
|
||||
|
||||
def add_clock(self, user_id):
|
||||
"""Добавляем параметры уведомления"""
|
||||
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))
|
||||
|
||||
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):
|
||||
"""Получаем задержки таймера"""
|
||||
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()
|
||||
|
||||
def get_start(self, user_id):
|
||||
"""Получаем статус уведомления в момент исполнения"""
|
||||
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):
|
||||
"""Получаем статус ежедневного уведомления"""
|
||||
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):
|
||||
"""Получаем статус отправки второго уведомления"""
|
||||
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):
|
||||
"""Обновляем статус ежедневного уведомления"""
|
||||
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))
|
||||
|
||||
def update_start(self, user_id):
|
||||
"""Обновляем статус уведомления в момент исполнения"""
|
||||
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))
|
||||
|
||||
# КОМАНДЫ ОБНОВЛЕНИЯ ВРЕМЕННОГО ДИАПАЗОНА
|
||||
|
|
@ -150,7 +171,7 @@ class Clock:
|
|||
def update_status2(self, user_id):
|
||||
"""Обновляем статус отправки второго уведомления"""
|
||||
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))
|
||||
|
||||
# ЗАКРЫТИЕ ВЫЗОВА
|
||||
|
|
|
|||
0
data/icals/.gitkeep
Normal file
0
data/icals/.gitkeep
Normal file
0
db/.gitkeep
Normal file
0
db/.gitkeep
Normal file
BIN
db/clock.db
BIN
db/clock.db
Binary file not shown.
BIN
db/users.db
BIN
db/users.db
Binary file not shown.
Loading…
Add table
Reference in a new issue