diff --git a/code/bot.py b/code/bot.py index 4ecabb1..31a328c 100644 --- a/code/bot.py +++ b/code/bot.py @@ -1,104 +1,113 @@ -import asyncio, logging, time, wget, os - -from init import * -from script import * -from handlers import router - - -# ПРОВЕРКА НА СОБЫТИЕ -async def alarm(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 dc.clock_exists(user_id): - if du.get_status(user_id): - events = sorted(text_ical(user_id, du.get_tz(user_id))) - tg_id = str(du.get_first_user_id(user_id)) - - start = dc.get_start(user_id) - daily = dc.get_daily(user_id) - alarm = dc.get_alarm(user_id) - - alarm2_status = dc.get_status2(user_id) - - today = dt_now(du.get_tz(user_id)).date() - time_check = dt_now(du.get_tz(user_id)).time() - - # ДЛЯ DAILY - counter = 0 - txt = "События сегодня\n\n" - - delta_daily1 = time(hour=8, minute=0) - delta_daily2 = time(hour=8, minute=1) - # -------------------------------- - - for event in events: - if event[0] == today: - d_event = datetime.combine(today, event[1]) - - if daily and delta_daily1 <= time_check < delta_daily2: - counter += 1 - txt += message_form(counter, event[3]) - - 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"Событие начинается!\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"Напоминаю!\nЧерез {alarm[0]} минут " - f"будет событие:\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"Напоминаю!\nЧерез {alarm[1]} минут " - f"будет событие:\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) - else: - await bot.send_message(chat_id=tg_id, disable_web_page_preview=True, text=f"Сегодня событий нет") - - -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') - - -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) - - -async def process() -> None: - await asyncio.gather(main(), # ЗАПУСК ОСНОВНОЙ ПРОГРАММЫ - alarm(60), # ПРОВЕРКА КАЖДУЮ 1 МИНУТУ - update(780)) # ПРОВЕРКА КАЖДУЮ 13 МИНУТУ - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - - try: asyncio.run(process()) - except KeyboardInterrupt: pass +import asyncio +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 +from handlers import router + + +async def send(tg_id, text): + await bot.send_message(chat_id=tg_id, disable_web_page_preview=True, text=text) + + +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) + daily = dc.get_daily(user_id) + alarm = dc.get_alarm(user_id) + + alarm2_status = dc.get_status2(user_id) + + today = dt_now(du.get_tz(user_id)).date() + time_check = dt_now(du.get_tz(user_id)).time() + + # ДЛЯ DAILY + counter = 0 + txt = "События сегодня\n\n" + + delta_daily1 = time(hour=8, minute=0) + delta_daily2 = time(hour=8, minute=1) + # -------------------------------- + + for event in events: + if event[0] == today: + d_event = datetime.combine(today, event[1]) + + if daily and delta_daily1 <= time_check < delta_daily2: + 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 send(tg_id, "Событие начинается!\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 send(tg_id, f"Напоминаю!\nЧерез {alarm[0]} минут " + f"будет событие:\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 send(tg_id, f"Напоминаю!\nЧерез {alarm[1]} минут " + f"будет событие:\n\n{message_form(0, event[3])}") + + if daily and delta_daily1 <= time_check < delta_daily2: + if counter: + await send(tg_id, txt) + else: + await send(tg_id, "Сегодня событий нет") + + +# ПРОВЕРКА НА СОБЫТИЕ +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) + + 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()) + + +async def process() -> None: + await asyncio.gather(main(), # ЗАПУСК ОСНОВНОЙ ПРОГРАММЫ + alarm(60), # ПРОВЕРКА КАЖДУЮ 1 МИНУТУ + update(780)) # ПРОВЕРКА КАЖДУЮ 13 МИНУТУ + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + + try: asyncio.run(process()) + except KeyboardInterrupt: pass diff --git a/code/config.py b/code/config.py index 54e365d..db539ec 100644 --- a/code/config.py +++ b/code/config.py @@ -1 +1,3 @@ -TOKEN = "**********:***************************" +import os + +botToken = os.getenv("BOT_TOKEN", "XXXXXXXXXXXXXXXXXXXXXXXX") # @calendarkin_ybot diff --git a/code/handlers.py b/code/handlers.py index c726169..d5abe4a 100644 --- a/code/handlers.py +++ b/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="Я.Календаркин - бот для оповещения о событиях из Яндекс.Календаря. " "Для начала работы вам нужно всего лишь прислать в чат ссылку экспорта календаря в " - "формате ICal. После получения ссылки, бот начнёт оповещать о всех новых событиях " - "и появится возможность настройки оповещений. О том, какие команды есть для настройки, " - "вы можете ознакомиться по кнопке КОМАНДЫ", reply_markup=keyboard) + "формате ICal (как её получить — /link). После получения ссылки, бот начнёт " + "оповещать о всех новых событиях и появится возможность настройки оповещений. О том, " + "какие команды есть для настройки, вы можете ознакомиться по кнопке КОМАНДЫ", + reply_markup=keyboard) @router.callback_query(F.data == "auth") @@ -34,31 +47,57 @@ async def author(call: CallbackQuery) -> None: 'получения уведомлений о событиях в Яндекс.Календаре. Не многим этот бот будет ' 'полезен, но людям, чья работа подразумевает его использование, он станет лишь ' 'удобным инструментом. Я же пишу подобные небольшие проекты, о которых вы можете ' - 'узнать больше на моём GitHub.') + 'узнать больше на моём GitHub.') @router.callback_query(F.data == "com") async def commands(call: CallbackQuery) -> None: await call.message.answer(text='| КОМАНДЫ |\n\n' '/help - вспомогательная функция для уточнения работы команд\n' + '/link - как получить ссылку на календарь (прислать её можно в любой момент)\n' '/list - список событий календаря, запланированных на сегодняшний день\n' '/notif - команда, отключающая рассылку уведомлений, даже при наличии событий в календаре\n' '/daily - оповещение в 8 утра по вашему часовому поясу со списком событий на день\n' '/moment - напоминание, приходящее в момент начала события\n\n' '/get_alarm - информация о времени на которое настроены оповещения\n' '/edit_alarm - изменение времени оповещений\n' - '/stop_alarm - команда, отключающая второе оповещение о событии') + '/stop_alarm - команда, отключающая второе оповещение о событии\n\n' + '/unsubscribe - удалить подписку на календарь') # КОМАНДЫ +@router.message(Command('link')) +async def link_help(msg: Message) -> None: + await msg.answer("Как получить ссылку\n\n" + "1. Откройте Яндекс.Календарь в браузере и наведите на нужный календарь в списке слева\n" + "2. Нажмите на шестерёнку → Экспорт\n" + "3. Выберите формат iCal и скопируйте ссылку\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 = "Имеющиеся события на сегодня\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("В данный момент событий на сегодня найдено не было!") - 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Первое оповещение приходит за {alarms[0]} минут\n" f"Второе оповещение приходит за {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') - - 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) - dc.add_clock(user_id) - else: - du.update_url(user_id, msg.text, time_zone) - - await msg.answer("Ссылка успешно добавлена! Уведомления уже включены!") - except Exception: + await download_ical(url, user_id) + except Exception as err: + logging.warning("Не удалось скачать календарь пользователя %s: %s", user_id, err) await msg.answer("Ошибка скачивания! Проверьте правильность ссылки и пришлите ещё раз") + return - if (msg.reply_to_message is not None) and dc.clock_exists(user_id): - text = "Для изменения времени вам надо в ответ на это сообщение прислать два числа через " \ - "пробел: разница времени первого и второго таймера по ходу времени соответственно" + 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) - if msg.reply_to_message.caption == text: - alarm_new = msg.text.split() + await msg.answer("Ссылка успешно добавлена! Уведомления уже включены!") + return - if int(alarm_new[0]) < 60 and int(alarm_new[1]) < 60: - dc.update_alarm1(user_id, int(alarm_new[0])) - dc.update_alarm2(user_id, int(alarm_new[1])) + 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() - await msg.answer("Время отправки уведомлений успешно обновлено!") - else: - await msg.answer("Время отправки уведомлений должно быть меньше 60 минут!") + if len(alarm_new) != 2 or not all(x.isdigit() for x in alarm_new): + await msg.answer("Пришлите два числа через пробел, например: 15 5") + 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") diff --git a/code/init.py b/code/init.py index b6733a4..5f5beeb 100644 --- a/code/init.py +++ b/code/init.py @@ -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') diff --git a/code/script.py b/code/script.py index 9729d67..8b5111b 100644 --- a/code/script.py +++ b/code/script.py @@ -1,17 +1,58 @@ +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 [] - ecal = icalendar.Calendar.from_ical(e.read()) + with open(path, "rb") as e: + ecal = icalendar.Calendar.from_ical(e.read()) events = [] for i, component in enumerate(ecal.walk()): @@ -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"{k}. {event['name']}\n" + txt += "-------\n" + txt += f"{k}. {escape(str(event['name']))}\n" else: - txt += f"{event['name']}\n" + txt += f"{escape(str(event['name']))}\n" - txt += f"Описание: {event['desc']}\n" - txt += f"Организатор: {event['org']}\n\n" - txt += f"Начало: {event['datetime'][0].strftime('%H:%M - %d.%m.%Y года')}\n" - txt += f"Конец: {event['datetime'][1].strftime('%H:%M - %d.%m.%Y года')}\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 diff --git a/code/sql.py b/code/sql.py index 87ef9c2..7a19882 100644 --- a/code/sql.py +++ b/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)) # ЗАКРЫТИЕ ВЫЗОВА diff --git a/data/icals/.gitkeep b/data/icals/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/db/.gitkeep b/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/db/clock.db b/db/clock.db deleted file mode 100644 index a7fda41..0000000 Binary files a/db/clock.db and /dev/null differ diff --git a/db/users.db b/db/users.db deleted file mode 100644 index 86b0d99..0000000 Binary files a/db/users.db and /dev/null differ