commit 30bca7a64a037df3553b5a70c4de41938dd9d2d5 Author: FazziCLAY Date: Sun Apr 5 22:31:00 2026 +0300 Initial commit diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..22852bb --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,52 @@ +name: Build and Run VSTU Public TG bot + +on: + push: + branches: + - main + +jobs: + build_and_run: + runs-on: ubuntu-latest + + steps: + # Шаг 1: Получаем исходный код проекта + - name: Checkout code + uses: actions/checkout@v3 + + # Шаг 2: Сборка Docker-образа локально на хост-машине + # Мы не пушим его в registry, а просто создаем с нужным тегом. + - name: Build Docker image + run: docker build -t vstu_public_tg_bot:latest . + + # Шаг 3: Перезапуск контейнера на хост-машине + # Это сердце упрощенного workflow + - name: Restart the container + run: | + # 1. Останавливаем и удаляем старый контейнер, если он существует. + # `docker ps -q -f name=...` вернет ID контейнера, если он запущен. + # Конструкция `[ $(...) ] && ...` проверит, не пустой ли вывод. + if [ "$(docker ps -q -f name=vstu_public_tg_bot)" ]; then + echo "Stopping and removing existing container..." + docker stop vstu_public_tg_bot + docker rm vstu_public_tg_bot + else + echo "No running container found. Skipping stop/remove." + fi + + # 2. Запускаем новый контейнер из только что собранного локального образа. + # Команда точно такая же, как твоя. + echo "Starting new container..." + docker run -d \ + --network cl2so4 \ + --ip 172.20.0.63 \ + -v /home/holder/fclay/secrets/vstu_public_tg_bot.env:/app/.env \ + --restart=always \ + --name=vstu_public_tg_bot \ + vstu_public_tg_bot:latest + + # (Опционально) Шаг 4: Очистка старых, "висячих" образов + # Это хорошая практика, чтобы не засорять диск. + - name: Clean up old images + if: always() # Выполнять этот шаг всегда, даже если предыдущие провалились + run: docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/__pycache__/data_manager.cpython-313.pyc b/__pycache__/data_manager.cpython-313.pyc new file mode 100644 index 0000000..b717da0 Binary files /dev/null and b/__pycache__/data_manager.cpython-313.pyc differ diff --git a/__pycache__/database.cpython-313.pyc b/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..ec16ae7 Binary files /dev/null and b/__pycache__/database.cpython-313.pyc differ diff --git a/__pycache__/keyboards.cpython-313.pyc b/__pycache__/keyboards.cpython-313.pyc new file mode 100644 index 0000000..17cc644 Binary files /dev/null and b/__pycache__/keyboards.cpython-313.pyc differ diff --git a/data_manager.py b/data_manager.py new file mode 100644 index 0000000..f1c814a --- /dev/null +++ b/data_manager.py @@ -0,0 +1,52 @@ +import aiohttp +import logging + +logger = logging.getLogger(__name__) + +class DataManager: + def __init__(self, facultets_url, groups_url): + self.facultets_url = facultets_url + self.groups_url = groups_url + self.facs = {} # {id: {name: ...}} + self.groups = {} # {id: {real_name: ..., facultet_tech: ..., ...}} + + async def refresh_cache(self, connector): + async with aiohttp.ClientSession(connector=connector) as session: + async def fetch(url): + async with session.get(url) as r: return await r.json() + try: + self.facs = await fetch(self.facultets_url) + self.groups = (await fetch(self.groups_url))['groups'] + logger.info(f"Loaded {len(self.facs)} facs and {len(self.groups)} groups") + except Exception as e: + logger.error(f"Cache refresh failed: {e}") + + def get_groups_by_fac(self, fac_id): + return [ + (gid, g['real_name']) for gid, g in self.groups.items() + if g.get('facultet_tech') == fac_id or g.get('facultet_recognized') == fac_id + ] + + def get_files_by_fac(self, fac_id): + files = {} # {uniqpath*: display_name} + for g in self.groups.values(): + if g.get('facultet_tech') == fac_id or g.get('facultet_recognized') == fac_id: + for ex in g.get('excels', []): + fn = ex['uniqpath'].replace("vstu.ru/rasp?dep=", "") + files[fn] = ex['display_filename'] + return list(files.items()) + + def get_excel_by_uniqpath(self, uniqid): + for g in self.groups.values(): + for ex in g.get('excels', []): + if uniqid == ex['uniqpath']: + return ex + + return None + + def find_group_by_name(self, name): + name = name.lower() + for gid, g in self.groups.items(): + if g['real_name'].lower() == name: + return gid, g['real_name'] + return None, None \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..30ac448 --- /dev/null +++ b/database.py @@ -0,0 +1,47 @@ +from datetime import datetime, timezone +import enum +from sqlalchemy import BigInteger, Column, DateTime, String, ForeignKey, Enum as SQLEnum +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy.ext.asyncio import AsyncAttrs + +class SubType(enum.Enum): + GROUP = "group" + EXCEL = "excel" + +class Base(AsyncAttrs, DeclarativeBase): + pass + +class Chat(Base): + __tablename__ = "tg_public_bot_chats" + chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=True) + username: Mapped[str] = mapped_column(String(255), nullable=True) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="user", cascade="all, delete-orphan") + + +class SentStatus(Base): + __tablename__ = "tg_public_bot_sent_statuses" + id: Mapped[int] = mapped_column(primary_key=True) + sub_id: Mapped[int] = mapped_column(ForeignKey("tg_public_bot_subscriptions.id")) + guid: Mapped[str] = mapped_column(String(32), nullable=True) + end_state: Mapped[str] = mapped_column(String(64), nullable=True) + log: Mapped[str] = mapped_column(String, nullable=True) + timestamp = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + +class Subscription(Base): + __tablename__ = "tg_public_bot_subscriptions" + id: Mapped[int] = mapped_column(primary_key=True) + chat_id: Mapped[int] = mapped_column(ForeignKey("tg_public_bot_chats.chat_id")) + created_by: Mapped[int] = mapped_column(BigInteger, nullable=True) + sub_type: Mapped[SubType] = mapped_column(SQLEnum(SubType)) + value: Mapped[str] = mapped_column(String(255)) # Имя группы или имя файла? + human_name: Mapped[str] = mapped_column(String(255)) # Читаемое название (для вывода в /my) + is_pattern: Mapped[bool] = mapped_column(default=False) + deleted: Mapped[bool] = mapped_column(default=False) + message_thread_id: Mapped[int] = mapped_column(BigInteger, nullable=True) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + user: Mapped["Chat"] = relationship(back_populates="subscriptions") \ No newline at end of file diff --git a/keyboards.py b/keyboards.py new file mode 100644 index 0000000..9805108 --- /dev/null +++ b/keyboards.py @@ -0,0 +1,36 @@ +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram import types + +def fac_kb(action_prefix: str, facs: dict, back="menu_start"): + builder = InlineKeyboardBuilder() + for f_id, f_data in facs.items(): + if f_id.startswith("_"): continue + + bt = None + try: + bt = f_data['short_names'][0] + except: pass + if bt is None: + try: + bt = f_data['full_name'] + except: pass + + if bt is not None: + builder.row(types.InlineKeyboardButton( + text=bt, + callback_data=f"{action_prefix}:{f_id}") + ) + + builder.adjust(2) + builder.row(types.InlineKeyboardButton(text="⬅️ Назад", callback_data=back)) + return builder.as_markup() + +def items_kb(action_prefix: str, items: list, max_col=1, back="menu_start"): + # items: [(id, name), ...] + builder = InlineKeyboardBuilder() + for item_id, item_name in items[:99]: # Лимит TG на кнопки + builder.row(types.InlineKeyboardButton(text=item_name, callback_data=f"{action_prefix}:{item_id}")) + + builder.adjust(max_col) + builder.row(types.InlineKeyboardButton(text="⬅️ Назад", callback_data=back)) + return builder.as_markup() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e0d6c6a --- /dev/null +++ b/main.py @@ -0,0 +1,353 @@ +import os +import asyncio +import json +import logging +import traceback +from dotenv import load_dotenv + +import aio_pika +import aiohttp +from aiogram import Bot, Dispatcher, types, F +from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.filters import Command +from aiohttp_socks import ProxyConnector +from sqlalchemy import and_, select, delete +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker + +from database import Chat, SentStatus, Subscription, SubType, Base +from data_manager import DataManager +import keyboards as kb + +import re +import fnmatch + +load_dotenv() + +# --- Настройки --- +engine = create_async_engine(os.getenv("DATABASE_URL")) +async_session = async_sessionmaker(engine, expire_on_commit=False) +data_mgr = DataManager(os.getenv("FACULTETS_JSON_URL"), os.getenv("GROUPS_JSON_URL")) +bot = None +dp = Dispatcher() + +# --- RabbitMQ Consumer v2 --- +async def start_rabbitmq_consumer(): + while bot is None: await asyncio.sleep(1) + conn = await aio_pika.connect_robust(os.getenv("RABBITMQ_URL")) + print("RabbitMQ conn created") + async with conn: + channel = await conn.channel() + # В v2 используем Topic Exchange + exchange = await channel.declare_exchange("vstu_schedule", aio_pika.ExchangeType.TOPIC, durable=True) + queue = await channel.declare_queue(name="vstu_tg_public_bot-queue", durable=True) + # Слушаем изменения в файлах + await queue.bind(exchange, routing_key="schedule_logging_service.event.excel.#") + + async with queue.iterator() as q_iter: + async for message in q_iter: + async with message.process(): + data = json.loads(message.body.decode('utf-8')) + print(f"{message.body_size}: data={data}") + try: + await process_sls_event(data) + except Exception as e: + print(e) + print("END") + +async def process_sls_event(data): + affected_groups = data.get("diff_summary", {}).get("affected_groups", []) + ai_text = data.get("ai_summary", {}) + guid = data.get("log_guid", "???") + + async with async_session() as session: + # Получаем ВСЕ подписки на группы, чтобы проверить паттерны + # (В будущем здесь можно оптимизировать, выгружая только паттерны) + stmt = select(Subscription).where(Subscription.deleted == False) + all_subs = (await session.execute(stmt)).scalars().all() + + def is_match_func(sub: Subscription, check_value: str): + if check_value is None or sub is None: return False + + if not sub.is_pattern and sub.value.lower() == check_value.lower(): + return True + elif sub.is_pattern and match_pattern(sub.value, check_value): + return True + return False + + for sub in all_subs: + is_match = False + value = None + is_skip_humanlike = False + if sub.sub_type == SubType.EXCEL: + fn = data.get("filename", None) + is_match = is_match_func(sub, fn) + if is_match: + value = fn + + elif sub.sub_type == SubType.GROUP: + for g_name in affected_groups: + if not is_match: + is_match = is_match_func(sub, g_name) + if is_match: + is_skip_humanlike = not sub.is_pattern + value = g_name + else: + print(f"Unknown sub_type: {sub.sub_type}") + + if is_match: + text = data.get("humanlike_message", "") if not is_skip_humanlike else "" + + if value in ai_text.keys(): + text = ai_text.get(value, "?_?") + + else: + text += f"\n\nAI Ревью: {ai_text.get('wide_review', '???')}" + + await send_sub_text(session, sub, guid, text.strip()) + +async def send_sub_text(session: AsyncSession, sub: Subscription, guid: str, text: str, ads=None): + print("send_sub_text") + is_empty = len(text) == 0 + + if not is_empty and ads is not None: + text += ("\n" + str(ads)) + + status = SentStatus(sub_id=sub.id, guid=guid, end_state="START_SENDING" if not is_empty else "EMPTY_TEXT", log=text) + session.add(status) + await session.commit() + + if is_empty: + return + + await session.refresh(status) + + success = False + err_text = None + try: + await bot.send_message( + sub.chat_id, + f"🔔 Обновление расписания ({sub.value}):\n{text}", + parse_mode="HTML" + ) + print(f"SENT TO TG: chat_id={sub.chat_id}!") + success = True + except Exception as e: + err_text = str(e) + + status.end_state = "SUCCESS" if success else "FAILED" + status.log = err_text + + session.add(status) + await session.commit() + + +def match_pattern(pattern: str, text: str) -> bool: + """Проверяет текст на соответствие маске (иит* -> ИИТ-273)""" + # fnmatch делает именно то, что ты хочешь: иит* совпадет с иит-273 + return fnmatch.fnmatch(text.lower(), pattern.lower()) + +# --- Handlers --- + +@dp.message(Command("start")) +@dp.message(Command("vstu_rasp")) +async def cmd_start(message: types.Message): + builder = InlineKeyboardBuilder() + builder.row(types.InlineKeyboardButton(text="👥 Подписка на группу", callback_data="sub_nav:group")) + builder.row(types.InlineKeyboardButton(text="📄 Подписка на файл", callback_data="sub_nav:excel")) + builder.row(types.InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subs")) + builder.row(types.InlineKeyboardButton(text="🧩 Подписка по маске", callback_data="sub_mask_info")) + await message.answer("Выберите действие:", reply_markup=builder.as_markup()) + + async with async_session() as session: + await session.merge(Chat(chat_id=message.chat.id, name=message.chat.full_name, username=message.chat.username)) + await session.commit() + +@dp.callback_query(F.data == "menu_start") +async def back_to_start(callback: types.CallbackQuery): + await cmd_start(callback.message) + await callback.message.delete() + +# Навигация: Выбор факультета +@dp.callback_query(F.data.startswith("sub_nav:")) +async def sub_nav_fac(callback: types.CallbackQuery): + mode = callback.data.split(":")[1] # group или excel + await callback.message.edit_text("Выберите:", reply_markup=kb.fac_kb(f"fac_pick:{mode}", data_mgr.facs)) + +# Навигация: Выбор конкретного элемента +@dp.callback_query(F.data.startswith("fac_pick:")) +async def sub_nav_items(callback: types.CallbackQuery): + _, mode, fac_id = callback.data.split(":") + + if fac_id == "asp": + await callback.message.answer("Аспиратны: у вас беды с названием групп (и тг бот ломается), используйте маску с фамилией преподователя, должно работать.") + return + + if mode == "group": + items = data_mgr.get_groups_by_fac(fac_id) + await callback.message.edit_text("Выберите группу:", reply_markup=kb.items_kb("view_group", items, max_col=3, back="sub_nav:group")) + else: + items = data_mgr.get_files_by_fac(fac_id) + await callback.message.edit_text("Выберите файл:", reply_markup=kb.items_kb("view_excel", items, back="sub_nav:excel")) + +# Просмотр группы +@dp.callback_query(F.data.startswith("view_group:")) +async def view_group(callback: types.CallbackQuery): + g_id = callback.data.split(":")[1] + g_name = data_mgr.groups[g_id]['real_name'] + builder = InlineKeyboardBuilder() + builder.row(types.InlineKeyboardButton(text="🔔 Подписаться", callback_data=f"suco:group:{g_name}")) + builder.row(types.InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_start")) + await callback.message.edit_text(f"🏫 Группа: *{g_name}*", reply_markup=builder.as_markup(), parse_mode="Markdown") + +# Просмотр файла +@dp.callback_query(F.data.startswith("view_excel:")) +async def view_excel(callback: types.CallbackQuery): + pre_uniq_path = callback.data.split(":")[1] + uniqpath = "vstu.ru/rasp?dep=" + pre_uniq_path + # Находим имя файла для красоты + h_name = "Excel файл" + for g in data_mgr.groups.values(): + for ex in g.get('excels', []): + if ex.get('uniqpath', "") == uniqpath: h_name = ex['display_filename']; break + + builder = InlineKeyboardBuilder() + builder.row(types.InlineKeyboardButton(text="🔔 Подписаться на файл", callback_data=f"suco:excel:{pre_uniq_path}")) + builder.row(types.InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_start")) + await callback.message.edit_text(f"📄 Файл: *{h_name}*", reply_markup=builder.as_markup(), parse_mode="Markdown") + +@dp.callback_query(F.data.startswith("suco:")) +async def sub_confirm(callback: types.CallbackQuery): + parts = callback.data.split(":") + mode, val = parts[1], parts[2] + h_name = parts[3] if len(parts) > 3 else val + sub_value = val + + if mode == "excel": + pre_uniq_path = h_name + uniqpath = "vstu.ru/rasp?dep=" + pre_uniq_path + excel = data_mgr.get_excel_by_uniqpath(uniqpath) + sub_value = excel['url'].split("/")[-1] + h_name = sub_value + + async with async_session() as session: + # Регистрация пользователя если нет + await session.merge(Chat(chat_id=callback.message.chat.id, name=callback.message.chat.full_name)) + session.add(Subscription(chat_id=callback.message.chat.id, sub_type=SubType(mode), value=sub_value, human_name=h_name, created_by=callback.from_user.id)) + await session.commit() + + await callback.answer("Подписка оформлена!") + await callback.message.answer(f"✅ Готово! Я уведомлю, когда *{h_name}* изменится.", parse_mode="Markdown") + +# Команда /sub для групп +@dp.message(Command("sub")) +async def cmd_sub_group(message: types.Message): + query = message.text.replace("/sub", "").strip() + if not query: return await message.reply("Напишите группу, например: `/sub ИИТ-273`", parse_mode="Markdown") + + g_id, g_name = data_mgr.find_group_by_name(query) + if not g_id: return await message.reply("Группа не найдена в базе v2.") + + builder = InlineKeyboardBuilder() + builder.row(types.InlineKeyboardButton(text="🔔 Подписаться", callback_data=f"sub_confirm:group:{g_name}")) + + # В группах отвечаем реплаем + await message.reply(f"Найдена группа: *{g_name}*", reply_markup=builder.as_markup(), parse_mode="Markdown") + +@dp.callback_query(F.data == "my_subs") +async def my_subs(callback: types.CallbackQuery): + async with async_session() as session: + stmt = select(Subscription).where(and_(Subscription.chat_id == callback.message.chat.id, Subscription.deleted == False)) + subs = (await session.execute(stmt)).scalars().all() + if not subs: return await callback.answer("Подписок нет", show_alert=True) + + builder = InlineKeyboardBuilder() + for s in subs: + builder.row(types.InlineKeyboardButton(text=f"❌ {s.human_name}", callback_data=f"unsub:{s.id}")) + builder.row(types.InlineKeyboardButton(text="⬅️ Меню", callback_data="menu_start")) + await callback.message.edit_text("Ваши подписки (нажми, чтобы удалить):", reply_markup=builder.as_markup()) + +@dp.callback_query(F.data.startswith("unsub:")) +async def unsub(callback: types.CallbackQuery): + sid = int(callback.data.split(":")[1]) + user_id = callback.from_user.id # ID того, кто нажал на кнопку + + async with async_session() as session: + # Добавляем условие chat_id == user_id + stmt = select(Subscription).where( + Subscription.id == sid, + Subscription.chat_id == user_id, + Subscription.deleted == False + ) + result = await session.execute(stmt) + result.deleted = True + session.add(result) + await session.commit() + + # Проверяем, было ли реально что-то удалено + await callback.answer("Подписка удалена") + + await my_subs(callback) + +@dp.callback_query(F.data == "sub_mask_info") +async def sub_mask_info(callback: types.CallbackQuery): + await callback.message.answer( + "Команда для подписки по маске:\n" + "`/sub_mask ИИТ*` — все группы ИИТ\n" + "`/sub_mask *161` — все группы первого курса (161)\n" + "`/sub_mask *` — ВООБЩЕ ВСЕ изменения в вузе\n\n" + "Отправь команду в чат.", + parse_mode="Markdown" + ) + +@dp.message(Command("sub_mask")) +async def cmd_sub_mask(message: types.Message): + pattern = message.text.replace("/sub_mask", "").strip() + if not pattern: + return await message.reply("Пример: `/sub_mask ИИТ*`") + + # Защита от слишком коротких масок (кроме '*') + if len(pattern) < 3 and pattern != "*": + return await message.reply("Слишком короткая маска (минимум 3 символа или '*')") + + async with async_session() as session: + await session.merge(Chat(chat_id=message.chat.id, name=message.chat.full_name)) + session.add(Subscription( + chat_id=message.chat.id, + created_by=message.from_user.id, + sub_type=SubType.GROUP, + value=pattern, + human_name=f"Маска: {pattern}", + is_pattern=True + )) + await session.commit() + + await message.reply(f"✅ Подписка на маску `{pattern}` оформлена!", parse_mode="Markdown") + +# --- Запуск --- +async def main(): + global bot + # Настройка прокси с таймаутом v2 (90 сек) + connector = ProxyConnector.from_url(os.getenv("SOCKS5_URL")) + bot = Bot(token=os.getenv("BOT_TOKEN"), session=AiohttpSession(proxy=os.getenv("SOCKS5_URL"))) + # Важно: ставим таймаут для сессии aiohttp внутри бота + bot.session._timeout = aiohttp.ClientTimeout(total=90) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + await data_mgr.refresh_cache(connector) + asyncio.create_task(start_rabbitmq_consumer()) + + # Периодическое обновление индексов + async def index_refresher(): + while True: + await asyncio.sleep(3600) + await data_mgr.refresh_cache(connector) + asyncio.create_task(index_refresher()) + + print("Start polling") + await dp.start_polling(bot) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file