From 31602e96d0e55667571927905ed546588ffc9405 Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Sat, 4 Apr 2026 18:36:49 +0300 Subject: [PATCH] Initial commit --- .gitea/workflows/deploy.yml | 52 ++++++++++++++ .gitignore | 1 + Dockerfile | 6 ++ main.py | 132 ++++++++++++++++++++++++++++++++++++ requirements.txt | 5 ++ 5 files changed, 196 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..3278cff --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,52 @@ +name: Build and Run VSTU TG Poster + +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_tg_poster:latest . + + # Шаг 3: Перезапуск контейнера на хост-машине + # Это сердце упрощенного workflow + - name: Restart the container + run: | + # 1. Останавливаем и удаляем старый контейнер, если он существует. + # `docker ps -q -f name=...` вернет ID контейнера, если он запущен. + # Конструкция `[ $(...) ] && ...` проверит, не пустой ли вывод. + if [ "$(docker ps -q -f name=vstu_tg_poster)" ]; then + echo "Stopping and removing existing container..." + docker stop vstu_tg_poster + docker rm vstu_tg_poster + else + echo "No running container found. Skipping stop/remove." + fi + + # 2. Запускаем новый контейнер из только что собранного локального образа. + # Команда точно такая же, как твоя. + echo "Starting new container..." + docker run -d \ + --network cl2so4 \ + --ip 172.20.0.62 \ + -v /home/holder/fclay/secrets/vstu_tg_poster.env:/app/.env \ + --restart=always \ + --name=vstu_tg_poster \ + vstu_tg_poster: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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..604a7d8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["python", "-u", "main.py"] \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..9ca3d3d --- /dev/null +++ b/main.py @@ -0,0 +1,132 @@ +import os +import json +import asyncio +import logging +from datetime import datetime, timezone + +import aio_pika +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiohttp_socks import ProxyConnector +from dotenv import load_dotenv + +load_dotenv() + +# --- КОНФИГУРАЦИЯ --- +RABBITMQ_URL = os.getenv("RABBITMQ_URL") +EXCHANGE_NAME = os.getenv("EXCHANGE_NAME", "vstu_schedule") +TG_TOKEN = os.getenv("TG_TOKEN") +TG_CHANNEL_ID = os.getenv("TG_CHANNEL_ID") +PROXY_URL = os.getenv("PROXY_URL") + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger("TGPublisher") + +# Глобальный объект бота +bot = None + +async def init_bot(): + """Инициализация бота с поддержкой прокси""" + global bot + connector = ProxyConnector.from_url(PROXY_URL) + # Используем сессию с прокси коннектором + bot = Bot( + token=TG_TOKEN, + connector=connector, + default=DefaultBotProperties(parse_mode=ParseMode.HTML) # Используем HTML для надежности + ) + logger.info(f"[*] Бот инициализирован через прокси {PROXY_URL.split('@')[-1] if '@' in PROXY_URL else 'local'}") + +async def send_to_tg(text: str) -> bool: + """Отправляет сообщение в канал. Возвращает True при успехе.""" + try: + await bot.send_message(chat_id=TG_CHANNEL_ID, text=text) + logger.info("[+] Сообщение опубликовано в Telegram.") + return True + except Exception as e: + logger.error(f"[!] Ошибка Telegram API: {e}") + return False + +async def process_message(message: aio_pika.IncomingMessage): + """Обработка события от SLS с гарантией доставки (Manual Ack)""" + try: + # Мы НЕ используем context manager 'async with message.process()', + # так как нам нужен ручной ack только после успешного TG-поста + + payload = json.loads(message.body.decode()) + shm = payload.get("humanlike_message", "") + + if not shm: + logger.warning("Получено пустое сообщение, скип.") + await message.ack() + return + + # Формируем текст + facultet = payload.get("facultet", "news").upper() + # Экранируем HTML символы на всякий случай + shm_safe = shm.replace("<", "<").replace(">", ">") + + tg_text = f"#{facultet}\n\n{shm_safe}" + + ai = payload.get("ai_summary") + if ai and isinstance(ai, dict) and 'wide_review' in ai: + tg_text += f"\n\n🤖 AI Резюме:\n{ai['wide_review']}" + + tg_text += "\n\n🔗 fazziclay.com" + + # Пытаемся отправить + success = await send_to_tg(tg_text) + + if success: + await message.ack() # Удаляем из RabbitMQ только после успеха в TG + else: + logger.warning("Повтор попытки через 15 секунд...") + await asyncio.sleep(15) + await message.nack(requeue=True) # Возвращаем в очередь + + except json.JSONDecodeError: + logger.error("Критическая ошибка: невалидный JSON. Удаление.") + await message.ack() + except Exception as e: + logger.error(f"Непредвиденная ошибка воркера: {e}") + await message.nack(requeue=True) + +async def main(): + if not TG_TOKEN or not TG_CHANNEL_ID: + logger.error("Кредиты Telegram (Token/ChannelID) не найдены!") + return + + # Инициализируем бота + await init_bot() + + # Коннект к RabbitMQ + connection = await aio_pika.connect_robust(RABBITMQ_URL) + channel = await connection.channel() + + # Рекомендуется ограничить количество сообщений, обрабатываемых за раз + await channel.set_qos(prefetch_count=1) + + exchange = await channel.declare_exchange(EXCHANGE_NAME, aio_pika.ExchangeType.TOPIC, durable=True) + + # Создаем независимую очередь для Telegram + queue = await channel.declare_queue("tg_publisher_queue", durable=True) + await queue.bind(exchange, routing_key="schedule_logging_service.event.excel.#") + + logger.info(f"[*] TG Publisher готов к работе. Канал: {TG_CHANNEL_ID}") + + await queue.consume(process_message) + + try: + await asyncio.Future() # Вечный цикл + finally: + await connection.close() + # Не забываем закрыть сессию бота + if bot: + await bot.session.close() + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b9d16c1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +aio_pika==9.6.2 +aiohttp-socks +aiohttp +aiogram +python-dotenv \ No newline at end of file