Initial commit
Some checks failed
Build and Run VSTU Public TG bot / build_and_run (push) Failing after 6s
Some checks failed
Build and Run VSTU Public TG bot / build_and_run (push) Failing after 6s
This commit is contained in:
52
.gitea/workflows/deploy.yml
Normal file
52
.gitea/workflows/deploy.yml
Normal 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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
BIN
__pycache__/data_manager.cpython-313.pyc
Normal file
BIN
__pycache__/data_manager.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/database.cpython-313.pyc
Normal file
BIN
__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/keyboards.cpython-313.pyc
Normal file
BIN
__pycache__/keyboards.cpython-313.pyc
Normal file
Binary file not shown.
52
data_manager.py
Normal file
52
data_manager.py
Normal 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
47
database.py
Normal 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
36
keyboards.py
Normal 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
353
main.py
Normal 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())
|
||||||
Reference in New Issue
Block a user