From 30bca7a64a037df3553b5a70c4de41938dd9d2d5 Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Sun, 5 Apr 2026 22:31:00 +0300 Subject: [PATCH] Initial commit --- .gitea/workflows/deploy.yml | 52 ++++ .gitignore | 1 + __pycache__/data_manager.cpython-313.pyc | Bin 0 -> 4498 bytes __pycache__/database.cpython-313.pyc | Bin 0 -> 4242 bytes __pycache__/keyboards.cpython-313.pyc | Bin 0 -> 1939 bytes data_manager.py | 52 ++++ database.py | 47 +++ keyboards.py | 36 +++ main.py | 353 +++++++++++++++++++++++ 9 files changed, 541 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 __pycache__/data_manager.cpython-313.pyc create mode 100644 __pycache__/database.cpython-313.pyc create mode 100644 __pycache__/keyboards.cpython-313.pyc create mode 100644 data_manager.py create mode 100644 database.py create mode 100644 keyboards.py create mode 100644 main.py 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 0000000000000000000000000000000000000000..b717da011ed0b7a9a06750e84e7d1109a1dfde3c GIT binary patch literal 4498 zcmbtXYiv}<6`r}TeRz%6tg*2d+lwE-+KFvs%)4;UCzElD7HLbMD=1 zYy+u8$G&II%$#|i?|d`6SX$~Pkbd{!-BhiUkbhvoPC`*xJPOKXA`_V&A;UPKoy?td1>Hu;{%AJZAJw8sl|7JPtQfy+8e9dIi?a-Q z(@Tv*fwa8_(m@7Ey0~Ium6k?HlAAOV6pYeDG?q(e)vT`MSlVzWStd7X=>jzAhGf+Q zc|*|Ebb`V8!a_mlI94DKFb;>ihn4=94-Aj8Op--MMp9Z5^|#bmRv$hXJ{meU{JQ+w z;P4qen^Uq$Wi)ppor)Ll3XLQHt~m}BRvK7t zTdZp!gH)!wC?$u$fhS?BXXs@;J3J0?D|Z|qN#Z1t>+qd}FSd~!Uu%!+AkhXwG=AAG*4b^3bJ;P-uOXe> z;~DE=K8+9YkzOlL&;;4T*OLT=8M-%;7l^qE2yV-BPU0b3iUY ze1Zm@ksvi(v5ckxdNV8rmP-lIcM-nDJcwRu;pjDPV7X1vc#zmDeG);L@_{A)7tJB- zDypl|qQhDK_}Mn$*}ec@6@0Ge-8sYE1v!IHs#!yLOV4N!@;q_^Wrjmnbv>2QiZOqV zm4dM$Ce&=~q_Jgrq%t+ZRQ;q9i^fi>ZM)N%STwEowwacQPJKIw?;l_Q`x~zJTP%H9Dr(($G53w9kWjy#wl535+}DIGTyZ z)p%gc9YEd-L^aSI0gL^{D#N&+Kyi0qkxHxa?m)1_a70s?liBR3Q5sICR4pr8CuQ*@ zMvzIC<{XBU&Ln}*4I!l^G6tVkHN$n_Y)l=^!r2<4%2)tD zJn(JcCF}G{R~)rb{Y)>Quk^oyFpvE!UroWc<*skbtz8A*p1Zz1cdEYSXlWmPD2Sy! z_Zv1(356})vs=378hQ#FdM1QRuKUd`Q`V$eb=q%EjeGitKjXP^>+W~ArFoJ{;tgG37zx4JwNEb zfX$q*-d{?8MfdHNKHx#S;Rtu~Q|rPU_b0lqSNd}uXww^Lm`KyLp*2wWthEHpKHE;i zTZPYdNT~NxsGlLGOx(C}K?;kNQ>$_A{N*ez#+07Y*hxpr?<22suk$+JPIRts2O&AQokkl6`GF|E@ zZI&6N-9v=LtKepMy5$mc7A;}a{;h-zHCp${HtrR!e-|Moz=t}o@fz0z^V@oY1ck`s zr-G7IA?$tj%RymkER)nyV`|)RF*TZ2wCIQ$wJFa)-R>vA9}&o{h5|;RWpfknk?|zZMCoI#R{2+e7uzbh#Vk3fJS+YPx?XS}^KQ zSov{$ewk^I8^H5hmF$bphH?{uOPFl}D~7`vk_rr&)(vM)OPwB#W>1cl<3qBI<=R

QNB0=S@Wxa6XgUgj0cth@C>2TfOf;QSEd&}4rh-vSHH36Z&$1v)5R@#!ZNbSz z?5(pGQ0LUM2A9xu^ee*C2B;SiXxb@7d~R5c&*iOk&|QBQ1YCLEwd3M^`MN9qNq?cd zakjki%kt*&(Emc_7oErEYy8)nuQlIrPBna4^Wwz5`D*{w!`JEx)$Oy@?fH)Ioq@UP zKBx{fkY)ly&SG({D69l8a4+Qw}Nf%aD zT~R01t9>_4d|4Ts;O2eRH{93W{KD5d@2|atuBd8x#DgmyqAQ*}d;!Ka4}S_2IDGoF zN2MJp8UyJASJqrPHF>H~xp}s7GdSVG!SOIe)`b7U&kALYvt^CQ`xRf5y*OW0 zbM@46Mc|zWzf5s$@XOS#C?lD8E{(cJ zQBLQg>0(Qn0=&TVY&xZ>T1HXW8XTq(#fvDqQS_n+gD}=9ik^*T;iqskn`NmJxvZ)y z%1vUf#Jmg{;=&Y2LV5ti&&dKOh}8=om)NsVDTwX`k3(!+SSyJY-+D#S+J@N$hz)-# z8xDMR!f$fJ1<*fgzChUlXkfJ{%oz@&hKyic!Oq>*274KvGNMFp1_6&TO8-vQ!2j1S cZ^2b_*Hv?M=bWqQob;X0Mr#%b2y>YK0)?@Yq5uE@ literal 0 HcmV?d00001 diff --git a/__pycache__/database.cpython-313.pyc b/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec16ae7737e93c705a54639a55e8ca9937d5b524 GIT binary patch literal 4242 zcmd57&-72YM6Kce^}iWDVFvMl(YvCTC8t7WIQWtp^1S#qP59LHK@u_V_N|771O?&%0b-Ux8ctCdMSBrYparC!+xKSqCtJ;- zXn_*o+nM*ic{?+2zV~J~JsuYaztE?@%S`le+@G=2`q`U}&A&k7At!PoKh4EC!o_*Q zk7M65Z6OwTTBogXfe3LMvBmAg9(NE&+)14A9^zs+VY)Z&ChoY0c;a5-jr)j?>21?} zaX;~ob3@!-PP7kmqC>LG^|vO_d7iSGZPL$>PC)iZe#w1GXzBw@?*e_V6lkM^OzQ@% zr%OA)v|iBqy0oFRCET}x?!p%8OeuzHWU?xCqP(Bask31_bzRD&ujLFitrBXV%4@}J zjyf*`bRMC0(IA;znz~|nqGr;$X?2AP(OfYb7O3yCn$#4c7@1}DlA;56Mky536!m1; zQ%;)R+(gv?l+Wo)nL?PSu1S3*mz*>Vq9@Iznv!pJHbb<0@t#Pwq2X)GFPRXjWOkJ{^&8LbQw%xM) zZc)*i7N0EN$q?PpGC4Ju2ea+U+>M2wQd{)aRP;I>kY(L~i6>yvLuH^un{X<0XlvXPbx#e@d#5_v;{ zU?>ULv`Arv_`w+yxES3H;zRCxcc2sn@j;aAZkc!9tst0R-mSD1A|zl~HhOV9&U!X% zBO&Yzq8LK43kB{s1DLNG!|r%X;Aq_j;zN!KP7{H~ZIfx!>M2;ZPv!tniU^WzFDKbW zt0+uZh(mHpJ?K($iMB)dL~Ae7T#4}premnH#=oh13FMXqZrF}cO$W8++(~(l|C`HYn0-e5Wc_?$(89 z!$0^N=j#62(5tnH#hPC##s75t{L?$n-X^u;tovX_GAU|@ql#o7hXLdj93`ZS(7%Yy9yOO@PE~5v zpgcrCxh*1AP~iW+On34l@JU`nfjPe`rJsZ@4RrRRP4GQn(3=Eze(C(IHUQF_|Tf`*$J_Jc)mWoP>Pjf z)f1KJy0BnUaPILtpPj4?UF=ZsVq^C!Yp2)uJ~;~%+#M}lD=$}*mG_W>gZn?ext^*G zo-0k2Lse^Kq%NFm^o1VWTMgC*BegTPYki9!Tr;-|r?)NAZSMUFs@wmbd%y!{lW#2+ zn-vkOtQY>i7T;P=f}HZb#7*Fp*yd&%+Yi`46Wd|u&e&iV*5oS8fz@7~41g`v^wZa3 zdDAx7Wn&eY-4NW(l?{QlwX%LTau=*>m}P(fWPl)EgJB0oq*2ffh@lCd?3NtjVDoK5 zI7Zw>#?r%M~8Hui#eDJ^vK-AoGv&-eWo(g(W=M4FY~tUW9lwChM>g*q`+7;s@F zVSJNqS<#9rbuAUMN{(GrV+2`7T^U_2z;zRD30PIJf~Ke+7@;d^RW_EOGNmBp{J&(A zf$q}X&L^y%o`&U^e8BP<=E+%9>_ahu!n}~~`A_r7Vf2CVBnu$md*F@h_*r7!rEj91 zC7U;~#gfe}Y`uk8A(78(*wu40YxV{7U4bUkvuK7N6f|2Hf7x^SVf z>)t~+|wF5KHgn{bvqvQj~ax*(zG)4}szh4`g~WId6FgUONCVr zR!lGwG}MJr?->yVKOQrA;zKX&%V`{2>|jN)#Uix5^ug8E`~*BtQUJkk3_Stj?^d4Y zpL2))!1X@oZvCFS^_;s==WhIwJGLcQ_+UBp>r5rH#X)n+$@4wsv(@*vIB0Cy9lXJp fPi}Ge+zL8)iLVB+Cqd7{U*)SO|H0vt?bg2lM&Pyw literal 0 HcmV?d00001 diff --git a/__pycache__/keyboards.cpython-313.pyc b/__pycache__/keyboards.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..17cc6446d7ce6576bb10c9419e8b1181ea4d41b4 GIT binary patch literal 1939 zcmbVNO>7%Q6n?uqyZ%|*Nl2U~O&X_70(B)!n^H<6Xhluvk4ccc-O?%++x6N>Ht}C) z*EHcmia<@oA+6L(LHq?yt%O7lNFZ@Sy>PVCh*&EsRlp@D97U}VT$ov}(X}p; zlL_9OE~E|1uWF{A&nX3*9@VETG^%Qb#?j4=c!;I`#@7rz-w4q)IpWwHCo}P_J9f-B z!)XPe$O04wWfZU9*I7l4#2j(5?suXpSv|Ce%s@`)0-2Xsx=rG!&Iz)h2>^B+ZAR#h zmjGlg?sAfBF(dA}6Sq52_iA<@$bt+3ka!sd;4mi%$JqLbx?xBK5TTo9_HW-uJ<0WW z<$X#W(eXrD zGmXR>(Klbam^d$;lN0Y6W>GQ6ltM9?(KRKRHxrW%2aHHzniNnqWip8g$bg>RSRrZ# zehu{83-ryLsHWm}g>TL`iz;Hn{E4{}3x`+5P+1Ip)%L*OG|N_5))TBW?OO1c zo1R^~Ty6@_o_rws=LX1xzr7OdScsK_`>(TA0epdvk5_!n^RckKL#skZZ z2TBK{CF$*Q3l8T{oStZh$XnR?bd@C}bJDhoB#*eG3~?WGF3b(*ifs0KG15p(;pKD^Yc%(+_RAvg(wg&cJH2bRoo5p;+%MMaJ40}+!9$j^YiohvH#TM%Z~2xI{F z5f%1Wt*UMlG*UG6j1j4Gul9(zoXOg&!5JpDP)gTKn~fIA=p;g~dIxYX%~|^juVnM7 zVkUhY6W9T*WgH@^$}tRcA6~u>?dzzO2`%(gftbZ}52;x{=q90P6^OZw9#Lbb{1=j2 BlrsPT literal 0 HcmV?d00001 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