Files
VSTU_Public_TG_Bot/main.py
FazziCLAY 30bca7a64a
Some checks failed
Build and Run VSTU Public TG bot / build_and_run (push) Failing after 6s
Initial commit
2026-04-05 22:31:00 +03:00

353 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())