Initial commit
Some checks failed
Build and Run VSTU Public TG bot / build_and_run (push) Failing after 6s

This commit is contained in:
2026-04-05 22:31:00 +03:00
commit 30bca7a64a
9 changed files with 541 additions and 0 deletions

View File

@@ -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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

Binary file not shown.

Binary file not shown.

Binary file not shown.

52
data_manager.py Normal file
View File

@@ -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

47
database.py Normal file
View File

@@ -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")

36
keyboards.py Normal file
View File

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

353
main.py Normal file
View File

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