Some checks failed
Build and Run VSTU Public TG bot / build_and_run (push) Failing after 6s
353 lines
15 KiB
Python
353 lines
15 KiB
Python
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()) |