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