telegram.py 88 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145
  1. """Telegram-бот для доступа к SPMessages.
  2. Полностью реализует доступ ко всем методам SPMessages.
  3. Не считая некоторых ограничений в настройке "намерений" (Intents).
  4. Команды бота для BotFather
  5. --------------------------
  6. sc - Уроки на сегодня
  7. updates - Изменения в расписании
  8. notify - Настроить уведомления
  9. counter - Счётчики уроков/кабинетов
  10. tutorial - Как писать запросы
  11. set_class - Изменить класс
  12. intents - Настроить намерения
  13. add_intent - Добавить намерение
  14. remove_intents - Удалить намерение
  15. help - Главное меню
  16. info - Информация о боте
  17. Author: Milinuri Nirvalen
  18. Ver: 2.2.2 (sp v5.7)
  19. """
  20. import asyncio
  21. import sqlite3
  22. from datetime import datetime
  23. from os import getenv
  24. from pathlib import Path
  25. from typing import (
  26. Any, Awaitable,
  27. Callable,
  28. Dict,
  29. NamedTuple,
  30. Optional,
  31. Union
  32. )
  33. from aiogram import Bot, Dispatcher, F
  34. from aiogram.exceptions import TelegramBadRequest
  35. from aiogram.filters import Command, CommandObject
  36. from aiogram.filters.callback_data import CallbackData
  37. from aiogram.fsm.context import FSMContext
  38. from aiogram.fsm.state import State, StatesGroup
  39. from aiogram.types import (
  40. CallbackQuery,
  41. ErrorEvent,
  42. InlineKeyboardButton,
  43. InlineKeyboardMarkup,
  44. Message,
  45. Update,
  46. )
  47. from dotenv import load_dotenv
  48. from loguru import logger
  49. from sp.counters import (
  50. cl_counter,
  51. days_counter,
  52. group_counter_res,
  53. index_counter,
  54. )
  55. from sp.intents import Intent
  56. from sp.messages import SPMessages, send_counter, send_search_res, send_update
  57. from sp.parser import Schedule
  58. from sp.utils import get_str_timedelta
  59. # Настройкки и константы
  60. # ======================
  61. load_dotenv()
  62. TELEGRAM_TOKEN = getenv("TELEGRAM_TOKEN", "")
  63. dp = Dispatcher()
  64. days_names = ("пн", "вт", "ср", "чт", "пт", "сб")
  65. _TIMETAG_PATH = Path("sp_data/last_update")
  66. DB_CONN = sqlite3.connect("sp_data/tg.db")
  67. # Некоторые константные настройки бота
  68. # - Максимальное число намерений на одного пользователя
  69. # - Через сколько секунд выводит предупреждение в статусе
  70. # о не руботающем скрипте автообновлений.
  71. # - Предельная длинна для сообщения списка изменений
  72. # - Минимальная длинна имена намерения.
  73. # - Максимальная длинна имена намерения.
  74. _BOT_VERSION = "v2.2.2"
  75. _MAX_INTENTS = 9
  76. _ALERT_AUTOUPDATE_AFTER_SECONDS = 3600
  77. _MAX_UPDATE_MESSAGE_LENGTHT = 4000
  78. _MIN_INTENT_NAME = 3
  79. _MAX_INTENT_NAME = 15
  80. # Статические клавиатуры при выборе класса
  81. # pass => Пропустить смену класс и установить None
  82. # cl_features => Список преимуществ если указать класс
  83. PASS_SET_CL_MARKUP = InlineKeyboardMarkup(
  84. inline_keyboard=[
  85. [
  86. InlineKeyboardButton(
  87. text="Без класса", callback_data="pass"
  88. ),
  89. InlineKeyboardButton(
  90. text="Ограничения", callback_data="cl_features"
  91. )
  92. ]
  93. ]
  94. )
  95. BACK_SET_CL_MARKUP = InlineKeyboardMarkup(
  96. inline_keyboard=[
  97. [
  98. InlineKeyboardButton(text="◁", callback_data="set_class"),
  99. InlineKeyboardButton(text="Без класса", callback_data="pass"),
  100. ]
  101. ]
  102. )
  103. # Всопомгательный класс
  104. # =====================
  105. class IntentObject(NamedTuple):
  106. """Используется для описания намерения пользователя.
  107. name (str): Ключевое имя намерения.
  108. intent (Ontent): Намерение пользователя.
  109. """
  110. name: str
  111. intent: Intent
  112. class UserIntents:
  113. """Хранилище намерений пользователя.
  114. Является обёрткой над базой данных.
  115. Позволяет получаеть, добавлять и удалять намерений пользователя.
  116. Args:
  117. conn (sqlite3.Connection): Подключение к базе данных намерений.
  118. uid (int): Идентификатор пользователя бота.
  119. """
  120. def __init__(self, conn: sqlite3.Connection, uid: int) -> None:
  121. self._conn = conn
  122. self._cur = self._conn.cursor()
  123. self._uid = uid
  124. self._check_tables()
  125. def _check_tables(self) -> None:
  126. self._cur.execute(("CREATE TABLE IF NOT EXISTS intent("
  127. "user_id TEXT NOT NULL,"
  128. "name TEXT NOT NULL,"
  129. "intent TEXT NOT NULL)"
  130. ))
  131. self._conn.commit()
  132. # Работа со списком намерений ----------------------------------------------
  133. def get(self) -> list[IntentObject]:
  134. """Получает список всех намерений пользователя."""
  135. self._cur.execute(
  136. "SELECT name,intent FROM intent WHERE user_id=?",
  137. (self._uid,)
  138. )
  139. return [IntentObject(n, Intent.from_str(i))
  140. for n, i in self._cur.fetchall()
  141. ]
  142. def get_intent(self, name: str) -> Optional[Intent]:
  143. """Возвращает первое намерение пользователя по имени."""
  144. for x in self.get():
  145. if x.name == name:
  146. return x.intent
  147. def remove_all(self):
  148. """Удаляет все намерение пользователя из базы данных."""
  149. self._cur.execute("DELETE FROM intent WHERE user_id=?", (self._uid,))
  150. self._conn.commit()
  151. # Работа с одним намерением ------------------------------------------------
  152. def add(self, name: str, intent: Intent) -> None:
  153. """Добавляет намерение в базу данных.
  154. Доабвляет запись в базу данных.
  155. Еслм такое намерение уже существует - обновляет.
  156. Args:
  157. name (str): Имя намерения.
  158. intent (Intent): Намерение для добавления.
  159. """
  160. int_s = intent.to_str()
  161. if self.get_intent(name) is not None:
  162. self._cur.execute(
  163. "UPDATE intent SET intent=? WHERE user_id=? AND name=?",
  164. (int_s, self._uid, name)
  165. )
  166. else:
  167. self._cur.execute(
  168. "INSERT INTO intent(user_id,name,intent) VALUES(?,?,?);",
  169. (self._uid, name, int_s)
  170. )
  171. self._conn.commit()
  172. def rename(self, old_name: str, new_name: str) -> None:
  173. """Изменяет имя намерения.
  174. Заменяет имя намерения в базе данных на новое.
  175. Args:
  176. old_name (str): Старое имя намерения.
  177. new_name (str): Новое имя намерения.
  178. """
  179. self._cur.execute(
  180. "UPDATE intent SET name=? WHERE user_id=? AND name=?",
  181. (new_name, self._uid, old_name)
  182. )
  183. self._conn.commit()
  184. def remove(self, name: str) -> None:
  185. """Удаляет намерение пользователя из базы данных.
  186. Args:
  187. name (str): Имя намерения для удаления.
  188. """
  189. self._cur.execute(
  190. "DELETE FROM intent WHERE user_id=? AND name=?",
  191. (self._uid, name)
  192. )
  193. self._conn.commit()
  194. # Добавление Middleware
  195. # =====================
  196. @dp.message.middleware()
  197. @dp.callback_query.middleware()
  198. @dp.error.middleware()
  199. async def user_middleware(
  200. handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
  201. event: Union[Update, CallbackQuery, ErrorEvent],
  202. data: Dict[str, Any],
  203. ) -> Any:
  204. """Добавляет экземпляр SPMessages и намерения пользователя."""
  205. if isinstance(event, ErrorEvent):
  206. if event.update.callback_query is not None:
  207. uid = event.update.callback_query.message.chat.id
  208. else:
  209. uid = event.update.message.chat.id
  210. elif isinstance(event, CallbackQuery):
  211. uid = event.message.chat.id
  212. else:
  213. uid = event.chat.id
  214. data["sp"] = SPMessages(str(uid))
  215. data["intents"] = UserIntents(DB_CONN, uid)
  216. return await handler(event, data)
  217. # Если вы хотите отключить логгирование в боте
  218. # Закомментируйте необходимые вам декораторы
  219. @dp.message.middleware()
  220. @dp.callback_query.middleware()
  221. async def log_middleware(
  222. handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
  223. event: Update,
  224. data: Dict[str, Any],
  225. ) -> Any:
  226. """Отслеживает полученные ботом сообщения и callback query."""
  227. if isinstance(event, CallbackQuery):
  228. logger.info("[c] {}: {}", event.message.chat.id, event.data)
  229. else:
  230. logger.info("[m] {}: {}", event.chat.id, event.text)
  231. return await handler(event, data)
  232. # Статические тексты сообщений
  233. # ============================
  234. # Главнео сообщение справки бота
  235. HOME_MESSAGE = ("💡 Некоторые интересные примеры запросов:"
  236. "\n-- 7в 6а на завтра"
  237. "\n-- уроки 6а на вторник ср"
  238. "\n-- 312 на вторник пятницу"
  239. "\n-- химия 228 6а вторник"
  240. "\n\n🏫 В ваших запросах вы можете использовать:"
  241. "\n* Урок/Кабинет: Получить все его упоминания."
  242. "\n* Классы: для которого нужно расписание."
  243. "\n* Дни недели:"
  244. "\n-- Если день не указан - на сегодня/завтра."
  245. "\n-- Понедельник-суббота (пн-сб)."
  246. "\n-- Сегодня, завтра, неделя."
  247. "\n\n🌟 Хотите научиться писать запросы? /tutorial"
  248. )
  249. # Сообщение при смене класса
  250. SET_CLASS_MESSAGE = (
  251. "🌟 Для более удобной работы и получения всех преимуществ,"
  252. "рекомендуется указать ваш класс."
  253. "\nЭто позволит быстро просматривать расписание и получать уведомления."
  254. "\nВы можете ознакомиться с полным списком командой /cl_features."
  255. "\n\n✨ Просто укажите свой класс следующим сообщением (\"8в\")."
  256. "\n\nВы также можете пропустить выбор класса, нажав кнопку (/pass)."
  257. "\n\n💡 Не забывайте, что вы всегда можете сменить класс позже:"
  258. "\n-- просто отправьте команду /set_class."
  259. "\n-- или выберите \"Ещё\" -> \"Сменить класс\"."
  260. )
  261. # Какие преимущества получает указавгих класс пользователь
  262. CL_FEATURES_MESSAGE = ("🌟 Если вы укажете класс, то сможете:"
  263. "\n\n-- Быстро получать расписание для класса, кнопкой в главном меню."
  264. "\n-- Не укзаывать ваш класс в текстовых запросах (прим. \"пн\")."
  265. "\n-- Получать уведомления и рассылку расписания для класса."
  266. "\n-- Просматривать список изменений для вашего класса."
  267. "\n-- Использовать счётчик cl/lessons."
  268. "\n\n💎 Список возможностей может пополняться."
  269. )
  270. # Сообщения работы с намерениями -----------------------------------------------
  271. INTENTS_INFO_MESSAGE = ("Это ваши намерения."
  272. "\nИспользуйте их, чтобы получить более точные результаты запроса."
  273. "\nНапример в счётчиках и при получении списка изменений."
  274. "\nОни будут бережно хранися здесь для вашего удобства."
  275. )
  276. SET_INTENT_NAME_MESSAGE = ("✏️ Теперь дайте имя вашему намерению."
  277. "\nТак вы сможете отличать его от других намерений в списке."
  278. "\nТакже это имя будет видно в клавиатуре."
  279. "\nДавайте напишем что-нбиудь осмысленное от 3-х до 15-ти символов."
  280. "\n\nЕсли вы передумали, воспользуйтесь командой /cancel."
  281. )
  282. PARSE_INTENT_MESSAGE = ("✏️ Отлично! Теперь давайте опишем намерения."
  283. "\nВы помните как составлять запросы?"
  284. "\nТут такой же принцип. Вы словно замораживаете запрос в намерение."
  285. "\nМожете воспользоваться классами, уроками, днями, кабинетами."
  286. "\n\n🔶 Некоторые примеры:"
  287. "\n-- Вторник матем"
  288. "\n-- 9в 312"
  289. "\n\nЕсли вы подзабыли как писать запросы - /tutorial"
  290. "\n/cancel - Если вы Передумали добавлять намерение."
  291. )
  292. INTENTS_REMOVE_MANY_MESSAGE = ("🧹 Режим удаления намерений"
  293. "\nВам надоели все ваши намерения и вы быстро хотите навести порядок?"
  294. "\nЭтот инструмент для вас!"
  295. "\nПросто нажмите на название намерения и оно исчезнет."
  296. "\nТакже по нажатию на одну кнопку вы можете удалить всё."
  297. "\nБудьте осторожны."
  298. "\n\nНажмите \"завершить\" как наиграетесь."
  299. )
  300. INTENTS_LIMIT_MESSAGE = ("💼 Это ваш предел количества намерений."
  301. "\n🧹 Пожалуйста удалите не используемые намерения,"
  302. "прежде чем добавлять новые в коллекцию."
  303. "\n\n/remove_intents - Для быстрой чистки намерений"
  304. "\nИли воспользуйтесь кнопкой ниже."
  305. )
  306. # Сообщения интерактивного обучения по запросам к расписанию
  307. TUTORIAL_MESSAGES = [
  308. ("💡 Хотите научиться писать запросы?"
  309. "\nНа самом деле всё намно-о-ого легче."
  310. "\nПройдите это простое обучение и убедитесь в этом сами."
  311. "\n\nВы можете пройти обучение с как самого начала,"
  312. "так и выбрать интересующую вас страницу."
  313. ),
  314. ("1. Будьте проще"
  315. "\n\nВсё стремится к простоте. Запросы не исключение."
  316. "\nТак ли нужно указывать все эти \"посторонние\" слова?"
  317. "\nНет, совсем не обязательно! Они никак не влияют на запрос."
  318. "\n\n🔶 Вот несколько простых примеров, чтобы понять о чём рень:"
  319. "\n\n❌ уроки на завтра"
  320. "\n✅ Завтра"
  321. "\n\n❌ Расписание для 9в на вторник"
  322. "\n✅ 9в вторник"
  323. "\n\nПорядок ключевых слов не имеет значение."
  324. "\n🌲 матем 8в = 8в матем"
  325. "\nБыла ли матемитака в 8в или 9в в математике для нас не важно."
  326. ),
  327. ("2. Классы"
  328. "\n\nНачнём с самого простого - классов."
  329. "\nХотите расписание - просто напишите нужный класс."
  330. "\n\n-- 7г 6а ➜ можно сразу для нескольких классов."
  331. "\nЕсли вы не укажите день, то получите расписание на сегодня/завтра."
  332. "\n🔸 На сегодня - если уроки ещё идут."
  333. "\n🔸 На завтра - если сегодня уроки уже кончились."
  334. "\n\n🔎 Ещё классы используются в поиске по уроку/кабинету:"
  335. "\n\n- химия 8б ➜ Все уроки химии для 9б."
  336. "\n- 9в 312 ➜ Все уроки в 312 кабинете для 9в"
  337. "\n\n💡 Посмотреть список всех классов можно в статусе:"
  338. "\n-- По кнопке \"ещё\" в главном меню."
  339. "\n-- По команде /info"
  340. ),
  341. ("3. Дни недели"
  342. "\n\nВы можете более явно указать дни недели в запросах и поиске."
  343. "\nЕсли указаать только день, то получите расписание для вашего класса."
  344. "\nОднако если вы предпочли не указывать класс по умолчанию,"
  345. "То получите достаточно интересный результат"
  346. "\n\n✏️ Как вы можете укзаать дни"
  347. "\n-- Понедельник - суббота."
  348. "\n-- пн - сб."
  349. "\n-- Сегодня, завтра, неделя."
  350. "\n\n-- вт ➜ Расписание для вашего класса по умолчанию на вторник."
  351. "\n\nНапоминаем что не обязательно указывать \"посторонние\" слова"
  352. "\n❌ Уроки для 5г на среду"
  353. "\n✅ 5г среда"
  354. "\n\n🔎 В поиске если день не указан, то результат выводится на неделю."
  355. "\n-- матем вт ➜ Все уроки математики на вторник"
  356. "\n-- пт 312 ➜ Все уроки в 312 кабинете на пятницу"
  357. ),
  358. ("4. Поиск по урокам"
  359. "\n\n🔎 Укажите точное название урока для его поиска в расписании."
  360. "\nЕсли не указаны прочие параметры, расписание для всех на неделю."
  361. "\n\n✏️ Вы можете указать класс, день, кабинет в параметрах."
  362. "\n\n-- матем ➜ Вся математика за неделю для всех классов."
  363. "\n-- химия вторник 10а ➜ Более точный поиск."
  364. "\n\n⚠️ Если ввести несколько уроков, будет взят только первый."
  365. "\nЧтобы результат поиска не было слишком длинным."
  366. "\n\n💡 Посмотреть все классы можно в счётчиках:"
  367. "\n-- По кнопке \"Ещё\" ➜ \"Счётчики\""
  368. "\n-- По команде /counter"
  369. ),
  370. ("5. Поиск по кабинетам"
  371. "\n🔎 Укажите кабинет, чтобы взглянуть на расписание от его лица."
  372. "\nЕсли прочие параметры не указаны, расписание для всех на неделю."
  373. "\n\n✏️ Вы можете указать класс, день, урок в параметрах."
  374. "\n\n-- 328 ➜ Всё что проходит в 328 кабинете за неделю."
  375. "\n-- 312 литер вторник 7а ➜ Более точный поиск."
  376. "\n\n⚠️ Если указать несколько кабинетов, будет взят только первый."
  377. "\nЧтобы результат поиска не был слишком длинным."
  378. "\nОднако можно указать несколько предметов в поиске по кабинету."
  379. "\n\n💡 Посмотреть все кабинеты можно в счётчиках:"
  380. "\n-- По кнопке \"Ещё\" ➜ \"Счётчики\" ➜ \"По урокам\""
  381. "\n-- По команде /counter ➜ \"По урокам\""
  382. ),
  383. ("6. Групповые чаты"
  384. "\n\n🌟 Вы можете добавить бота в ваш чатик."
  385. "\nДля того чтобы использовать бота вместе."
  386. "\nКласс уставливается один на весь чат."
  387. "\n\n🌲 Вот некоторые особенности при использовании в чате:"
  388. "\n\n/set_class [класс] - чтобы установить класс в чате."
  389. "\nИли ответьте классом на сообщение бота (9в)."
  390. "\n\n✏️ Чтобы писать запросы в чате, используйте команду /sc [запрос]"
  391. "\nИли ответьте запросом на сообщение бота."
  392. "\n\n⚙️ Имейте ввиду что доступ к боту имеют все участники чата."
  393. "\nЭто также касается и настроек бота."
  394. ),
  395. ("🎉 Поздравляем с прохождением обучения!"
  396. "\nТеперь вы знаете всё о составлении запросов к расписанию."
  397. "\nПриятного вам использования бота."
  398. "\nВы умничка. ❤️"
  399. )
  400. ]
  401. # Динамические клавиатуры
  402. # =======================
  403. # Основные клавиатуры ----------------------------------------------------------
  404. def get_other_keyboard(
  405. cl: Optional[str]=None, home_button: Optional[bool] = True
  406. ) -> InlineKeyboardMarkup:
  407. """Собирает дополнительную клавиатуру.
  408. Дополнительная клавиатура содержит не часто использумые функции.
  409. Чтобы эти разделы не занимали место на главном экране и не пугали
  410. пользователей большим количеством разных кнопочек.
  411. Buttons:
  412. set_class => Сменить класс.
  413. count:lessons:main: => Меню счётчиков бота.
  414. updates:last:0:{cl}: => Последная страница списка изменений.
  415. tutorial:0 => первая страница общей справки.
  416. intents => Раздел настройки намерений пользователя.
  417. home => Вернуться на главную страницу.
  418. Args:
  419. cl (str, Optional): Класс пользователя для клавиатуры.
  420. home_button (bool, optional): Добавлять ли кнопку возврата.
  421. Returns:
  422. InlineKeyboardMarkup: Дополнительная клавиатура.
  423. """
  424. buttons = [
  425. [
  426. InlineKeyboardButton(
  427. text="⚙️ Сменить класс", callback_data="set_class"
  428. ),
  429. InlineKeyboardButton(
  430. text="📊 Счётчики", callback_data="count:lessons:main:"
  431. ),
  432. InlineKeyboardButton(
  433. text="📜 Изменения", callback_data=f"updates:last:0:{cl}:"
  434. ),
  435. ],
  436. [
  437. InlineKeyboardButton(
  438. text="🌟 Обучение", callback_data="tutorial:0"
  439. ),
  440. InlineKeyboardButton(text="💼 Намерения", callback_data="intents"),
  441. ],
  442. ]
  443. if home_button:
  444. buttons[-1].append(
  445. InlineKeyboardButton(text="🏠 Домой", callback_data="home")
  446. )
  447. return InlineKeyboardMarkup(inline_keyboard=buttons)
  448. def get_main_keyboard(cl: Optional[str]=None) -> InlineKeyboardMarkup:
  449. """Возращает главную клавиатуру бота.
  450. Главная клавиатуры предоставляет доступ к самым часто используемым
  451. разделам бота, таким как получение расписания для класса по
  452. умолчанию или настройка оповщеений.
  453. Если пользвоателй не указал класс - возвращается доплнительная
  454. клавиатура, но без кнопки возврата домой.
  455. Buttons:
  456. other => Вызов дополнительной клавиатуры.
  457. notify => Меню настройки уведомлений пользователя.
  458. sc:{cl}:today => Получаени расписания на сегодня для класса.
  459. Args:
  460. cl (str, Optional): Класс для подставновки в клавиатуру.
  461. Returns:
  462. InlineKeyboardMarkup: Главная домашная клавиатура.
  463. """
  464. if cl is None:
  465. return get_other_keyboard(cl, home_button=False)
  466. return InlineKeyboardMarkup(
  467. inline_keyboard=[
  468. [
  469. InlineKeyboardButton(text="🔧 Ещё", callback_data="other"),
  470. InlineKeyboardButton(
  471. text="🔔 Уведомления", callback_data="notify"
  472. ),
  473. InlineKeyboardButton(
  474. text=f"📚 Уроки {cl}", callback_data=f"sc:{cl}:today"
  475. ),
  476. ]
  477. ]
  478. )
  479. # Для расписания уроков --------------------------------------------------------
  480. def get_week_keyboard(cl: str) -> InlineKeyboardMarkup:
  481. """Возращает клавиатуру, для получение расписания на неделю.
  482. Используется в сообщении с расписанием уроков.
  483. Когда режии просмотра выставлен "на сегодня".
  484. Также содержит кнопки для возврата домой и выбора дня недели.
  485. Buttons:
  486. home => Возврат на главный экран.
  487. sc:{cl}:week => Получить расписание на неедлю для класса.
  488. select_day:{cl} => Выбрать день недели для расписания.
  489. Args:
  490. cl (str, Optional): Класс для подставновки в клавиатуру.
  491. Return:
  492. InlineKeyboardMarkup: Клавиатуру для сообщения с расписанием.
  493. """
  494. return InlineKeyboardMarkup(
  495. inline_keyboard=[
  496. [
  497. InlineKeyboardButton(text="🏠Домой", callback_data="home"),
  498. InlineKeyboardButton(
  499. text="На неделю", callback_data=f"sc:{cl}:week"
  500. ),
  501. InlineKeyboardButton(
  502. text="▷", callback_data=f"select_day:{cl}"
  503. )
  504. ]
  505. ]
  506. )
  507. def get_sc_keyboard(cl: str) -> InlineKeyboardMarkup:
  508. """Возаращает клавиатуру, для получения расписания на сегодня.
  509. Используется в сообщениях с расписанием уроков.
  510. Когда режии просмотра выставлен "на неделю".
  511. Также содержит кнопки для возврата домой и выбора дня недели.
  512. Buttons:
  513. home => Возврат в домашний раздел.
  514. sc:{cl}:today => Получить расписание на сегодня для класса.
  515. select_day:{cl} => Выбрать день недели для расписания.
  516. Args:
  517. cl (str): Класс для подставновки в клавиатуру.
  518. Return:
  519. InlineKeyboardMarkup: Клавиатуру для просмотра расписания.
  520. """
  521. return InlineKeyboardMarkup(
  522. inline_keyboard=[
  523. [
  524. InlineKeyboardButton(text="🏠Домой", callback_data="home"),
  525. InlineKeyboardButton(
  526. text="На сегодня", callback_data=f"sc:{cl}:today"
  527. ),
  528. InlineKeyboardButton(text="▷", callback_data=f"select_day:{cl}")
  529. ]
  530. ]
  531. )
  532. def get_select_day_keyboard(cl: str) -> InlineKeyboardMarkup:
  533. """Возаращает клавиатуру выбора дня недели в рассписания.
  534. Мспользуется в сообщения с расписанием.
  535. Позволяет выбрать один из дней недели.
  536. Автоматически подставляя укзааный класс в запрос.
  537. Buttons:
  538. sc:{cl}:{0..6} => Получить расписания для укзаанного дня.
  539. sc:{cl}:today => Получить расписание на сегодня.
  540. sc:{cl}:week => получить расписание на неделю.
  541. Args:
  542. cl (str): Класс для подставноки в клавиатуру.
  543. Returns:
  544. InlineKeyboardMarkup: Клаавиатура для выбра дня расписания.
  545. """
  546. return InlineKeyboardMarkup(
  547. inline_keyboard=[
  548. [
  549. InlineKeyboardButton(text=x, callback_data=f"sc:{cl}:{i}")
  550. for i, x in enumerate(days_names)
  551. ],
  552. [
  553. InlineKeyboardButton(text="◁", callback_data="home"),
  554. InlineKeyboardButton(
  555. text="Сегодня", callback_data=f"sc:{cl}:today"
  556. ),
  557. InlineKeyboardButton(
  558. text="Неделя", callback_data=f"sc:{cl}:week"
  559. ),
  560. ],
  561. ]
  562. )
  563. # Клавиатуры разделов ----------------------------------------------------------
  564. def get_notify_keyboard(enabled: bool, hours: list[int]
  565. ) -> InlineKeyboardMarkup:
  566. """Возвращет клавиатуру для настройки уведомлений.
  567. Используется для управления оповещениями.
  568. Позволяет включить/отключить уведомления.
  569. Настроить дни для рассылки расписания.
  570. Сброисить все часы рассылки расписания.
  571. Buttons:
  572. notify:on:0 => Включить уведомления бота.
  573. notify:off:0 => Отключить уведомления бота.
  574. notify:reset:0 => Сбросить часы для рассылки расписния.
  575. notify:add:{hour} => Включить рассылку для указанного часа.
  576. notify:remove:{hour} => Отключить рассылку для указанного часа.
  577. Args:
  578. enabled (bool): Включены ли уведомления у пользователя.
  579. hours (list[int]): В какой час рассылать расписание.
  580. Returns:
  581. InlineKeyboardMarkup: Клавиатура для настройки уведомлений.
  582. """
  583. inline_keyboard = [[InlineKeyboardButton(text="◁", callback_data="home")]]
  584. if not enabled:
  585. inline_keyboard[0].append(InlineKeyboardButton(
  586. text="🔔 Включить", callback_data="notify:on:0"
  587. )
  588. )
  589. else:
  590. inline_keyboard[0].append(InlineKeyboardButton(
  591. text="🔕 Выключить", callback_data="notify:off:0"
  592. )
  593. )
  594. if hours:
  595. inline_keyboard[0].append(InlineKeyboardButton(
  596. text="❌ Сброс", callback_data="notify:reset:0"
  597. )
  598. )
  599. hours_line = []
  600. for i, x in enumerate(range(6, 24)):
  601. if x % 6 == 0:
  602. inline_keyboard.append(hours_line)
  603. hours_line = []
  604. if x in hours:
  605. hours_line.append(
  606. InlineKeyboardButton(
  607. text=f"✔️{x}", callback_data=f"notify:remove:{x}"
  608. )
  609. )
  610. else:
  611. hours_line.append(InlineKeyboardButton(
  612. text=str(x), callback_data=f"notify:add:{x}"
  613. )
  614. )
  615. if len(hours_line):
  616. inline_keyboard.append(hours_line)
  617. return InlineKeyboardMarkup(inline_keyboard=inline_keyboard)
  618. def get_updates_keyboard(
  619. page: int, updates: list, cl: Optional[str],
  620. intents: UserIntents, intent_name: str = ""
  621. ) -> InlineKeyboardMarkup:
  622. """Возвращает клавиатуру, для просмотра списка изменений.
  623. Используется для перемещения по списку изменений в расписании.
  624. Также может переключать режим просмотре с общего на для класса.
  625. Использует клавиатуру выбора намерений, для уточнения результатов.
  626. Buttons:
  627. home => Возврат к главному меня бота.
  628. updates:back:{page}:{cl} => Перещается на одну страницу назад.
  629. updates:switch:0:{cl} => Переключает режим просмотра расписания.
  630. updates:next:{page}:{cl} => Перемещается на страницу вперёд.
  631. updates:last:0:{cl} => Перерключиться на последную страницу.
  632. Args:
  633. page (int): Номер текущей страницы списка обновлений.
  634. updates (list): Список всех страниц списка изменений.
  635. cl (str, optional): Класс для подстановки в клавиатуру.
  636. intents (UserIntents): Экземпляр намерений пользователя.
  637. intent_name (str, Optional): Название текущего
  638. намерения пользователя
  639. Returns:
  640. InlineKeyboardMarkup: Клавиатура просмотра списка изменений.
  641. """
  642. # базовая клавиатура
  643. inline_keyboard = [
  644. [
  645. InlineKeyboardButton(text="🏠", callback_data="home"),
  646. InlineKeyboardButton(
  647. text="◁",
  648. callback_data=f"updates:back:{page}:{cl}:{intent_name}"
  649. ),
  650. InlineKeyboardButton(
  651. text=f"{page+1}/{len(updates)}",
  652. callback_data=f"updates:switch:0:{cl}:{intent_name}",
  653. ),
  654. InlineKeyboardButton(
  655. text="▷",
  656. callback_data=f"updates:next:{page}:{cl}:{intent_name}"
  657. ),
  658. ]
  659. ]
  660. # Доплнительная клавиатура выбора намерения
  661. for i, x in enumerate(intents.get()):
  662. if i % 3 == 0:
  663. inline_keyboard.append([])
  664. if x.name == intent_name:
  665. inline_keyboard[-1].append(InlineKeyboardButton(
  666. text=f"✅ {x.name}", callback_data=f"updates:last:0:{cl}:")
  667. )
  668. else:
  669. inline_keyboard[-1].append(InlineKeyboardButton(
  670. text=f"⚙️ {x.name}",
  671. callback_data=f"updates:last:0:{cl}:{x.name}"
  672. )
  673. )
  674. return InlineKeyboardMarkup(inline_keyboard=inline_keyboard)
  675. _COUNTERS = (
  676. ("cl", "По классам"),
  677. ("days", "По дням"),
  678. ("lessons", "По урокам"),
  679. ("cabinets", "По кабинетам"),
  680. )
  681. _TARGETS = (
  682. ("none", "Ничего"),
  683. ("cl", "Классы"),
  684. ("days", "Дни"),
  685. ("lessons", "Уроки"),
  686. ("cabinets", "Кабинеты"),
  687. ("main", "Общее"),
  688. )
  689. def get_counter_keyboard(cl: str, counter: str, target: str,
  690. intents: UserIntents, intent_name: Optional[str]=""
  691. ) -> InlineKeyboardMarkup:
  692. """Возвращает клавиатуру, для просмотра счётчиков расписания.
  693. Позволяет просматривать счётчики расписания по группам и целям:
  694. +----------+-------------------------+
  695. | counter | targets |
  696. +----------+-------------------------+
  697. | cl | days, lessons. cabinets |
  698. | days | cl, lessons. cabinets |
  699. | lessons | cl, days, main |
  700. | cabinets | cl, days, main |
  701. +----------+-------------------------+
  702. Исользуется клавиатуру для выбрра намерений.
  703. Чтобы уточнить результаты подсчётов.
  704. Buttons:
  705. home => Вернуться к главному сообщению бота.
  706. count:{counter}:{target} => Переключиться на нужный счётчик.
  707. Args:
  708. cl (str): Класс для подстановки в клавиатуру.
  709. counter (str): Текущая группа счётчиков.
  710. target (str): Текущий тип просмотра счётчика.
  711. intents (UserIntents): Экземпляр намерений пользователя.
  712. intent_name (str, optional): Текущее выбранное намерение.
  713. Returns:
  714. InlineKeyboardMarkup: Клавиатура для просмотра счётчиков.
  715. """
  716. inline_keyboard = [[
  717. InlineKeyboardButton(text="◁", callback_data="home")
  718. ],
  719. []
  720. ]
  721. for k, v in _COUNTERS:
  722. if counter == k:
  723. continue
  724. inline_keyboard[0].append(
  725. InlineKeyboardButton(
  726. text=v,
  727. callback_data=f"count:{k}:{target}:{intent_name}"
  728. )
  729. )
  730. for k, v in _TARGETS:
  731. if k in (target, counter):
  732. continue
  733. if k == "main" and counter not in ("lessons", "cabinets"):
  734. continue
  735. if counter in ("lessons", "cabinets") and k in ("lessons", "cabinets"):
  736. continue
  737. if counter == "cl" and k == "lessons" and not cl:
  738. continue
  739. inline_keyboard[1].append(
  740. InlineKeyboardButton(
  741. text=v,
  742. callback_data=f"count:{counter}:{k}:{intent_name}"
  743. )
  744. )
  745. # Добавляем клавиатуру выбора намерений
  746. for i, x in enumerate(intents.get()):
  747. if i % 3 == 0:
  748. inline_keyboard.append([])
  749. if x.name == intent_name:
  750. inline_keyboard[-1].append(
  751. InlineKeyboardButton(
  752. text=f"✅ {x.name}",
  753. callback_data=f"count:{counter}:{target}:"
  754. )
  755. )
  756. else:
  757. inline_keyboard[-1].append(
  758. InlineKeyboardButton(
  759. text=f"⚙️ {x.name}",
  760. callback_data=f"count:{counter}:{target}:{x.name}"
  761. )
  762. )
  763. return InlineKeyboardMarkup(inline_keyboard=inline_keyboard)
  764. def get_tutorial_keyboard(page: int) -> InlineKeyboardMarkup:
  765. """Клавиатура многостраничного обучения.
  766. Используется для перемещения между страницами обучения.
  767. Содержит кнопку для запуска и закрытия справки.
  768. Кнопки перемещения на следующую и предыдущую страницы.
  769. Содержание для быстрого переключения страниц.
  770. Buttons:
  771. delete_msg => Удалить сообщение.
  772. tutorual:{page} => Сменить страницу справки.
  773. Args:
  774. page (int): Текущая страница справки.
  775. Returns:
  776. InlineKeyboardMarkup: Клавиатура для перемещения по справке.
  777. """
  778. inline_keyboard = []
  779. # Если это первая страница -> без кнопки назад
  780. if page == 0:
  781. inline_keyboard.append([
  782. InlineKeyboardButton(text="🚀 Начать", callback_data="tutorial:1")
  783. ])
  784. # Кнопкеи для управления просмотром
  785. elif page != len(TUTORIAL_MESSAGES)-1:
  786. inline_keyboard.append([
  787. InlineKeyboardButton(text="◁", callback_data=f"tutorial:{page-1}"),
  788. InlineKeyboardButton(
  789. text="🌟 Дальше", callback_data=f"tutorial:{page+1}"
  790. )
  791. ])
  792. # Крпткое содержание для быстрого перемещения
  793. for i, x in enumerate(TUTORIAL_MESSAGES[1:-1]):
  794. if i+1 == page:
  795. continue
  796. inline_keyboard.append([InlineKeyboardButton(
  797. text=x.splitlines()[0], callback_data=f"tutorial:{i+1}")]
  798. )
  799. inline_keyboard.append([InlineKeyboardButton(
  800. text="❌ Закрыть", callback_data="delete_msg")]
  801. )
  802. # Завершение обучения
  803. else:
  804. inline_keyboard.append([
  805. InlineKeyboardButton(
  806. text="🎉 Завершить", callback_data="delete_msg"
  807. )
  808. ])
  809. return InlineKeyboardMarkup(inline_keyboard=inline_keyboard)
  810. # Обработка намерений ----------------------------------------------------------
  811. def get_intents_keyboard(intents: list[IntentObject]) -> InlineKeyboardMarkup:
  812. """Отправляет клавиатуру редактора намерений.
  813. Используется в главном сообщении редактора.
  814. Позволяет получить доступ к каждому намерению.
  815. Добавить новое намерение, если не превышем лимит.
  816. Или перейти в режим быстрого удаления.
  817. Buttons:
  818. intent:show:{name} => Покзаать информацию о намерении.
  819. intents:remove_mode => Перейти в режим быстрого удаления.
  820. intent:add: => Добавить новое намерение.
  821. home => Вернуться на главный экран.
  822. Args:
  823. intents (list[IntentObject]): Намерения пользователя.
  824. Returns:
  825. InlineKeyboardMarkup: Клавиатура редактора намерений.
  826. """
  827. inlene_keyboard = [[]]
  828. if len(intents):
  829. for i, x in enumerate(intents):
  830. if i % 3 == 0:
  831. inlene_keyboard.append([])
  832. inlene_keyboard[-1].append(InlineKeyboardButton(
  833. text=x.name, callback_data=f"intent:show:{x.name}"
  834. )
  835. )
  836. inlene_keyboard.append([InlineKeyboardButton(
  837. text="🧹 удалить", callback_data="intents:remove_mode"
  838. )
  839. ])
  840. if len(intents) < _MAX_INTENTS:
  841. inlene_keyboard[-1].append(InlineKeyboardButton(
  842. text="➕ Добавить", callback_data="intent:add:"
  843. )
  844. )
  845. inlene_keyboard[-1].append(
  846. InlineKeyboardButton(text="🏠 Домой", callback_data="home")
  847. )
  848. return InlineKeyboardMarkup(inline_keyboard=inlene_keyboard)
  849. def get_edit_intent_keyboard(intent_name: str) -> InlineKeyboardMarkup:
  850. """Возвращает клавиатуру редактора намерения.
  851. Исползуется для управления намерением пользвоателя.
  852. Позволяет изменить имя или параметры намерения, а также удалить его.
  853. Buttons:
  854. intent:reparse:{name} => Изменить параметры намерения.
  855. intent:remove:{name} => Удалить намерение.
  856. intents => Вернуться к списку намерений.
  857. Args:
  858. intent_name (str): Имя намерения для редактирования
  859. Returns:
  860. InlineKeyboardMarkup: Клавиатура редактирования намерения.
  861. """
  862. return InlineKeyboardMarkup(inline_keyboard=[[
  863. InlineKeyboardButton(
  864. text="✏️ Изменить", callback_data=f"intent:reparse:{intent_name}"
  865. )
  866. ],
  867. [
  868. InlineKeyboardButton(text="<", callback_data="intents"),
  869. InlineKeyboardButton(
  870. text="🗑️ Удалить", callback_data=f"intent:remove:{intent_name}"
  871. )
  872. ]])
  873. def get_remove_intents_keyboard(intents: list[IntentObject]
  874. ) -> InlineKeyboardMarkup:
  875. """Возаращает клавиатуру быстрого удаления намерений.
  876. Используется когда необходимо удалить много намерений.
  877. Позволяет удалять намерения по нажатию на название.
  878. Также позволяет удалить все намерения пользователя.
  879. Buttons:
  880. intent:remove_many:{name} => Удаляет намерение пользователя.
  881. intents => Вернуться к списку намерений.
  882. intents:remove_all => Удаляет все намерения пользователя.
  883. Args:
  884. intents (list[IntentObject]): Список намерений пользователя.
  885. Returns:
  886. InlineKeyboardMarkup: Клавиатура быстрого удаления намерений.
  887. """
  888. inlene_keyboard = [[]]
  889. if len(intents):
  890. for i, x in enumerate(intents):
  891. if i % 3 == 0:
  892. inlene_keyboard.append([])
  893. inlene_keyboard[-1].append(
  894. InlineKeyboardButton(
  895. text=x.name, callback_data=f"intent:remove_many:{x.name}"
  896. )
  897. )
  898. inlene_keyboard.append([
  899. InlineKeyboardButton(
  900. text="🧹 Удалить все", callback_data="intents:remove_all"
  901. )
  902. ])
  903. inlene_keyboard[-1].append(
  904. InlineKeyboardButton(text="✅ Завершить", callback_data="intents")
  905. )
  906. return InlineKeyboardMarkup(inline_keyboard=inlene_keyboard)
  907. # Динамический сообщения
  908. # ======================
  909. def get_intent_status(i: Intent) -> str:
  910. """Отображает краткую информацию о содержимом намерения.
  911. Формат: < {классы} / {дни} / {уроки} / {кабинеты} >
  912. Args:
  913. i (Intent): Намерение для отображения статуса
  914. Returns:
  915. str: Краткое описание намерения.
  916. """
  917. message = "<"
  918. for group in (i.cl, i.days, i.lessons, i.cabinets):
  919. for x in group:
  920. message += f" {x}"
  921. message += " /"
  922. message += " >"
  923. return message
  924. def get_update_timetag(path: Path) -> int:
  925. """Получает время последней удачной проверки обнолвений.
  926. Вспомогательная функция.
  927. Время успешой проверки используется для контроля скрипта обновлений.
  928. Если время последней проверки будет дольше одного часа,
  929. то это повод задуматься о правильноти работы скрипта.
  930. Args:
  931. path (Path): Путь к файлу временной метки обновлений.
  932. Returns:
  933. int: UNIXtime последней удачной проверки обновлений.
  934. """
  935. try:
  936. with open(path) as f:
  937. return int(f.read())
  938. except (ValueError, FileNotFoundError):
  939. return 0
  940. def get_status_message(sp: SPMessages, timetag_path: Path) -> str:
  941. """Отправляет информационно сособщение о работа бота и парсера.
  942. Инфомарционно сообщения содержит некоторую вспомогательную
  943. информацию относительно статуса и работаспособности бота.
  944. К примеру версия бота, время последнего обновления,
  945. классов и прочее.
  946. Также осдержит метку последнего автоматического обновления.
  947. Если давно не было автообновлений - выводит предупреждение.
  948. Args:
  949. sp (SPMessages): Экземпляр генератора сообщений.
  950. timetag_path (Path): Путь к файлу временной метки обновления.
  951. Returns:
  952. str: Информацинное сообщение.
  953. """
  954. message = sp.send_status()
  955. message += f"\n⚙️ Версия бота: {_BOT_VERSION}\n🛠️ Тестер @micronuri"
  956. timetag = get_update_timetag(timetag_path)
  957. timedelta = int(datetime.now().timestamp()) - timetag
  958. message += f"\n📀 Проверка была {get_str_timedelta(timedelta)} назад"
  959. if timedelta > _ALERT_AUTOUPDATE_AFTER_SECONDS:
  960. message += ("\n⚠️ Автоматическая проверка была более часа назад."
  961. "\nПожалуйста свяжитесь с администратором бота."
  962. )
  963. return message
  964. def get_home_message(cl: str) -> str:
  965. """Отпраляет главное сообщение бота.
  966. Главное сообщение будет сопровождать пользователя всегда.
  967. Оно содержит краткую необходимую информацию.
  968. В шапке сообщения указывается ваш класс по умолчанию.
  969. В теле сообщения содержится краткая справка по использованию бота.
  970. Если вы не привязаны к классу, справка немного отличается.
  971. Args:
  972. cl (str): Для какого класса получить сообщение.
  973. Returns:
  974. str: Готовое главное сообщение бота.
  975. """
  976. if cl:
  977. message = f"💎 Ваш класс {cl}"
  978. else:
  979. message = "🌟 Вы не привязаны к классу."
  980. message += f"\n\n{HOME_MESSAGE}"
  981. return message
  982. def get_notify_message(enabled: bool, hours: list[int]) -> str:
  983. """Отправляет сообщение с информацией о статусе уведомлений.
  984. Сообщение о статусе уведомлений содержит в себе:
  985. Включены ли сейчас уведомления.
  986. Краткая инфомрация об уведомленях.
  987. В какие часы рассылается расписание уроков.
  988. Args:
  989. enabled (bool): Включены ли уведомления пользователя.
  990. hours (list[int]): В какие часы отправлять уведомления.
  991. Returns:
  992. str: Сообщение с информацией об уведомлениях.
  993. """
  994. if enabled:
  995. message = ("🔔 уведомления включены."
  996. "\nВы получите уведомление, если расписание изменится."
  997. "\n\nТакже вы можете настроить отправку расписания."
  998. "\nВ указанное время бот отправит расписание вашего класса."
  999. )
  1000. if len(hours) > 0:
  1001. message += "\n\nРасписание будет отправлено в: "
  1002. message += ", ".join(map(str, set(hours)))
  1003. else:
  1004. message = "🔕 уведомления отключены.\nНикаких лишних сообщений."
  1005. return message
  1006. def get_counter_message(
  1007. sc: Schedule, counter: str, target: str, intent: Optional[Intent]=None
  1008. ) -> str:
  1009. """Собирает сообщение с результатами работы счётчиков.
  1010. В зависимости от выбранного счётчика использует соответствующую
  1011. функцию счётчика.
  1012. +----------+-------------------------+
  1013. | counter | targets |
  1014. +----------+-------------------------+
  1015. | cl | days, lessons. cabinets |
  1016. | days | cl, lessons. cabinets |
  1017. | lessons | cl, days. main |
  1018. | cabinets | cl, days. main |
  1019. +----------+-------------------------+
  1020. Args:
  1021. sc (Schedule): Экземпляр расписания уроков.
  1022. counter (str): Тип счётчика.
  1023. target (str): Группа просмтора счётчика.
  1024. intent (Intent): Намерение для уточнения результатов счётчика.
  1025. Returns:
  1026. str: Сообщение с результаатми счётчика.
  1027. """
  1028. message = f"✨ Счётчик {counter}/{target}:"
  1029. if intent is not None:
  1030. message += f"\n⚙️ {get_intent_status(intent)}"
  1031. else:
  1032. intent = Intent()
  1033. if counter == "cl":
  1034. if target == "lessons":
  1035. intent = intent.reconstruct(sc, cl=sc.cl)
  1036. res = cl_counter(sc, intent)
  1037. elif counter == "days":
  1038. res = days_counter(sc, intent)
  1039. elif counter == "lessons":
  1040. res = index_counter(sc, intent)
  1041. else:
  1042. res = index_counter(sc, intent, cabinets_mode=True)
  1043. if target == "none":
  1044. target = None
  1045. message += send_counter(group_counter_res(res), target=target)
  1046. return message
  1047. def get_updates_message(
  1048. update: Optional[list]=None, cl: Optional[str]=None,
  1049. intent: Optional[Intent]=None
  1050. ) -> str:
  1051. """Собирает сообщение со страницей списка изменений расписания.
  1052. Args:
  1053. update (list, Optional): Странциа списка изменений расписания.
  1054. cl (str, Optional): Для какого класса представлены изменения.
  1055. intent (Intent, Optional): Намерение для уточнения результата.
  1056. Returns:
  1057. str: Сообщение со страницей списка изменений.
  1058. """
  1059. message = "🔔 Изменения "
  1060. message += " в расписании:\n" if cl is None else f" для {cl}:\n"
  1061. if intent is not None:
  1062. message += f"⚙️ {get_intent_status(intent)}\n"
  1063. if update is not None:
  1064. update_text = send_update(update, cl=cl)
  1065. if len(update_text) > _MAX_UPDATE_MESSAGE_LENGTHT:
  1066. message += "\n📚 Слишком много изменений."
  1067. else:
  1068. message += update_text
  1069. else:
  1070. message += "✨ Нет новых обновлений."
  1071. return message
  1072. # Обработка намерений ----------------------------------------------------------
  1073. def get_intent_info(name: str, i: Intent) -> str:
  1074. """Возвращает подробное содержимое намерения.
  1075. Args:
  1076. name (str): Имя намерения.
  1077. i (Intent): Экземпляр намерения.
  1078. Returns:
  1079. str: Подробная информация о намерении.
  1080. """
  1081. return (f"💼 Намерение \"{name}\":"
  1082. f"\n\n🔸 Классы: {', '.join(i.cl)}"
  1083. f"\n🔸 Дни: {', '.join([days_names[x] for x in i.days])}"
  1084. f"\n🔸 Уроки: {', '.join(i.lessons)}"
  1085. f"\n🔸 Кабинеты: {', '.join(i.cabinets)}"
  1086. )
  1087. def get_intents_message(intents: list[IntentObject]) -> str:
  1088. """Отправляет главное сообщение редактора намерений.
  1089. Оно используется чтобы представить список ваших намерений.
  1090. Для чего нужны намерения и что вы можете сделать в редакторе.
  1091. Args:
  1092. intents (list[IntentObject]): Список намерений пользователя.
  1093. Args:
  1094. str: Главное сообщение редактора намерений.
  1095. """
  1096. message = f"💼 Ваши намерения.\n\n{INTENTS_INFO_MESSAGE}\n"
  1097. if len(intents) == 0:
  1098. message += "\n\nУ вас пока нет намерений."
  1099. else:
  1100. for x in intents:
  1101. message += f"\n🔸 {x.name}: {get_intent_status(x.intent)}"
  1102. if len(intents) < _MAX_INTENTS:
  1103. message += ("\n\n✏️ /add_intent - Добавить новое намерение."
  1104. "\nИли использовать кнопку ниже."
  1105. )
  1106. return message
  1107. # Обработчики команд
  1108. # ==================
  1109. # Простая отправка сообщений -------------------------------------------
  1110. @dp.message(Command("cl_features"))
  1111. async def restrictions_handler(message: Message) -> None:
  1112. """Отправляет список примуществ при указанном классе."""
  1113. await message.answer(text=CL_FEATURES_MESSAGE)
  1114. @dp.message(Command("tutorial"))
  1115. async def tutorial_handler(message: Message) -> None:
  1116. """Отправляет интерактивное обучение по составлению запросов."""
  1117. await message.delete()
  1118. await message.answer(
  1119. text=TUTORIAL_MESSAGES[0],
  1120. reply_markup=get_tutorial_keyboard(0)
  1121. )
  1122. @dp.message(Command("info"))
  1123. async def info_handler(message: Message, sp: SPMessages) -> None:
  1124. """Сообщение о статусе рабты бота и парсера."""
  1125. await message.answer(
  1126. text=get_status_message(sp, _TIMETAG_PATH),
  1127. reply_markup=get_other_keyboard(sp.user["class_let"]),
  1128. )
  1129. # Help команда ---------------------------------------------------------
  1130. @dp.message(Command("help", "start"))
  1131. async def start_handler(message: Message, sp: SPMessages) -> None:
  1132. """Отправляет сообщение справки и главную клавиатуру.
  1133. Если класс не указан, отпраляет сообщение указания класса.
  1134. """
  1135. await message.delete()
  1136. if sp.user["set_class"]:
  1137. await message.answer(
  1138. text=get_home_message(sp.user["class_let"]),
  1139. reply_markup=get_main_keyboard(sp.user["class_let"]),
  1140. )
  1141. else:
  1142. await message.answer(SET_CLASS_MESSAGE, reply_markup=PASS_SET_CL_MARKUP)
  1143. # Изменение класса пользователя ----------------------------------------
  1144. @dp.message(Command("set_class"))
  1145. async def set_class_command(message: Message, sp: SPMessages,
  1146. command: CommandObject
  1147. ) -> None:
  1148. """Изменяет класс или удаляет данные о пользователе."""
  1149. if command.args is not None:
  1150. if sp.set_class(command.args):
  1151. await message.answer(
  1152. text=get_home_message(command.args),
  1153. reply_markup=get_main_keyboard(command.args)
  1154. )
  1155. else:
  1156. text = "👀 Такого класса не существует."
  1157. text += f"\n💡 Доступныe классы: {', '.join(sp.sc.lessons)}"
  1158. await message.answer(text=text)
  1159. else:
  1160. sp.reset_user()
  1161. await message.answer(
  1162. text=SET_CLASS_MESSAGE,
  1163. reply_markup=PASS_SET_CL_MARKUP
  1164. )
  1165. @dp.message(Command("pass"))
  1166. async def pass_handler(message: Message, sp: SPMessages) -> None:
  1167. """Отвязывает пользователя от класса по умолчанию."""
  1168. sp.set_class(None)
  1169. await message.answer(
  1170. text=get_home_message(sp.user["class_let"]),
  1171. reply_markup=get_main_keyboard(sp.user["class_let"]),
  1172. )
  1173. # Переход к разделам бота ----------------------------------------------
  1174. @dp.message(Command("updates"))
  1175. async def updates_handler(message: Message, sp: SPMessages,
  1176. intents: UserIntents
  1177. ) -> None:
  1178. """Оправляет последную страницу списка изменений в расписании."""
  1179. updates = sp.sc.updates
  1180. await message.answer(
  1181. text=get_updates_message(updates[-1] if len(updates) else None),
  1182. reply_markup=get_updates_keyboard(max(len(updates) - 1, 0),
  1183. updates, None, intents
  1184. )
  1185. )
  1186. @dp.message(Command("counter"))
  1187. async def counter_handler(message: Message, sp: SPMessages,
  1188. intents: UserIntents
  1189. ) -> None:
  1190. """Переводит в меню просмора счётчиков расписания."""
  1191. await message.answer(
  1192. text=get_counter_message(sp.sc, "lessons", "main"),
  1193. reply_markup=get_counter_keyboard(
  1194. cl=(sp.user["class_let"]),
  1195. counter="lessons",
  1196. target="main",
  1197. intents=intents
  1198. ),
  1199. )
  1200. @dp.message(Command("notify"))
  1201. async def notify_handler(message: Message, sp: SPMessages):
  1202. """Переводит в менюя настройки уведомлений."""
  1203. enabled = sp.user["notifications"]
  1204. hours = sp.user["hours"]
  1205. await message.answer(
  1206. text=get_notify_message(enabled, hours),
  1207. reply_markup=get_notify_keyboard(enabled, hours),
  1208. )
  1209. # Обработка намерений
  1210. # ===================
  1211. @dp.message(Command("cancel"))
  1212. async def cancel_handler(message: Message, state: FSMContext) -> None:
  1213. """Cбрасывает состояние контекста машины состояний."""
  1214. current_state = await state.get_state()
  1215. if current_state is None:
  1216. return
  1217. await state.clear()
  1218. await message.answer("Отменено.")
  1219. class EditIntentStates(StatesGroup):
  1220. """Состояния изменения намерения.
  1221. States:
  1222. name => Выбор имение намерения.
  1223. parse => Выбор параментов намерения.
  1224. """
  1225. name = State()
  1226. parse = State()
  1227. class IntentCallback(CallbackData, prefix="intent"):
  1228. """Управляет намерением.
  1229. action (str): Что сделать с намерением.
  1230. name (str): Имя намерения.
  1231. Artions:
  1232. add => Добавить новое намерение.
  1233. show => Посмотреть полную информацию о намерении.
  1234. reparse => Именить параметры намерения.
  1235. remove => Удалить намерение.
  1236. """
  1237. action: str
  1238. name: str
  1239. # Получение списка намерений ---------------------------------------------------
  1240. @dp.message(Command("intents"))
  1241. async def manage_intents_handler(message: Message,
  1242. intents: UserIntents
  1243. ) -> None:
  1244. """Команда для просмотра списка намерений пользователя."""
  1245. await message.answer(
  1246. text=get_intents_message(intents.get()),
  1247. reply_markup=get_intents_keyboard(intents.get())
  1248. )
  1249. @dp.callback_query(F.data=="intents")
  1250. async def intents_callback(query: CallbackQuery, intents: UserIntents) -> None:
  1251. """Кнопка для просмотра списка намерений пользователя."""
  1252. await query.message.edit_text(
  1253. text=get_intents_message(intents.get()),
  1254. reply_markup=get_intents_keyboard(intents.get())
  1255. )
  1256. # Добавление нового намерения --------------------------------------------------
  1257. @dp.callback_query(IntentCallback.filter(F.action=="add"))
  1258. async def add_intent_callback(query: CallbackQuery, state: FSMContext) -> None:
  1259. """Начать добавление нового намерения по кнопке."""
  1260. await state.set_state(EditIntentStates.name)
  1261. await query.message.edit_text(SET_INTENT_NAME_MESSAGE)
  1262. @dp.message(Command("add_intent"))
  1263. async def add_intent_handler(
  1264. message: Message, state: FSMContext, intents: UserIntents
  1265. ) -> None:
  1266. """Команда для добавления нового намерения.
  1267. Выводит сообщение при достижении предела количества намерений.
  1268. """
  1269. # Если превышено количество максимальных намерений
  1270. if len(intents.get()) >= _MAX_INTENTS:
  1271. await message.answer(INTENTS_LIMIT_MESSAGE,
  1272. reply_markup=InlineKeyboardMarkup(inline_keyboard=[[
  1273. InlineKeyboardButton(
  1274. text="🗑️ удалить", callback_data="intents:remove_mode")
  1275. ]]))
  1276. else:
  1277. await state.set_state(EditIntentStates.name)
  1278. await message.answer(SET_INTENT_NAME_MESSAGE)
  1279. @dp.message(EditIntentStates.name)
  1280. async def intent_name_handler(message: Message, state: FSMContext) -> None:
  1281. """Устанавливает имя намерения."""
  1282. name = message.text.lower().strip()
  1283. # Если длинна имени больше или меньше нужной
  1284. if len(name) < _MIN_INTENT_NAME or len(name) > _MAX_INTENT_NAME:
  1285. await message.answer(
  1286. text="Имя намерения должно быть от 3-х до 15-ти символов."
  1287. )
  1288. else:
  1289. await state.update_data(name=name)
  1290. await state.set_state(EditIntentStates.parse)
  1291. await message.answer(text=PARSE_INTENT_MESSAGE)
  1292. @dp.message(EditIntentStates.parse)
  1293. async def parse_intent_handler(
  1294. message: Message, state: FSMContext, intents: UserIntents, sp: SPMessages
  1295. ) -> None:
  1296. """Устанавливает парамеры намерения."""
  1297. i = Intent.parse(sp.sc, message.text.lower().strip().split())
  1298. name = (await state.get_data())["name"]
  1299. intents.add(name, i)
  1300. await state.clear()
  1301. await message.answer(
  1302. text=get_intents_message(intents.get()),
  1303. reply_markup=get_intents_keyboard(intents.get())
  1304. )
  1305. # Режим просмотра намерения ----------------------------------------------------
  1306. @dp.callback_query(IntentCallback.filter(F.action=="show"))
  1307. async def show_intent_callback(
  1308. query: CallbackQuery, intents: UserIntents, callback_data: IntentCallback
  1309. ) -> None:
  1310. """Просматривать информацию о намерении."""
  1311. intent = intents.get_intent(callback_data.name)
  1312. if intent is None:
  1313. await query.message.edit_text(text="⚠️ Непраивльное имя намерения")
  1314. else:
  1315. await query.message.edit_text(
  1316. text=get_intent_info(callback_data.name, intent),
  1317. reply_markup=get_edit_intent_keyboard(callback_data.name)
  1318. )
  1319. @dp.callback_query(IntentCallback.filter(F.action=="remove"))
  1320. async def remove_intent_callback(
  1321. query: CallbackQuery, intents: UserIntents, callback_data: IntentCallback
  1322. ) -> None:
  1323. """Удаляет намерение по имени."""
  1324. intents.remove(callback_data.name)
  1325. await query.message.edit_text(
  1326. text=get_intents_message(intents.get()),
  1327. reply_markup=get_intents_keyboard(intents.get())
  1328. )
  1329. @dp.callback_query(IntentCallback.filter(F.action=="reparse"))
  1330. async def reparse_intent_callback(
  1331. query: CallbackQuery, intents: UserIntents, callback_data: IntentCallback,
  1332. state: FSMContext
  1333. ) -> None:
  1334. """Изменение параметров намерения."""
  1335. await state.set_state(EditIntentStates.parse)
  1336. await state.update_data(name=callback_data.name)
  1337. await query.message.edit_text(text=PARSE_INTENT_MESSAGE)
  1338. # Режим удаления намерений -----------------------------------------------------
  1339. @dp.message(Command("remove_intents"))
  1340. async def intents_remove_mode_handler(
  1341. message: Message, intents: UserIntents
  1342. ) -> None:
  1343. """Переключает в режим удаления намерений."""
  1344. await message.answer(
  1345. text=INTENTS_REMOVE_MANY_MESSAGE,
  1346. reply_markup=get_remove_intents_keyboard(intents.get())
  1347. )
  1348. @dp.callback_query(F.data=="intents:remove_mode")
  1349. async def intents_remove_mode_callback(
  1350. query: CallbackQuery, intents: UserIntents
  1351. ) -> None:
  1352. """Переключает в режми удаления намерений."""
  1353. await query.message.edit_text(
  1354. text=INTENTS_REMOVE_MANY_MESSAGE,
  1355. reply_markup=get_remove_intents_keyboard(intents.get())
  1356. )
  1357. @dp.callback_query(IntentCallback.filter(F.action=="remove_many"))
  1358. async def remove_many_intent_callback(
  1359. query: CallbackQuery, intents: UserIntents, callback_data: IntentCallback
  1360. ) -> None:
  1361. """Удаляет намерение и возвращает в меню удаления."""
  1362. intents.remove(callback_data.name)
  1363. await query.message.edit_text(
  1364. text=INTENTS_REMOVE_MANY_MESSAGE,
  1365. reply_markup=get_remove_intents_keyboard(intents.get())
  1366. )
  1367. @dp.callback_query(F.data=="intents:remove_all")
  1368. async def intents_set_remove_mode_callback(
  1369. query: CallbackQuery, intents: UserIntents
  1370. ) -> None:
  1371. """Удаляет всен амерения пользвоателя."""
  1372. intents.remove_all()
  1373. await query.message.edit_text(
  1374. text=get_intents_message(intents.get()),
  1375. reply_markup=get_intents_keyboard(intents.get())
  1376. )
  1377. # Обработчик текстовых запросов
  1378. # =============================
  1379. def process_request(sp: SPMessages, request_text: str) -> Optional[str]:
  1380. """Обрабатывает текстовый запрос к расписанию.
  1381. Преобразует входящий текст в набор намерений или запрос.
  1382. Производит поиск по урокам/кабинетам
  1383. или получает расписание, в зависимости от намерений.
  1384. Args:
  1385. sp (SPMessages): Экземпляр генератора сообщений.
  1386. request_text (str): Текст запроса к расписанию.
  1387. Returns:
  1388. str: Ответ от генератора сообщений.
  1389. """
  1390. intent = Intent.parse(sp.sc, request_text.split())
  1391. # Чтобы не превращать бота в машину для спама
  1392. # Будет использоваться последний урок/кабинет из фильтра
  1393. if len(intent.cabinets):
  1394. res = sp.sc.search(list(intent.cabinets)[-1], intent, True)
  1395. text = send_search_res(intent, res)
  1396. elif len(intent.lessons):
  1397. res = sp.sc.search(list(intent.lessons)[-1], intent, False)
  1398. text = send_search_res(intent, res)
  1399. elif intent.cl or intent.days:
  1400. if intent.days:
  1401. text = sp.send_lessons(intent)
  1402. else:
  1403. text =sp.send_today_lessons(intent)
  1404. else:
  1405. text = None
  1406. return text
  1407. # Получить расписание уроков -------------------------------------------
  1408. @dp.message(Command("sc"))
  1409. async def sc_handler(
  1410. message: Message, sp: SPMessages, command: CommandObject
  1411. ) -> None:
  1412. """Отправляет расписание уроков пользовтелю.
  1413. Отправляет предупреждение, если у пользователя не укзаан класс.
  1414. """
  1415. if command.args is not None:
  1416. answer = process_request(sp, command.args)
  1417. if answer is not None:
  1418. await message.answer(text=answer)
  1419. else:
  1420. await message.answer(text="👀 Кажется это пустой запрос...")
  1421. elif sp.user["class_let"]:
  1422. await message.answer(
  1423. text=sp.send_today_lessons(Intent()),
  1424. reply_markup=get_week_keyboard(sp.user["class_let"]),
  1425. )
  1426. else:
  1427. await message.answer(
  1428. text="⚠️ Для быстрого получения расписания вам нужно указать класс."
  1429. )
  1430. @dp.message()
  1431. async def main_handler(message: Message, sp: SPMessages) -> None:
  1432. """Главный обработчик сообщений бота.
  1433. Перенаправляет входящий текст в запросы к расписанию.
  1434. Устанавливает класс, если он не установлен.
  1435. В личных подсказках отправляет подсказку о доступных классах.
  1436. """
  1437. if message.text is None:
  1438. return
  1439. text = message.text.strip().lower()
  1440. # Если у пользователя установлек класс -> создаём запрос
  1441. if sp.user["set_class"]:
  1442. answer = process_request(sp, text)
  1443. if answer is not None:
  1444. await message.answer(text=answer)
  1445. elif message.chat.type == "private":
  1446. await message.answer(text="👀 Кажется это пустой запрос...")
  1447. elif text in sp.sc.lessons:
  1448. logger.info("Set class {}", text)
  1449. sp.set_class(text)
  1450. await message.answer(
  1451. text=get_home_message(sp.user["class_let"]),
  1452. reply_markup=get_main_keyboard(sp.user["class_let"])
  1453. )
  1454. elif message.chat.type == "private":
  1455. text = "👀 Такого класса не существует."
  1456. text += f"\n💡 Доступныe классы: {', '.join(sp.sc.lessons)}"
  1457. await message.answer(text=text)
  1458. # Обработчик Callback запросов
  1459. # ============================
  1460. @dp.callback_query(F.data == "delete_msg")
  1461. async def delete_msg_callback(query: CallbackQuery, sp: SPMessages) -> None:
  1462. """Удаляет сообщение.
  1463. Если не удалось удалить, отправляет гланый раздел.
  1464. """
  1465. try:
  1466. await query.message.delete()
  1467. except TelegramBadRequest:
  1468. await query.message.edit_text(
  1469. text=get_home_message(sp.user["class_let"]),
  1470. reply_markup=get_main_keyboard(sp.user["class_let"])
  1471. )
  1472. @dp.callback_query(F.data == "home")
  1473. async def home_callback(query: CallbackQuery, sp: SPMessages) -> None:
  1474. """Возаращает в главный раздел."""
  1475. await query.message.edit_text(
  1476. text=get_home_message(sp.user["class_let"]),
  1477. reply_markup=get_main_keyboard(sp.user["class_let"])
  1478. )
  1479. @dp.callback_query(F.data == "other")
  1480. async def other_callback(query: CallbackQuery, sp: SPMessages) -> None:
  1481. """Возвращает сообщение статуса и доплнительную клавиатуру."""
  1482. await query.message.edit_text(
  1483. text=get_status_message(sp, _TIMETAG_PATH),
  1484. reply_markup=get_other_keyboard(sp.user["class_let"]),
  1485. )
  1486. @dp.callback_query(F.data == "cl_features")
  1487. async def restrictions_callback(query: CallbackQuery) -> None:
  1488. """Возвращает сообщение с преимуществами указанного класса."""
  1489. await query.message.edit_text(
  1490. text=CL_FEATURES_MESSAGE, reply_markup=BACK_SET_CL_MARKUP
  1491. )
  1492. @dp.callback_query(F.data == "set_class")
  1493. async def set_class_callback(query: CallbackQuery, sp: SPMessages) -> None:
  1494. """Сбрасывает класс пользователя."""
  1495. sp.reset_user()
  1496. await query.message.edit_text(
  1497. text=SET_CLASS_MESSAGE, reply_markup=PASS_SET_CL_MARKUP
  1498. )
  1499. @dp.callback_query(F.data == "pass")
  1500. async def pass_class_callback(query: CallbackData, sp: SPMessages) -> None:
  1501. """Отвязывает пользователя от класса."""
  1502. sp.set_class(None)
  1503. await query.message.edit_text(
  1504. text=get_home_message(sp.user["class_let"]),
  1505. reply_markup=get_main_keyboard(sp.user["class_let"])
  1506. )
  1507. class ScCallback(CallbackData, prefix="sc"):
  1508. """Используется при получении расписания.
  1509. cl (str): Класс для которого получить расписание.
  1510. day (str): Для какого дня получить расписание.
  1511. - 0-5: понедельник - суббота.
  1512. - today: Получить расписание на сегодня/завтра.
  1513. - week: Получить расписание на всю неделю.
  1514. """
  1515. cl: str
  1516. day: str
  1517. @dp.callback_query(ScCallback.filter())
  1518. async def sc_callback(
  1519. query: CallbackQuery, callback_data: ScCallback, sp: SPMessages
  1520. ) -> None:
  1521. """Отпарвляет расписание уроков для класса в указанный день."""
  1522. # Расписание на неделю
  1523. if callback_data.day == "week":
  1524. text = sp.send_lessons(
  1525. Intent.construct(
  1526. sp.sc, days=[0, 1, 2, 3, 4, 5], cl=callback_data.cl
  1527. )
  1528. )
  1529. reply_markup = get_sc_keyboard(callback_data.cl)
  1530. # Расипсание на сегодня/завтра
  1531. elif callback_data.day == "today":
  1532. text = sp.send_today_lessons(
  1533. Intent.construct(sp.sc,
  1534. cl=callback_data.cl)
  1535. )
  1536. reply_markup = get_week_keyboard(callback_data.cl)
  1537. # Расписание на другой день недели
  1538. else:
  1539. text = sp.send_lessons(
  1540. Intent.construct(
  1541. sp.sc, cl=callback_data.cl, days=int(callback_data.day)
  1542. )
  1543. )
  1544. reply_markup = get_week_keyboard(callback_data.cl)
  1545. await query.message.edit_text(text=text, reply_markup=reply_markup)
  1546. class SelectDayCallback(CallbackData, prefix="select_day"):
  1547. """Используется для выбора дня недели при получении расписания.
  1548. cl (str): Для какого класса получить расписание.
  1549. """
  1550. cl: str
  1551. @dp.callback_query(SelectDayCallback.filter())
  1552. async def select_day_callback(
  1553. query: CallbackQuery, callback_data: ScCallback, sp: SPMessages
  1554. ) -> None:
  1555. """Отобржает клавиатуру для выбора дня расписания уроков."""
  1556. await query.message.edit_text(
  1557. text=f"📅 на ...\n🔶 Для {callback_data.cl}:",
  1558. reply_markup=get_select_day_keyboard(callback_data.cl),
  1559. )
  1560. class NotifyCallback(CallbackData, prefix="notify"):
  1561. """Испольуется при настройке уведомлений пользователя.
  1562. action (str): Какое выполнить действие: add, remove, on, off.
  1563. hour (int): Для какого часа применять изменение.
  1564. - on: Включить увдомления.
  1565. - off: Откплючить уведомления.
  1566. - add: Включить рассылку расписания в указанный час.
  1567. - remove: Отключить рассылку расписания в указанный час.
  1568. """
  1569. action: str
  1570. hour: int
  1571. @dp.callback_query(F.data == "notify")
  1572. async def notify_callback(query: CallbackQuery, sp: SPMessages) -> None:
  1573. """Отправляет настройки увдеомлений."""
  1574. enabled = sp.user["notifications"]
  1575. hours = sp.user["hours"]
  1576. await query.message.edit_text(
  1577. text=get_notify_message(enabled, hours),
  1578. reply_markup=get_notify_keyboard(enabled, hours),
  1579. )
  1580. @dp.callback_query(NotifyCallback.filter())
  1581. async def notify_mod_callback(
  1582. query: CallbackQuery, sp: SPMessages, callback_data: NotifyCallback
  1583. ) -> None:
  1584. """Применяет настройки к уведомлениям."""
  1585. if callback_data.action == "on":
  1586. sp.user["notifications"] = True
  1587. elif callback_data.action == "off":
  1588. sp.user["notifications"] = False
  1589. elif callback_data.action == "add":
  1590. if int(callback_data.hour) not in sp.user["hours"]:
  1591. sp.user["hours"].append(int(callback_data.hour))
  1592. elif callback_data.action == "remove":
  1593. if int(callback_data.hour) in sp.user["hours"]:
  1594. sp.user["hours"].remove(int(callback_data.hour))
  1595. elif callback_data.action == "reset":
  1596. sp.user["hours"] = []
  1597. sp.save_user()
  1598. enabled = sp.user["notifications"]
  1599. hours = sp.user["hours"]
  1600. await query.message.edit_text(
  1601. text=get_notify_message(enabled, hours),
  1602. reply_markup=get_notify_keyboard(enabled, hours),
  1603. )
  1604. class UpdatesCallback(CallbackData, prefix="updates"):
  1605. """Используется при просмотре списка изменений.
  1606. action (str): back, mext, last, switch.
  1607. - back: Переместитсья на одну страницу назад.
  1608. - next: Переместиться на одну страницу вперёд.
  1609. - last: Переместиться на последную страницу расписания.
  1610. - swith: Переключить режим просмотра с общего на для класса.
  1611. page (int): Текущаю страница списка изменений.
  1612. cl (str): Для какого класса отображать список изменений.
  1613. intent (str): Имя намерения пользователя.
  1614. """
  1615. action: str
  1616. page: int
  1617. cl: str
  1618. intent: str
  1619. @dp.callback_query(UpdatesCallback.filter())
  1620. async def updates_callback(
  1621. query: CallbackQuery, sp: SPMessages, callback_data: UpdatesCallback,
  1622. intents: UserIntents
  1623. ) -> None:
  1624. """Клавиатура просмотра списка изменений."""
  1625. # Смена режима просмотра: только для класса/всего расписния
  1626. if callback_data.action == "switch":
  1627. cl = sp.user["class_let"] if callback_data.cl == "None" else None
  1628. else:
  1629. cl = None if callback_data.cl == "None" else callback_data.cl
  1630. intent = intents.get_intent(callback_data.intent)
  1631. if cl is not None and sp.user["class_let"]:
  1632. intent = Intent.construct(sp.sc, cl)
  1633. if intent is None:
  1634. updates = sp.sc.updates
  1635. else:
  1636. updates = sp.sc.get_updates(intent)
  1637. i = max(min(int(callback_data.page), len(updates) - 1), 0)
  1638. if len(updates):
  1639. if callback_data.action in ("last", "switch"):
  1640. i = len(updates) - 1
  1641. elif callback_data.action == "next":
  1642. i = (i + 1) % len(updates)
  1643. elif callback_data.action == "back":
  1644. i = (i - 1) % len(updates)
  1645. update = updates[i]
  1646. else:
  1647. update = None
  1648. await query.message.edit_text(
  1649. text=get_updates_message(update, cl, intent),
  1650. reply_markup=get_updates_keyboard(
  1651. i, updates, cl, intents, callback_data.intent
  1652. )
  1653. )
  1654. class CounterCallback(CallbackData, prefix="count"):
  1655. """Используется в клавиатуре просмотра счётчиков расписания.
  1656. counter (str): Тип счётчика.
  1657. target (str): Цль для отображения счётчика.
  1658. intent (str): Имя пользовательского намерения.
  1659. +----------+-------------------------+
  1660. | counter | targets |
  1661. +----------+-------------------------+
  1662. | cl | days, lessons. cabinets |
  1663. | days | cl, lessons. cabinets |
  1664. | lessons | cl, days, main |
  1665. | cabinets | cl, days, main |
  1666. +----------+-------------------------+
  1667. """
  1668. counter: str
  1669. target: str
  1670. intent: str
  1671. @dp.callback_query(CounterCallback.filter())
  1672. async def counter_callback(
  1673. query: CallbackQuery, sp: SPMessages, callback_data: NotifyCallback,
  1674. intents: UserIntents
  1675. ) -> None:
  1676. """Клавитура для просмотра счётчиков расписания."""
  1677. counter = callback_data.counter
  1678. target = callback_data.target
  1679. if counter == target:
  1680. target = None
  1681. if counter == "cl" and target == "lessons" and not sp.user["class_let"]:
  1682. target = None
  1683. intent = intents.get_intent(callback_data.intent)
  1684. await query.message.edit_text(
  1685. text=get_counter_message(sp.sc, counter, target, intent),
  1686. reply_markup=get_counter_keyboard(
  1687. cl=sp.user["class_let"],
  1688. counter=counter,
  1689. target=target,
  1690. intents=intents,
  1691. intent_name=callback_data.intent
  1692. )
  1693. )
  1694. class TutorlailCallback(CallbackData, prefix="tutorial"):
  1695. """Используется при просмотре постраничной справки.
  1696. page (int): Текущая страница справки.
  1697. """
  1698. page: int
  1699. @dp.callback_query(TutorlailCallback.filter())
  1700. async def tutorail_callback(query: CallbackQuery,
  1701. callback_data: TutorlailCallback) -> None:
  1702. """Отправляет страницу интерактивного обучения."""
  1703. await query.message.edit_text(
  1704. text=TUTORIAL_MESSAGES[callback_data.page],
  1705. reply_markup=get_tutorial_keyboard(callback_data.page)
  1706. )
  1707. @dp.callback_query()
  1708. async def callback_handler(query: CallbackQuery) -> None:
  1709. """Перехватывает все прочие callback_data."""
  1710. logger.warning("Unprocessed query - {}", query.data)
  1711. def send_error_messsage(exception: ErrorEvent, sp: SPMessages):
  1712. """Отпрвляет отладочное сообщние об ошибке пользователю.
  1713. Data:
  1714. user_name => Кто вызвал ошибку.
  1715. user_id => Какой пользователь вызвал ошибку.
  1716. class_let => К какому класс относился пользователь.
  1717. chat_id => Где была вызвана ошибка.
  1718. exception => Описание текста ошибки.
  1719. action => Callback data или текст сообщение, вызвавший ошибку.
  1720. Args:
  1721. exception (ErrorEvent): Событие ошибки aiogram.
  1722. sp (SPMessage): Экземпляр генератора сообщений пользователя.
  1723. Returns:
  1724. str: Отладочное сообщение с данными об ошибке в боте.
  1725. """
  1726. if exception.update.callback_query is not None:
  1727. action = f"-- Данные: {exception.update.callback_query.data}"
  1728. message = exception.update.callback_query.message
  1729. else:
  1730. action = f"-- Текст: {exception.update.message.text}"
  1731. message = exception.update.message
  1732. user_name = message.from_user.first_name
  1733. chat_id = message.chat.id
  1734. return ("⚠️ Произошла ошибка в работе бота."
  1735. f"\n-- Версия: {_BOT_VERSION}"
  1736. "\n\n👤 Пользователь"
  1737. f"\n-- Имя: {user_name}"
  1738. f"\n-- Класс: {sp.user['class_let']}"
  1739. f"\n-- ID: {chat_id}"
  1740. "\n\n🚫 Описание ошибки:"
  1741. f"\n-- {exception.exception}"
  1742. "\n\n🔍 Доплнительная информаиция"
  1743. f"\n{action}"
  1744. "\n\nПожалуйста, свяжитесь с @milinuri для решения проблемы."
  1745. )
  1746. @dp.errors()
  1747. async def error_handler(exception: ErrorEvent, sp: SPMessages) -> None:
  1748. """Ловит и обрабатывает все исключения.
  1749. Отправляет сообщение об ошибке пользователям.
  1750. """
  1751. logger.exception(exception.exception)
  1752. if exception.update.callback_query is not None:
  1753. await exception.update.callback_query.message.answer(
  1754. send_error_messsage(exception, sp)
  1755. )
  1756. else:
  1757. await exception.update.message.answer(
  1758. send_error_messsage(exception, sp)
  1759. )
  1760. # Запуск бота
  1761. # ===========
  1762. async def main() -> None:
  1763. """Главная функция запуска бота."""
  1764. bot = Bot(TELEGRAM_TOKEN)
  1765. logger.info("Bot started.")
  1766. await dp.start_polling(bot, skip_updates=True)
  1767. if __name__ == "__main__":
  1768. asyncio.run(main())