commit 73c4fc37f78d2bfae18aa2a0e74675549d8290e8 Author: yarok Date: Thu Jul 9 23:34:34 2026 +0300 initial diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5ea80c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.13-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "bot.py"] \ No newline at end of file diff --git a/__pycache__/api.cpython-313.pyc b/__pycache__/api.cpython-313.pyc new file mode 100644 index 0000000..8d8ca08 Binary files /dev/null and b/__pycache__/api.cpython-313.pyc differ diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..937b28e Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/database.cpython-313.pyc b/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..8fb4da4 Binary files /dev/null and b/__pycache__/database.cpython-313.pyc differ diff --git a/__pycache__/formatting.cpython-313.pyc b/__pycache__/formatting.cpython-313.pyc new file mode 100644 index 0000000..f55d7aa Binary files /dev/null and b/__pycache__/formatting.cpython-313.pyc differ diff --git a/__pycache__/keyboards.cpython-313.pyc b/__pycache__/keyboards.cpython-313.pyc new file mode 100644 index 0000000..2f1faa6 Binary files /dev/null and b/__pycache__/keyboards.cpython-313.pyc differ diff --git a/__pycache__/logger.cpython-313.pyc b/__pycache__/logger.cpython-313.pyc new file mode 100644 index 0000000..eeb4663 Binary files /dev/null and b/__pycache__/logger.cpython-313.pyc differ diff --git a/__pycache__/schedule.cpython-313.pyc b/__pycache__/schedule.cpython-313.pyc new file mode 100644 index 0000000..5e73f4d Binary files /dev/null and b/__pycache__/schedule.cpython-313.pyc differ diff --git a/__pycache__/states.cpython-313.pyc b/__pycache__/states.cpython-313.pyc new file mode 100644 index 0000000..e20e50f Binary files /dev/null and b/__pycache__/states.cpython-313.pyc differ diff --git a/api.py b/api.py new file mode 100644 index 0000000..0098e72 --- /dev/null +++ b/api.py @@ -0,0 +1,193 @@ +import httpx + +from datetime import date +from cachetools import TTLCache + +from config import ( + API_URL, + REQUEST_TIMEOUT, + GROUPS_CACHE_TTL, +) + +from database import ( + get_cached_schedule, + save_schedule_cache, +) + + +def get_schedule_ttl(day: date) -> int: + """ + Возвращает TTL кэша в минутах. + """ + + today = date.today() + + if day == today: + # Сегодня — обновляем каждый час + return 60 + + if day > today: + # Будущие даты — раз в сутки + return 60 * 24 + + # Прошедшие даты — неделю + return 60 * 24 * 7 + + +class ScheduleAPI: + + def __init__(self): + + self.client = httpx.AsyncClient( + timeout=REQUEST_TIMEOUT + ) + + self._groups_cache = TTLCache( + maxsize=1, + ttl=GROUPS_CACHE_TTL + ) + + async def close(self): + await self.client.aclose() + + async def _get( + self, + endpoint: str, + params: dict | None = None + ): + + response = await self.client.get( + f"{API_URL}/{endpoint}", + params=params + ) + + response.raise_for_status() + + return response.json() + + async def get_groups(self): + + if "groups" in self._groups_cache: + return self._groups_cache["groups"] + + response = await self._get("Groups") + + groups = ( + response + .get("data", {}) + .get("groups", []) + ) + + self._groups_cache["groups"] = groups + + return groups + + async def get_group_by_id( + self, + group_id: int + ): + + groups = await self.get_groups() + + for group in groups: + + if group["groupID"] == group_id: + return group + + return None + + async def search_group_by_name( + self, + text: str + ): + + groups = await self.get_groups() + + text = text.upper().strip() + + exact = [ + g + for g in groups + if g["groupName"].upper() == text + ] + + if exact: + return exact + + return [ + g + for g in groups + if text in g["groupName"].upper() + ] + + async def get_schedule( + self, + group_id: int, + day: date | None = None + ): + + request_day = day or date.today() + + schedule_date = request_day.isoformat() + + ttl = get_schedule_ttl(request_day) + + # Проверяем кэш + cached = await get_cached_schedule( + group_id, + schedule_date, + ttl + ) + + if cached is not None: + print(f"[CACHE] {group_id} {schedule_date}") + return cached + + print(f"[API] {group_id} {schedule_date}") + + params = { + "idGroup": group_id, + "sdate": schedule_date + } + + response = await self._get( + "Rasp", + params + ) + + schedule = response.get( + "data", + {} + ) + + # Сохраняем в кэш + await save_schedule_cache( + group_id, + schedule_date, + schedule + ) + + return schedule + + async def get_teachers(self): + + response = await self._get("Prepods") + + return ( + response + .get("data", {}) + .get("prepods", []) + ) + + async def get_auditories(self): + + response = await self._get("Auds") + + return ( + response + .get("data", {}) + .get("auds", []) + ) + + +api = ScheduleAPI() \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..eb698fb --- /dev/null +++ b/bot.py @@ -0,0 +1,95 @@ +import asyncio +import logging +from schedule import refresh_cache +from aiogram import Bot, Dispatcher +from aiogram.enums import ParseMode +from aiogram.client.default import DefaultBotProperties +from aiogram.fsm.storage.memory import MemoryStorage +from config import BOT_TOKEN +from api import api +from handlers.support import router as support_router +from handlers.start import router as start_router +from handlers.group import router as group_router +from handlers.admin import router as admin_router +from logger import logger +from database import ( + init_db, + clear_old_cache +) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)s | %(name)s | %(message)s" +) + +bot = Bot( + token=BOT_TOKEN, + default=DefaultBotProperties( + parse_mode=ParseMode.HTML + ) +) + +dp = Dispatcher( + storage=MemoryStorage() +) + +dp.include_router(start_router) +dp.include_router(group_router) +dp.include_router(support_router) +dp.include_router(admin_router) + + +#стартует бот +async def on_startup(): + + await init_db() + + deleted = await clear_old_cache() + + logger.info("База данных инициализирована") + + logger.info( + f"Удалено {deleted} записей кэша" + ) + + asyncio.create_task( + refresh_cache() + ) + + logger.info( + "Фоновое обновление кэша запущено" + ) + + +async def on_shutdown(): + + await api.close() + + await bot.session.close() + + logger.info("Бот остановлен") + + +async def main(): + + await on_startup() + + try: + + logger.info("Бот запущен") + + await dp.start_polling(bot) + + finally: + + await on_shutdown() + + +if __name__ == "__main__": + + try: + asyncio.run(main()) + + except KeyboardInterrupt: + logger.info("Остановка...") + diff --git a/config.py b/config.py new file mode 100644 index 0000000..77a5197 --- /dev/null +++ b/config.py @@ -0,0 +1,31 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + + +ADMIN_IDS = [ + int(x) + for x in os.getenv( + "ADMIN_IDS", + "" + ).split(",") + if x +] + +load_dotenv() + + +BOT_TOKEN = os.getenv("BOT_TOKEN") + +API_URL = os.getenv( + "API_URL", + "https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api" +) + +REQUEST_TIMEOUT = 15 + +GROUPS_CACHE_TTL = 3600 # 1 час + +if not BOT_TOKEN: + raise RuntimeError("BOT_TOKEN не найден в .env") \ No newline at end of file diff --git a/data/users.db b/data/users.db new file mode 100644 index 0000000..c052a5a Binary files /dev/null and b/data/users.db differ diff --git a/database.py b/database.py new file mode 100644 index 0000000..ce13f49 --- /dev/null +++ b/database.py @@ -0,0 +1,501 @@ +import aiosqlite +from datetime import datetime +import json + + +DB_NAME = "database/database.db" + + +async def init_db(): + + async with aiosqlite.connect(DB_NAME) as db: + + # Пользователи + await db.execute(""" + CREATE TABLE IF NOT EXISTS users( + + user_id INTEGER PRIMARY KEY, + + username TEXT, + + first_name TEXT, + + last_name TEXT, + + phone TEXT, + + email TEXT, + + group_id INTEGER, + + group_name TEXT, + + subgroup TEXT, + + created_at TEXT, + + last_seen TEXT + + ) + """) + + # Кэш расписания + await db.execute(""" + CREATE TABLE IF NOT EXISTS schedule_cache( + + id INTEGER PRIMARY KEY AUTOINCREMENT, + + group_id INTEGER NOT NULL, + + date TEXT NOT NULL, + + data TEXT NOT NULL, + + created_at TEXT NOT NULL, + + UNIQUE(group_id, date) + + ) + """) + + # Индекс + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_schedule_cache + ON schedule_cache(group_id, date) + """) + + # Поддержка + await db.execute(""" + CREATE TABLE IF NOT EXISTS support( + + id INTEGER PRIMARY KEY AUTOINCREMENT, + + user_id INTEGER NOT NULL, + + message TEXT NOT NULL, + + answer TEXT, + + status TEXT DEFAULT 'open', + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + + ) + """) + + await db.commit() + +async def save_group( + user_id: int, + group_id: int, + group_name: str, + subgroup: str | None = None +): + + async with aiosqlite.connect(DB_NAME) as db: + + await db.execute( + """ + INSERT INTO users( + user_id, + group_id, + group_name, + subgroup + ) + + VALUES(?,?,?,?) + + ON CONFLICT(user_id) + + DO UPDATE SET + + group_id = excluded.group_id, + + group_name = excluded.group_name, + + subgroup = excluded.subgroup + + """, + ( + user_id, + group_id, + group_name, + subgroup + ) + ) + + await db.commit() + + + +async def get_group( + user_id: int +): + + async with aiosqlite.connect(DB_NAME) as db: + + cursor = await db.execute( + """ + SELECT + + group_id, + + group_name, + + subgroup + + FROM users + + WHERE user_id=? + + """, + ( + user_id, + ) + ) + + + row = await cursor.fetchone() + + + if row is None: + return None + + + return { + + "group_id": row[0], + + "group_name": row[1], + + "subgroup": row[2] + + } + + + +async def delete_group( + user_id: int +): + + async with aiosqlite.connect(DB_NAME) as db: + + await db.execute( + """ + DELETE FROM users + + WHERE user_id=? + + """, + ( + user_id, + ) + ) + + await db.commit() + + + +async def update_subgroup( + user_id: int, + subgroup: str +): + + async with aiosqlite.connect(DB_NAME) as db: + + await db.execute( + """ + UPDATE users + + SET subgroup=? + + WHERE user_id=? + + """, + ( + subgroup, + user_id + ) + ) + + await db.commit() + +async def create_ticket( + user_id: int, + message: str +): + + async with aiosqlite.connect(DB_NAME) as db: + + cursor = await db.execute( + """ + INSERT INTO support( + user_id, + message + ) + VALUES(?,?) + """, + ( + user_id, + message + ) + ) + + await db.commit() + + return cursor.lastrowid + + #tickets +async def get_ticket(ticket_id: int): + + async with aiosqlite.connect(DB_NAME) as db: + + cursor = await db.execute( + """ + SELECT + id, + user_id, + message, + status + FROM support + WHERE id=? + """, + (ticket_id,) + ) + + row = await cursor.fetchone() + + return row + +async def get_tickets(): + + async with aiosqlite.connect(DB_NAME) as db: + + cursor = await db.execute( + """ + SELECT + id, + user_id, + message, + status + FROM support + WHERE status='open' + ORDER BY id DESC + """ + ) + + rows = await cursor.fetchall() + + return rows + +async def close_ticket(ticket_id: int): + + async with aiosqlite.connect(DB_NAME) as db: + + await db.execute( + """ + UPDATE support + SET status='closed' + WHERE id=? + """, + (ticket_id,) + ) + + await db.commit() + + +async def update_user( + user_id: int, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None +): + + now = datetime.now().isoformat() + + + async with aiosqlite.connect(DB_NAME) as db: + + await db.execute( + """ + INSERT INTO users( + user_id, + username, + first_name, + last_name, + created_at, + last_seen + ) + + VALUES(?,?,?,?,?,?) + + ON CONFLICT(user_id) + + DO UPDATE SET + + username=excluded.username, + first_name=excluded.first_name, + last_name=excluded.last_name, + last_seen=excluded.last_seen + + """, + + ( + user_id, + username, + first_name, + last_name, + now, + now + ) + ) + + + await db.commit() + +async def save_phone( + user_id: int, + phone: str +): + + async with aiosqlite.connect(DB_NAME) as db: + + await db.execute( + """ + UPDATE users + SET phone=? + WHERE user_id=? + """, + ( + phone, + user_id + ) + ) + + await db.commit() + +async def get_cached_schedule( + group_id: int, + schedule_date: str, + ttl_minutes: int +): + + async with aiosqlite.connect(DB_NAME) as db: + + cursor = await db.execute( + """ + SELECT + data, + created_at + + FROM schedule_cache + + WHERE group_id=? + AND date=? + + """, + ( + group_id, + schedule_date + ) + ) + + row = await cursor.fetchone() + + + if row is None: + return None + + + created = datetime.fromisoformat( + row[1] + ) + + + age = ( + datetime.now() - created + ).total_seconds() / 60 + + + # кэш устарел + + if age > ttl_minutes: + + return None + + + return json.loads(row[0]) + +async def save_schedule_cache( + group_id: int, + schedule_date: str, + data: dict +): + + async with aiosqlite.connect(DB_NAME) as db: + + await db.execute( + """ + INSERT INTO schedule_cache( + group_id, + date, + data, + created_at + ) + + VALUES(?,?,?,?) + + ON CONFLICT(group_id, date) + + DO UPDATE SET + + data = excluded.data, + created_at = excluded.created_at + """, + ( + group_id, + schedule_date, + json.dumps( + data, + ensure_ascii=False + ), + datetime.now().isoformat() + ) + ) + + await db.commit() + + +async def clear_old_cache(days: int = 30): + + async with aiosqlite.connect(DB_NAME) as db: + + cursor = await db.execute( + """ + DELETE FROM schedule_cache + WHERE datetime(created_at) + < datetime('now', ?) + """, + (f"-{days} days",) + ) + + await db.commit() + + return cursor.rowcount + + +async def get_active_groups(): + + async with aiosqlite.connect(DB_NAME) as db: + + cursor = await db.execute( + """ + SELECT DISTINCT group_id + FROM users + WHERE group_id IS NOT NULL + """ + ) + + rows = await cursor.fetchall() + + return [row[0] for row in rows] \ No newline at end of file diff --git a/database/database.db b/database/database.db new file mode 100644 index 0000000..603f836 Binary files /dev/null and b/database/database.db differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6f57846 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + schedule_bot: + build: . + container_name: schedule_bot + restart: unless-stopped + + env_file: + - .env + + volumes: + - ./database.db:/app/database.db + + dns: + - 8.8.8.8 + - 1.1.1.1 diff --git a/formatting.py b/formatting.py new file mode 100644 index 0000000..e836613 --- /dev/null +++ b/formatting.py @@ -0,0 +1,76 @@ +from collections import defaultdict + + +def format_schedule(data): + + rasp = data.get("rasp", []) + + if not rasp: + return "🎉 Сегодня занятий нет." + + + days = defaultdict(list) + + + for lesson in rasp: + + days[ + lesson["день_недели"] + ].append(lesson) + + + text = "" + + + for day, lessons in days.items(): + + text += f"\n📅 {day}\n\n" + + + for item in sorted( + lessons, + key=lambda x: x["номерЗанятия"] + ): + + number = item.get( + "номерЗанятия", + "?" + ) + + start = item.get( + "начало", + "" + ) + + end = item.get( + "конец", + "" + ) + + subject = item.get( + "дисциплина", + "Без названия" + ) + + teacher = item.get( + "преподаватель", + "-" + ) + + room = item.get( + "аудитория", + "-" + ) + + + text += ( + f"{number}️⃣ " + f"{start}-{end}\n" + f"📘 {subject}\n" + f"👤 {teacher}\n" + f"🏫 {room}\n\n" + "────────────\n\n" + ) + + + return text \ No newline at end of file diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/__pycache__/__init__.cpython-313.pyc b/handlers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..f48232e Binary files /dev/null and b/handlers/__pycache__/__init__.cpython-313.pyc differ diff --git a/handlers/__pycache__/admin.cpython-313.pyc b/handlers/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000..0664392 Binary files /dev/null and b/handlers/__pycache__/admin.cpython-313.pyc differ diff --git a/handlers/__pycache__/group.cpython-313.pyc b/handlers/__pycache__/group.cpython-313.pyc new file mode 100644 index 0000000..ec6d570 Binary files /dev/null and b/handlers/__pycache__/group.cpython-313.pyc differ diff --git a/handlers/__pycache__/start.cpython-313.pyc b/handlers/__pycache__/start.cpython-313.pyc new file mode 100644 index 0000000..7981318 Binary files /dev/null and b/handlers/__pycache__/start.cpython-313.pyc differ diff --git a/handlers/__pycache__/support.cpython-313.pyc b/handlers/__pycache__/support.cpython-313.pyc new file mode 100644 index 0000000..ad77a80 Binary files /dev/null and b/handlers/__pycache__/support.cpython-313.pyc differ diff --git a/handlers/admin.py b/handlers/admin.py new file mode 100644 index 0000000..8470c6f --- /dev/null +++ b/handlers/admin.py @@ -0,0 +1,168 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import ( + Message, + CallbackQuery +) +from aiogram.fsm.context import FSMContext + +from config import ADMIN_IDS +from database import ( + get_tickets, + get_ticket, + close_ticket +) + +from states import AdminState + +router = Router() + +@router.message(Command("tickets")) +async def tickets(message: Message): + + if message.from_user.id not in ADMIN_IDS: + return + + tickets = await get_tickets() + + if not tickets: + await message.answer( + "📭 Новых обращений нет." + ) + return + + text = "📩 Обращения:\n\n" + + for ticket in tickets: + + text += ( + f"🆔 #{ticket[0]}\n" + f"👤 Пользователь: {ticket[1]}\n" + f"📝 Сообщение:\n" + f"{ticket[2]}\n" + f"📌 Статус: {ticket[3]}\n" + "━━━━━━━━━━━━━━\n" + ) + + await message.answer(text) + +@router.callback_query( + F.data.startswith("reply_ticket:") +) +async def reply_ticket( + callback: CallbackQuery, + state: FSMContext +): + + ticket_id = int( + callback.data.split(":")[1] + ) + + await state.update_data( + ticket_id=ticket_id + ) + + await state.set_state( + AdminState.waiting_reply + ) + + await callback.message.answer( + "💬 Напишите ответ пользователю:" + ) + + await callback.answer() + +@router.message(AdminState.waiting_reply) +async def send_admin_reply( + message: Message, + state: FSMContext, + bot +): + + data = await state.get_data() + + ticket_id = data.get( + "ticket_id" + ) + + + if not ticket_id: + + await message.answer( + "❌ Тикет не найден." + ) + + await state.clear() + return + + + ticket = await get_ticket( + ticket_id + ) + + + if not ticket: + + await message.answer( + "❌ Обращение не найдено." + ) + + await state.clear() + return + + + user_id = ticket[1] + + + try: + + await bot.send_message( + user_id, + ( + "📩 Ответ поддержки\n\n" + f"{message.text}" + ) + ) + + + await message.answer( + "✅ Ответ отправлен пользователю." + ) + + + except Exception as e: + + await message.answer( + f"❌ Не удалось отправить сообщение:\n{e}" + ) + + + await state.clear() + + +@router.callback_query( + F.data.startswith("close_ticket:") +) +async def close_ticket_handler( + callback: CallbackQuery +): + + ticket_id = int( + callback.data.split(":")[1] + ) + + + await close_ticket( + ticket_id + ) + + + await callback.message.edit_text( + callback.message.html_text + + "\n\n✅ Тикет закрыт" + ) + + + await callback.answer( + "Тикет закрыт" + ) \ No newline at end of file diff --git a/handlers/group.py b/handlers/group.py new file mode 100644 index 0000000..7d677df --- /dev/null +++ b/handlers/group.py @@ -0,0 +1,803 @@ +from datetime import date, timedelta +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, CallbackQuery +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.fsm.context import FSMContext +from states import GroupState +from api import api +from database import save_group, get_group, update_subgroup +import math +from keyboards import main_keyboard +from keyboards import subgroup_keyboard +from keyboards import ( + groups_keyboard, + date_keyboard, + calendar_keyboard, + main_keyboard, + start_keyboard, +) + +router = Router() + +DAYS = ( + "Пн", + "Вт", + "Ср", + "Чт", + "Пт", + "Сб", + "Вс", +) + + +def format_date(day: date) -> str: + return f"{day.strftime('%d.%m')} ({DAYS[day.weekday()]})" + + +def build_keyboard(group_id: int, current_day: date): + + kb = InlineKeyboardBuilder() + + kb.button( + text="⬅️", + callback_data=f"day:{group_id}:{(current_day - timedelta(days=1)).isoformat()}" + ) + + kb.button( + text=format_date(current_day), + callback_data="ignore" + ) + + kb.button( + text="➡️", + callback_data=f"day:{group_id}:{(current_day + timedelta(days=1)).isoformat()}" + ) + + kb.adjust(3) + + return kb.as_markup() + +async def build_groups_keyboard(page: int = 0): + + groups = await api.get_groups() + groups.sort( + key=lambda g: g["groupName"] +) + + PER_PAGE = 30 + + start = page * PER_PAGE + end = start + PER_PAGE + + current = groups[start:end] + + kb = InlineKeyboardBuilder() + + for group in current: + kb.button( + text=group["groupName"], + callback_data=f"select:{group['groupID']}" + ) + + kb.adjust(3) + + pages = math.ceil(len(groups) / PER_PAGE) + + if page > 0: + kb.button( + text="◀️", + callback_data=f"groups_page:{page-1}" + ) + + kb.button( + text=f"{page+1}/{pages}", + callback_data="ignore" + ) + + if page < pages - 1: + kb.button( + text="▶️", + callback_data=f"groups_page:{page+1}" + ) + + kb.adjust(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3) + + return kb.as_markup() + +def build_schedule( + schedule: dict, + group: dict, + selected_day: date +) -> str: + + lessons = [ + lesson + for lesson in schedule.get("rasp", []) + if lesson.get("дата", "")[:10] == selected_day.isoformat() +] + + lessons.sort( + key=lambda lesson: ( + lesson.get("номерЗанятия", 0), + lesson.get("начало", "") + ) +) + + days = ( + "Понедельник", + "Вторник", + "Среда", + "Четверг", + "Пятница", + "Суббота", + "Воскресенье" + ) + + text = ( + "╔════════════════════╗\n" + " 📅 РАСПИСАНИЕ\n" + "╚════════════════════╝\n\n" + f"🎓 {group['groupName']}\n" + f"📆 {days[selected_day.weekday()]}, " + f"{selected_day.strftime('%d.%m.%Y')}\n\n" + ) + + if not lessons: + return text + "🎉 На этот день занятий нет." + + separator = "━━━━━━━━━━━━━━━━━━━━\n" + + for lesson in lessons: + + text += separator + + number = lesson.get("номерЗанятия", "-") + + text += ( + f"🟧 {number} занятие\n" + f"🕐 {lesson.get('начало', '?')} — " + f"{lesson.get('конец', '?')}\n\n" + ) + + text += ( + f"📚 {lesson.get('дисциплина', '-')}\n" + ) + + teacher = lesson.get("преподаватель") + + if teacher: + text += f"👨‍🏫 {teacher}\n" + + aud = lesson.get("аудитория") + + if aud: + text += f"🏫 Аудитория: {aud}\n" + + subgroup = lesson.get("номерПодгруппы") + + if subgroup: + text += f"👥 Подгруппа: {subgroup}\n" + + text += "\n" + + return text + + +async def send_schedule( + message: Message, + group: dict, + selected_day: date +): + + schedule = await api.get_schedule( + group["groupID"], + selected_day + ) + + text = build_schedule( + schedule, + group, + selected_day + ) + + LIMIT = 4000 + + if len(text) <= LIMIT: + + await message.answer( + text, + reply_markup=build_keyboard( + group["groupID"], + selected_day + ) + ) + + return + + parts = [] + current = "" + + for line in text.split("\n"): + + if len(current) + len(line) + 1 > LIMIT: + parts.append(current) + current = "" + + current += line + "\n" + + if current: + parts.append(current) + + for i, part in enumerate(parts): + + await message.answer( + part, + reply_markup=( + build_keyboard( + group["groupID"], + selected_day + ) + if i == len(parts) - 1 + else None + ) + ) + + +@router.message(Command("find_group")) +@router.message( + F.text.in_( + { + "🔍 Выбрать группу", + "🔍 Сменить группу" + } + ) +) +async def find_group(message: Message): + + await message.answer( + "📚 Выберите свою группу", + reply_markup=await build_groups_keyboard(0) + ) + +@router.message( + GroupState.waiting_group +) +async def search_group( + message: Message, + state: FSMContext +): + + query = message.text.strip() + + groups = await api.search_group_by_name(query) + + if not groups: + + await message.answer( + "❌ Группа не найдена.\nПопробуйте ещё раз." + ) + return + + if len(groups) == 1: + + group = groups[0] + + await message.answer( + "✅ Группа сохранена.", + reply_markup=main_keyboard() + ) + + await state.clear() + + await message.answer( + f"✅ Группа {group['groupName']} сохранена." + ) + + await message.answer( + "📅 Выберите дату:", + reply_markup=date_keyboard() +) + + return + + kb = InlineKeyboardBuilder() + + for group in groups[:10]: + + kb.button( + text=f"{group['groupName']} (курс {group['course']})", + callback_data=f"select:{group['groupID']}" + ) + + kb.adjust(1) + + await message.answer( + "Выберите группу:", + reply_markup=kb.as_markup() + ) +async def search_group(message: Message): + + query = message.text.strip() + + groups = await api.search_group_by_name(query) + + if not groups: + + await message.answer( + "❌ Группа не найдена." + ) + + return + + if len(groups) == 1: + + group = groups[0] + + await save_group( + message.from_user.id, + group["groupID"], + group["groupName"] + ) + + await message.answer( + f"✅ Группа {group['groupName']} сохранена." + ) + + await send_schedule( + message, + group, + date.today() + ) + + return + + kb = InlineKeyboardBuilder() + + for group in groups[:10]: + + kb.button( + text=f"{group['groupName']} (курс {group['course']})", + callback_data=f"select:{group['groupID']}" + ) + + kb.adjust(1) + + await message.answer( + "Найдено несколько групп.\nВыберите нужную:", + reply_markup=kb.as_markup() + ) + + +# ========================== +# ВЫБОР ГРУППЫ +# ========================== + +@router.callback_query( + F.data.startswith("select:") +) +async def select_group( + callback: CallbackQuery, + state: FSMContext +): + + group_id = int( + callback.data.split(":")[1] + ) + + group = await api.get_group_by_id( + group_id + ) + + if group is None: + + await callback.answer( + "Группа не найдена.", + show_alert=True + ) + + return + + await save_group( + callback.from_user.id, + group["groupID"], + group["groupName"] +) + + await state.clear() + + await callback.message.edit_text( + "🎓 Группа сохранена!\n\n" + "Теперь выберите свою подгруппу:", + reply_markup=subgroup_keyboard() +) + + await callback.answer() + +@router.callback_query(F.data.startswith("groups_page:")) +async def groups_page(callback: CallbackQuery): + + page = int(callback.data.split(":")[1]) + + await callback.message.edit_reply_markup( + reply_markup=await build_groups_keyboard(page) + ) + + await callback.answer() + +@router.message( + F.text.regexp(r"^\d{1,2}[./]\d{1,2}([./]\d{2,4})?$") +) +async def schedule_by_date(message: Message): + + saved_group = await get_group( + message.from_user.id + ) + + if not saved_group: + + await message.answer( + "❌ Сначала выберите группу." + ) + return + + text = message.text.strip().replace("/", ".") + + try: + + parts = text.split(".") + + if len(parts) == 2: + + selected_date = date( + year=date.today().year, + month=int(parts[1]), + day=int(parts[0]), + ) + + else: + + year = int(parts[2]) + + if year < 100: + year += 2000 + + selected_date = date( + year=year, + month=int(parts[1]), + day=int(parts[0]), + ) + + except ValueError: + + await message.answer( + "❌ Некорректная дата." + ) + + return + + group = await api.get_group_by_id( + saved_group["group_id"] + ) + + if group is None: + + await message.answer( + "❌ Группа не найдена." + ) + + return + + await send_schedule( + message, + group, + selected_date + ) + + # ========================== +# ПУСТАЯ КНОПКА +# ========================== + +@router.callback_query(F.data == "ignore") +async def ignore_callback(callback: CallbackQuery): + await callback.answer() + + +# ========================== +# ПЕРЕКЛЮЧЕНИЕ ДНЕЙ +# ========================== + +@router.callback_query(F.data.startswith("day:")) +async def change_day(callback: CallbackQuery): + + try: + + _, group_id, day = callback.data.split(":") + + selected_date = date.fromisoformat(day) + + group = await api.get_group_by_id( + int(group_id) + ) + + if group is None: + + await callback.answer( + "Группа не найдена.", + show_alert=True + ) + + return + + schedule = await api.get_schedule( + group["groupID"], + selected_date + ) + + text = build_schedule( + schedule, + group, + selected_date + ) + + if len(text) > 4096: + + await callback.answer( + "Расписание слишком большое. Используйте ввод даты.", + show_alert=True + ) + + return + + await callback.message.edit_text( + text, + reply_markup=build_keyboard( + group["groupID"], + selected_date + ) + ) + + await callback.answer() + + except Exception as e: + + await callback.answer( + str(e), + show_alert=True + ) + +@router.callback_query(F.data == "date:today") +async def date_today(callback: CallbackQuery): + + saved = await get_group(callback.from_user.id) + + if not saved: + await callback.answer("Сначала выберите группу") + return + + group = await api.get_group_by_id(saved["group_id"]) + + schedule = await api.get_schedule( + group["groupID"], + date.today() + ) + + text = build_schedule( + schedule, + group, + date.today() + ) + + await callback.message.edit_text( + text, + reply_markup=build_keyboard( + group["groupID"], + date.today() + ) + ) + +@router.callback_query(F.data == "date:tomorrow") +async def date_tomorrow(callback: CallbackQuery): + + tomorrow = date.today() + timedelta(days=1) + + saved_group = await get_group(callback.from_user.id) + + if not saved_group: + await callback.answer("Сначала выберите группу.", show_alert=True) + return + + group = await api.get_group_by_id(saved_group["group_id"]) + + await callback.message.edit_text( + build_schedule( + await api.get_schedule(group["groupID"], tomorrow), + group, + tomorrow + ), + reply_markup=build_keyboard(group["groupID"], tomorrow) + ) + + await callback.answer() + +@router.callback_query(F.data == "date:input") +async def input_date(callback: CallbackQuery): + + await callback.message.answer( + "Введите дату в формате:\n\n" + "01.09.2026" + ) + + await callback.answer() + + +@router.callback_query(F.data == "date:calendar") +async def open_calendar(callback: CallbackQuery): + + today = date.today() + + await callback.message.edit_reply_markup( + reply_markup=calendar_keyboard( + today.year, + today.month + ) + ) + + await callback.answer() + + +@router.callback_query(F.data.startswith("month:")) +async def change_month(callback: CallbackQuery): + + _, year, month = callback.data.split(":") + + await callback.message.edit_reply_markup( + reply_markup=calendar_keyboard( + int(year), + int(month) + ) + ) + + await callback.answer() + + +@router.callback_query(F.data.startswith("calendar:")) +async def calendar_select(callback: CallbackQuery): + + _, year, month, day = callback.data.split(":") + + selected_day = date( + int(year), + int(month), + int(day) + ) + + saved_group = await get_group( + callback.from_user.id + ) + + if not saved_group: + + await callback.answer( + "Сначала выберите группу.", + show_alert=True + ) + return + + group = await api.get_group_by_id( + saved_group["group_id"] + ) + + schedule = await api.get_schedule( + group["groupID"], + selected_day + ) + + text = build_schedule( + schedule, + group, + selected_day + ) + + await callback.message.edit_text( + text, + reply_markup=build_keyboard( + group["groupID"], + selected_day + ) + ) + + await callback.answer() + + + +@router.callback_query(F.data == "calendar_today") +async def calendar_today(callback: CallbackQuery): + + today = date.today() + + saved_group = await get_group( + callback.from_user.id + ) + + if not saved_group: + return + + group = await api.get_group_by_id( + saved_group["group_id"] + ) + + schedule = await api.get_schedule( + group["groupID"], + today + ) + + text = build_schedule( + schedule, + group, + today + ) + + await callback.message.edit_text( + text, + reply_markup=build_keyboard( + group["groupID"], + today + ) + ) + + await callback.answer() + +@router.message(F.text == "📅 Расписание") +async def schedule_menu(message: Message): + + await message.answer( + "📅 Выберите дату:", + reply_markup=date_keyboard() + ) + +@router.message(F.text == "🔄 Группа") +async def change_group(message: Message): + + await message.answer( + "Введите номер группы:", + reply_markup=start_keyboard() + ) + +@router.message(F.text == "⚙️ Моя группа") +async def my_group(message: Message): + + saved_group = await get_group(message.from_user.id) + + if not saved_group: + await message.answer( + "❌ У вас ещё не выбрана группа.\n\nНажмите «🔍 Выбрать группу»." + ) + return + + await message.answer( + f"🎓 Ваша группа: {saved_group['group_name']}" + ) + +@router.callback_query(F.data.startswith("sub:")) +async def select_subgroup(callback: CallbackQuery): + + subgroup = callback.data.split(":")[1] + + await update_subgroup( + callback.from_user.id, + subgroup + ) + + await callback.answer( + "Подгруппа сохранена!" + ) + + await callback.message.edit_text( + f"✅ Подгруппа {subgroup} сохранена!" +) + + await callback.message.answer( + "Выберите действие:", + reply_markup=main_keyboard() +) \ No newline at end of file diff --git a/handlers/start.py b/handlers/start.py new file mode 100644 index 0000000..284969b --- /dev/null +++ b/handlers/start.py @@ -0,0 +1,77 @@ +from aiogram import Router, F +from aiogram.filters import CommandStart, Command +from aiogram.types import Message +from database import get_group +from keyboards import main_keyboard, start_keyboard +from database import ( + get_group, + update_user +) +from database import save_phone + +router = Router() + + +@router.message(CommandStart()) +async def start(message: Message): + + await update_user( + user_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name +) + + group = await get_group(message.from_user.id) + + if group: + + text = ( + f"👋 Добро пожаловать!\n\n" + f"Ваша группа:\n" + f"{group['group_name']}\n\n" + "Выберите действие ниже." + ) + + await message.answer( + text, + reply_markup=main_keyboard() + ) + + else: + + text = ( + "👋 Добро пожаловать!\n\n" + "Похоже, вы еще не выбрали группу.\n\n" + "Нажмите 🔍 Выбрать группу " + ) + + await message.answer( + text, + reply_markup=start_keyboard() + ) + + +@router.message(Command("help")) +async def help_command(message: Message): + + await message.answer( + "📖 Справка\n\n" + "🔍 Выберите группу.\n" + "📅 Получайте расписание на любой день.\n" + "💾 После выбора группа сохраняется автоматически.\n" + ) + +@router.message(F.contact) +async def get_phone( + message: Message +): + + await save_phone( + message.from_user.id, + message.contact.phone_number + ) + + await message.answer( + "✅ Телефон сохранён." + ) \ No newline at end of file diff --git a/handlers/support.py b/handlers/support.py new file mode 100644 index 0000000..f3f58fa --- /dev/null +++ b/handlers/support.py @@ -0,0 +1,82 @@ +from aiogram import Router, F +from aiogram.types import Message +from aiogram.fsm.context import FSMContext +from states import SupportState +from database import create_ticket +from config import ADMIN_IDS +from aiogram import Bot +from keyboards import ticket_keyboard + + +router = Router() + + +# пользователь нажал кнопку поддержки +@router.message(F.text == "✉️ Поддержка") +async def support_start( + message: Message, + state: FSMContext +): + + await state.set_state( + SupportState.waiting_message + ) + + await message.answer( + "✉️ Опишите проблему.\n\n" + "Напишите сообщение, и оно будет отправлено разработчику." + ) + + +# пользователь написал проблему --> соснул хуйца, я не буду фиксить +@router.message( + SupportState.waiting_message +) +@router.message( + SupportState.waiting_message +) +async def send_ticket( + message: Message, + state: FSMContext, + bot: Bot +): + + ticket_id = await create_ticket( + message.from_user.id, + message.text + ) + + + # уведомляем админов + + for admin_id in ADMIN_IDS: + + try: + + await bot.send_message( + admin_id, + ( + "📩 Новое обращение!\n\n" + f"🆔 Тикет: #{ticket_id}\n" + f"👤 Пользователь: " + f"{message.from_user.id}\n\n" + f"📝 Сообщение:\n" + f"{message.text}" + ), + reply_markup=ticket_keyboard(ticket_id) +) + + except Exception as e: + + print( + f"Ошибка отправки админу {admin_id}: {e}" + ) + + + await state.clear() + + + await message.answer( + f"✅ Обращение #{ticket_id} отправлено.\n\n" + "Спасибо за обратную связь!" + ) \ No newline at end of file diff --git a/keyboards.py b/keyboards.py new file mode 100644 index 0000000..45f8b80 --- /dev/null +++ b/keyboards.py @@ -0,0 +1,229 @@ +from aiogram.types import ( + ReplyKeyboardMarkup, + KeyboardButton, + InlineKeyboardMarkup, + InlineKeyboardButton, +) +from aiogram.types import KeyboardButton, ReplyKeyboardMarkup +import calendar +from datetime import date +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + + +def main_keyboard(): + + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton(text="📅 Расписание"), + KeyboardButton(text="🔍 Сменить группу") + ], + [ + KeyboardButton(text="✉️ Поддержка") + ] + ], + resize_keyboard=True + ) +def start_keyboard(): + + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton(text="🔍 Выбрать группу") + ] + ], + resize_keyboard=True + ) + +def date_keyboard(): + + kb = InlineKeyboardBuilder() + + kb.button( + text="📅 Сегодня", + callback_data="date:today" + ) + + kb.button( + text="🗓 Календарь", + callback_data="date:calendar" + ) + + kb.adjust(2) + + return kb.as_markup() + + +def groups_keyboard(groups): + + keyboard = [] + + for group in groups: + + keyboard.append( + [ + InlineKeyboardButton( + text=f"{group['groupName']} (курс {group['course']})", + callback_data=f"select_group:{group['groupID']}" + ) + ] + ) + + return InlineKeyboardMarkup( + inline_keyboard=keyboard + ) + +def calendar_keyboard(year: int, month: int): + + kb = [] + + months = [ + "", + "Январь", + "Февраль", + "Март", + "Апрель", + "Май", + "Июнь", + "Июль", + "Август", + "Сентябрь", + "Октябрь", + "Ноябрь", + "Декабрь", + ] + + kb.append([ + InlineKeyboardButton( + text=f"{months[month]} {year}", + callback_data="ignore" + ) + ]) + + kb.append([ + InlineKeyboardButton(text="Пн", callback_data="ignore"), + InlineKeyboardButton(text="Вт", callback_data="ignore"), + InlineKeyboardButton(text="Ср", callback_data="ignore"), + InlineKeyboardButton(text="Чт", callback_data="ignore"), + InlineKeyboardButton(text="Пт", callback_data="ignore"), + InlineKeyboardButton(text="Сб", callback_data="ignore"), + InlineKeyboardButton(text="Вс", callback_data="ignore"), + ]) + + cal = calendar.monthcalendar(year, month) + + for week in cal: + + row = [] + + for day in week: + + if day == 0: + row.append( + InlineKeyboardButton( + text=" ", + callback_data="ignore" + ) + ) + else: + row.append( + InlineKeyboardButton( + text=str(day), + callback_data=f"calendar:{year}:{month}:{day}" + ) + ) + + kb.append(row) + + prev_month = month - 1 + prev_year = year + + if prev_month == 0: + prev_month = 12 + prev_year -= 1 + + next_month = month + 1 + next_year = year + + if next_month == 13: + next_month = 1 + next_year += 1 + + kb.append([ + InlineKeyboardButton( + text="⬅️", + callback_data=f"month:{prev_year}:{prev_month}" + ), + InlineKeyboardButton( + text="🏠 Сегодня", + callback_data="calendar_today" + ), + InlineKeyboardButton( + text="➡️", + callback_data=f"month:{next_year}:{next_month}" + ), + ]) + + return InlineKeyboardMarkup( + inline_keyboard=kb + ) + +def subgroup_keyboard(): + + kb = InlineKeyboardBuilder() + + kb.button(text="Общая", callback_data="sub:0") + + for i in range(1, 5): + kb.button(text=f"{i}А", callback_data=f"sub:{i}А") + kb.button(text=f"{i}Б", callback_data=f"sub:{i}Б") + + kb.adjust(1, 2, 2, 2, 2) + + return kb.as_markup() + +def support_keyboard(): + + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton( + text="✉️ Поддержка" + ) + ] + ], + resize_keyboard=True + ) + +def ticket_keyboard(ticket_id: int): + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="💬 Ответить", + callback_data=f"reply_ticket:{ticket_id}" + ), + InlineKeyboardButton( + text="✅ Закрыть", + callback_data=f"close_ticket:{ticket_id}" + ) + ] + ] + ) + +def phone_keyboard(): + + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton( + text="📱 Отправить телефон", + request_contact=True + ) + ] + ], + resize_keyboard=True, + one_time_keyboard=True + ) \ No newline at end of file diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..6451cff --- /dev/null +++ b/logger.py @@ -0,0 +1,12 @@ +import logging + + +logging.basicConfig( + filename="logs/bot.log", + level=logging.INFO, + format= + "%(asctime)s | %(message)s" +) + + +logger = logging.getLogger() \ No newline at end of file diff --git a/logs/bot.log b/logs/bot.log new file mode 100644 index 0000000..9241644 --- /dev/null +++ b/logs/bot.log @@ -0,0 +1,128 @@ +2026-07-09 22:53:52,570 | База данных инициализирована +2026-07-09 22:53:52,570 | Бот запущен +2026-07-09 22:53:52,571 | Start polling +2026-07-09 22:53:52,854 | Run polling for bot @ural_colleges_bot id=8809518082 - 'УЭК' +2026-07-09 22:53:55,054 | Update id=210321910 is handled. Duration 712 ms by bot id=8809518082 +2026-07-09 22:54:00,942 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Groups "HTTP/1.1 200 OK" +2026-07-09 22:54:01,106 | Update id=210321911 is handled. Duration 1852 ms by bot id=8809518082 +2026-07-09 22:54:03,001 | Update id=210321912 is handled. Duration 439 ms by bot id=8809518082 +2026-07-09 22:54:04,262 | Update id=210321913 is handled. Duration 356 ms by bot id=8809518082 +2026-07-09 22:54:06,227 | Update id=210321914 is handled. Duration 107 ms by bot id=8809518082 +2026-07-09 22:54:07,597 | Update id=210321915 is handled. Duration 215 ms by bot id=8809518082 +2026-07-09 22:54:09,584 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12679&sdate=2026-07-02 "HTTP/1.1 200 OK" +2026-07-09 22:54:09,812 | Update id=210321916 is handled. Duration 891 ms by bot id=8809518082 +2026-07-09 22:54:11,908 | Update id=210321917 is handled. Duration 110 ms by bot id=8809518082 +2026-07-09 22:54:13,406 | Update id=210321918 is handled. Duration 117 ms by bot id=8809518082 +2026-07-09 22:54:15,273 | Update id=210321919 is handled. Duration 228 ms by bot id=8809518082 +2026-07-09 22:54:16,785 | Update id=210321920 is handled. Duration 212 ms by bot id=8809518082 +2026-07-09 22:54:17,569 | Update id=210321921 is handled. Duration 210 ms by bot id=8809518082 +2026-07-09 22:54:18,463 | Update id=210321922 is handled. Duration 210 ms by bot id=8809518082 +2026-07-09 22:54:19,173 | Update id=210321923 is handled. Duration 216 ms by bot id=8809518082 +2026-07-09 22:54:23,103 | Update id=210321924 is handled. Duration 201 ms by bot id=8809518082 +2026-07-09 22:54:24,779 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12679&sdate=2026-02-02 "HTTP/1.1 200 OK" +2026-07-09 22:54:24,983 | Update id=210321925 is handled. Duration 998 ms by bot id=8809518082 +2026-07-09 22:54:33,062 | Update id=210321926 is handled. Duration 111 ms by bot id=8809518082 +2026-07-09 22:55:26,983 | Received SIGINT signal +2026-07-09 22:55:26,984 | Polling stopped for bot @ural_colleges_bot id=8809518082 - 'УЭК' +2026-07-09 22:55:26,984 | Polling stopped +2026-07-09 22:55:27,235 | Бот остановлен +2026-07-09 23:03:50,570 | База данных инициализирована +2026-07-09 23:03:50,570 | Удалено 0 записей кэша +2026-07-09 23:03:50,570 | Фоновое обновление кэша запущено +2026-07-09 23:03:50,571 | Бот запущен +2026-07-09 23:03:50,571 | Start polling +2026-07-09 23:03:50,925 | Run polling for bot @ural_colleges_bot id=8809518082 - 'УЭК' +2026-07-09 23:03:56,560 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12679&sdate=2026-07-09 "HTTP/1.1 200 OK" +2026-07-09 23:03:56,785 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12679&sdate=2026-07-10 "HTTP/1.1 200 OK" +2026-07-09 23:03:59,421 | Update id=210321927 is not handled. Duration 2 ms by bot id=8809518082 +2026-07-09 23:04:01,960 | Update id=210321928 is handled. Duration 578 ms by bot id=8809518082 +2026-07-09 23:04:12,965 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Groups "HTTP/1.1 200 OK" +2026-07-09 23:04:13,099 | Update id=210321929 is handled. Duration 1131 ms by bot id=8809518082 +2026-07-09 23:04:14,717 | Update id=210321930 is handled. Duration 244 ms by bot id=8809518082 +2026-07-09 23:04:18,738 | Update id=210321931 is handled. Duration 342 ms by bot id=8809518082 +2026-07-09 23:04:21,391 | Update id=210321932 is handled. Duration 125 ms by bot id=8809518082 +2026-07-09 23:04:23,676 | Update id=210321933 is handled. Duration 211 ms by bot id=8809518082 +2026-07-09 23:04:25,564 | Update id=210321934 is handled. Duration 251 ms by bot id=8809518082 +2026-07-09 23:04:28,888 | Update id=210321935 is handled. Duration 218 ms by bot id=8809518082 +2026-07-09 23:04:29,991 | Update id=210321936 is handled. Duration 309 ms by bot id=8809518082 +2026-07-09 23:04:30,928 | Update id=210321937 is handled. Duration 262 ms by bot id=8809518082 +2026-07-09 23:04:31,692 | Update id=210321938 is handled. Duration 269 ms by bot id=8809518082 +2026-07-09 23:04:32,744 | Update id=210321939 is handled. Duration 304 ms by bot id=8809518082 +2026-07-09 23:04:37,039 | Update id=210321940 is handled. Duration 291 ms by bot id=8809518082 +2026-07-09 23:04:40,175 | Update id=210321941 is handled. Duration 400 ms by bot id=8809518082 +2026-07-09 23:04:42,251 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12638&sdate=2025-11-07 "HTTP/1.1 200 OK" +2026-07-09 23:04:43,480 | Update id=210321942 is handled. Duration 2103 ms by bot id=8809518082 +2026-07-09 23:09:58,630 | Received SIGINT signal +2026-07-09 23:09:58,631 | Polling stopped for bot @ural_colleges_bot id=8809518082 - 'УЭК' +2026-07-09 23:09:58,631 | Polling stopped +2026-07-09 23:09:58,882 | Бот остановлен +2026-07-09 23:11:18,096 | База данных инициализирована +2026-07-09 23:11:18,096 | Удалено 0 записей кэша +2026-07-09 23:11:18,096 | Фоновое обновление кэша запущено +2026-07-09 23:11:18,096 | Бот запущен +2026-07-09 23:11:18,096 | Start polling +2026-07-09 23:11:18,580 | Run polling for bot @ural_colleges_bot id=8809518082 - 'УЭК' +2026-07-09 23:11:19,151 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12638&sdate=2026-07-09 "HTTP/1.1 200 OK" +2026-07-09 23:11:19,341 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12638&sdate=2026-07-10 "HTTP/1.1 200 OK" +2026-07-09 23:11:19,899 | Update id=210321943 is handled. Duration 1197 ms by bot id=8809518082 +2026-07-09 23:11:36,111 | Update id=210321944 is handled. Duration 298 ms by bot id=8809518082 +2026-07-09 23:11:37,267 | Update id=210321945 is handled. Duration 93 ms by bot id=8809518082 +2026-07-09 23:11:44,401 | Update id=210321946 is handled. Duration 144 ms by bot id=8809518082 +2026-07-09 23:12:17,894 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Groups "HTTP/1.1 200 OK" +2026-07-09 23:12:18,487 | Update id=210321947 is handled. Duration 2905 ms by bot id=8809518082 +2026-07-09 23:12:23,004 | Update id=210321948 is handled. Duration 254 ms by bot id=8809518082 +2026-07-09 23:12:24,230 | Update id=210321949 is handled. Duration 240 ms by bot id=8809518082 +2026-07-09 23:12:28,705 | Update id=210321950 is handled. Duration 210 ms by bot id=8809518082 +2026-07-09 23:12:31,863 | Update id=210321951 is handled. Duration 347 ms by bot id=8809518082 +2026-07-09 23:12:35,168 | Update id=210321952 is handled. Duration 116 ms by bot id=8809518082 +2026-07-09 23:12:37,284 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12769&sdate=2026-07-09 "HTTP/1.1 200 OK" +2026-07-09 23:12:37,410 | Update id=210321953 is handled. Duration 892 ms by bot id=8809518082 +2026-07-09 23:12:39,908 | Update id=210321954 is handled. Duration 109 ms by bot id=8809518082 +2026-07-09 23:12:40,110 | Update id=210321955 is handled. Duration 100 ms by bot id=8809518082 +2026-07-09 23:12:41,239 | Update id=210321956 is handled. Duration 104 ms by bot id=8809518082 +2026-07-09 23:12:42,118 | Update id=210321957 is handled. Duration 105 ms by bot id=8809518082 +2026-07-09 23:12:42,834 | Update id=210321958 is handled. Duration 109 ms by bot id=8809518082 +2026-07-09 23:12:43,753 | Update id=210321959 is handled. Duration 101 ms by bot id=8809518082 +2026-07-09 23:12:44,576 | Update id=210321960 is handled. Duration 95 ms by bot id=8809518082 +2026-07-09 23:12:46,112 | Update id=210321961 is handled. Duration 104 ms by bot id=8809518082 +2026-07-09 23:12:50,923 | Update id=210321962 is handled. Duration 102 ms by bot id=8809518082 +2026-07-09 23:12:56,554 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12769&sdate=2026-07-08 "HTTP/1.1 200 OK" +2026-07-09 23:12:56,885 | Update id=210321963 is handled. Duration 1132 ms by bot id=8809518082 +2026-07-09 23:12:58,757 | Update id=210321964 is handled. Duration 233 ms by bot id=8809518082 +2026-07-09 23:12:59,798 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12769&sdate=2026-07-07 "HTTP/1.1 200 OK" +2026-07-09 23:13:00,053 | Update id=210321965 is handled. Duration 426 ms by bot id=8809518082 +2026-07-09 23:13:00,614 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12769&sdate=2026-07-06 "HTTP/1.1 200 OK" +2026-07-09 23:13:00,844 | Update id=210321966 is handled. Duration 398 ms by bot id=8809518082 +2026-07-09 23:13:01,290 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12769&sdate=2026-07-05 "HTTP/1.1 200 OK" +2026-07-09 23:13:01,583 | Update id=210321967 is handled. Duration 464 ms by bot id=8809518082 +2026-07-09 23:13:28,484 | Update id=210321968 is handled. Duration 440 ms by bot id=8809518082 +2026-07-09 23:13:34,189 | Update id=210321969 is handled. Duration 348 ms by bot id=8809518082 +2026-07-09 23:13:41,625 | Update id=210321970 is handled. Duration 215 ms by bot id=8809518082 +2026-07-09 23:13:50,633 | Update id=210321971 is handled. Duration 234 ms by bot id=8809518082 +2026-07-09 23:13:54,291 | Update id=210321972 is handled. Duration 658 ms by bot id=8809518082 +2026-07-09 23:13:56,382 | Update id=210321973 is handled. Duration 224 ms by bot id=8809518082 +2026-07-09 23:13:57,276 | Update id=210321974 is handled. Duration 95 ms by bot id=8809518082 +2026-07-09 23:13:58,843 | Update id=210321975 is handled. Duration 217 ms by bot id=8809518082 +2026-07-09 23:14:00,451 | HTTP Request: GET https://xn--j1ab.xn----7sbndgvfca2ar9a.xn--p1ai/api/Rasp?idGroup=12769&sdate=2026-07-04 "HTTP/1.1 200 OK" +2026-07-09 23:14:00,677 | Update id=210321976 is handled. Duration 1037 ms by bot id=8809518082 +2026-07-09 23:14:02,721 | Update id=210321977 is handled. Duration 111 ms by bot id=8809518082 +2026-07-09 23:14:04,556 | Update id=210321978 is handled. Duration 206 ms by bot id=8809518082 +2026-07-09 23:14:16,271 | Update id=210321979 is handled. Duration 352 ms by bot id=8809518082 +2026-07-09 23:14:20,890 | Update id=210321980 is handled. Duration 209 ms by bot id=8809518082 +2026-07-09 23:14:25,022 | Update id=210321981 is handled. Duration 87 ms by bot id=8809518082 +2026-07-09 23:14:26,404 | Update id=210321982 is handled. Duration 319 ms by bot id=8809518082 +2026-07-09 23:14:32,718 | Update id=210321983 is not handled. Duration 2 ms by bot id=8809518082 +2026-07-09 23:14:41,187 | Received SIGINT signal +2026-07-09 23:14:41,188 | Polling stopped for bot @ural_colleges_bot id=8809518082 - 'УЭК' +2026-07-09 23:14:41,189 | Polling stopped +2026-07-09 23:14:41,441 | Бот остановлен +2026-07-09 23:27:37,784 | База данных инициализирована +2026-07-09 23:27:37,784 | Удалено 0 записей кэша +2026-07-09 23:27:37,784 | Фоновое обновление кэша запущено +2026-07-09 23:27:37,784 | Бот запущен +2026-07-09 23:27:37,784 | Start polling +2026-07-09 23:27:38,246 | Run polling for bot @ural_colleges_bot id=8809518082 - 'УЭК' +2026-07-09 23:27:49,524 | Received SIGINT signal +2026-07-09 23:27:49,525 | Polling stopped for bot @ural_colleges_bot id=8809518082 - 'УЭК' +2026-07-09 23:27:49,525 | Polling stopped +2026-07-09 23:27:49,779 | Бот остановлен diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..44df3d1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,27 @@ +aiofiles==24.1.0 +aiogram==3.13.1 +aiohappyeyeballs==2.7.1 +aiohttp==3.10.11 +aiosignal==1.4.0 +aiosqlite==0.20.0 +annotated-types==0.7.0 +anyio==4.14.1 +attrs==26.1.0 +cachetools==5.5.0 +certifi==2026.6.17 +frozenlist==1.8.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.27.2 +idna==3.18 +magic-filter==1.0.12 +multidict==6.7.1 +propcache==0.5.2 +pydantic==2.9.2 +pydantic_core==2.23.4 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +six==1.17.0 +sniffio==1.3.1 +typing_extensions==4.16.0 +yarl==1.24.2 diff --git a/schedule.py b/schedule.py new file mode 100644 index 0000000..382f8e4 --- /dev/null +++ b/schedule.py @@ -0,0 +1,41 @@ +import asyncio + +from datetime import date, timedelta + +from api import api +from database import get_active_groups + + +async def refresh_cache(): + + while True: + + groups = await get_active_groups() + + if groups: + + print(f"[CACHE] Обновляем {len(groups)} групп") + + for group_id in groups: + + try: + + # сегодня + await api.get_schedule( + group_id, + date.today() + ) + + # завтра + await api.get_schedule( + group_id, + date.today() + timedelta(days=1) + ) + + except Exception as e: + + print( + f"[CACHE] Ошибка {group_id}: {e}" + ) + + await asyncio.sleep(1800) \ No newline at end of file diff --git a/states.py b/states.py new file mode 100644 index 0000000..f9b9c56 --- /dev/null +++ b/states.py @@ -0,0 +1,13 @@ +from aiogram.fsm.state import State, StatesGroup +from aiogram.fsm.state import StatesGroup, State + +class SupportState(StatesGroup): + + waiting_message = State() + +class GroupState(StatesGroup): + waiting_group = State() + +class AdminState(StatesGroup): + + waiting_reply = State() \ No newline at end of file