commit 6b72b9d479c5d035a80bb8ae87245e3f7b964548 Author: FazziCLAY Date: Sat Apr 4 18:18:05 2026 +0300 Initial commit diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..12bef8f --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,52 @@ +name: Build and Run VSTU Vk 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_vk_poster:latest . + + # Шаг 3: Перезапуск контейнера на хост-машине + # Это сердце упрощенного workflow + - name: Restart the container + run: | + # 1. Останавливаем и удаляем старый контейнер, если он существует. + # `docker ps -q -f name=...` вернет ID контейнера, если он запущен. + # Конструкция `[ $(...) ] && ...` проверит, не пустой ли вывод. + if [ "$(docker ps -q -f name=vstu_vk_poster)" ]; then + echo "Stopping and removing existing container..." + docker stop vstu_vk_poster + docker rm vstu_vk_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.61 \ + -v /home/holder/fclay/secrets/vstu_vk_poster.env:/app/.env \ + --restart=always \ + --name=vstu_vk_poster \ + vstu_vk_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..36ca34c --- /dev/null +++ b/main.py @@ -0,0 +1,119 @@ +import os +import json +import asyncio +import logging +import aiohttp +import aio_pika +from dotenv import load_dotenv + +load_dotenv() + +# --- КОНФИГУРАЦИЯ --- +RABBITMQ_URL = os.getenv("RABBITMQ_URL") +EXCHANGE_NAME = os.getenv("OUT_EXCHANGE", "vstu_schedule") +VK_TOKEN = os.getenv("VK_TOKEN") +VK_GROUP_ID = os.getenv("VK_GROUP_ID") # 237378775 + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger("VKPublisher") + +async def post_to_vk(text: str) -> bool: + """Возвращает True при успехе, False при временной ошибке""" + url = "https://api.vk.com/method/wall.post" + params = { + "owner_id": f"-{VK_GROUP_ID}", + "from_group": 1, + "message": text, + "access_token": VK_TOKEN, + "v": "5.131" + } + + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, data=params, timeout=15) as resp: + result = await resp.json() + if "error" in result: + err_code = result['error']['error_code'] + logger.error(f"[!] VK Error {err_code}: {result['error']['error_msg']}") + # Если ошибка в токене или правах (код 5, 15, 100) — ретрай бесполезен + if err_code in [5, 15, 100]: + return True # Условно "успех", чтобы не зацикливать очередь + return False # Временная ошибка (лимиты, сервер) + return True + except Exception as e: + logger.error(f"Network error in post_to_vk: {e}") + return False + +async def process_message(message: aio_pika.IncomingMessage): + """Парсит событие от SLS с гарантией доставки""" + try: + # Мы НЕ используем 'async with message.process()', чтобы контролировать ack/nack вручную + payload = json.loads(message.body.decode()) + + # 1. Подготовка текста (Логика данных) + shm = payload.get("humanlike_message", "") + if not shm: + logger.warning("Пустое сообщение. Удаляем из очереди.") + await message.ack() # Подтверждаем, чтобы не висело, раз оно пустое + return + + facultet = payload.get("facultet", "news").upper() + vk_text = f"#{facultet}@vstu_rasp\n\n{shm}" + + ai = payload.get("ai_summary") + if ai and isinstance(ai, dict) and 'wide_review' in ai: + vk_text += f"\n\n🤖 AI Резюме: {ai['wide_review']}" + + vk_text += "\n\n🔗 fazziclay.com" + + # 2. Попытка публикации (Логика сети) + success = await post_to_vk(vk_text) + + if success: + # ТОЛЬКО ТЕПЕРЬ удаляем из очереди + await message.ack() + logger.info(f"[v] Сообщение успешно обработано и подтверждено.") + else: + # VK API недоступен или лимиты — возвращаем в очередь + logger.warning(f"[!] Ошибка публикации. Возврат сообщения в очередь для повтора...") + await asyncio.sleep(10) # Небольшая пауза, чтобы не спамить ретраями мгновенно + await message.nack(requeue=True) + + except json.JSONDecodeError: + logger.error("Критическая ошибка: битый JSON. Удаление сообщения.") + await message.ack() # Ack, потому что ретрай не поможет распарсить плохой JSON + + except Exception as e: + logger.error(f"Непредвиденная ошибка в воркере: {e}") + # В случае неизвестной ошибки лучше вернуть в очередь (requeue) + await message.nack(requeue=True) + +async def main(): + if not VK_TOKEN: + logger.error("VK_TOKEN не найден в .env!") + return + + connection = await aio_pika.connect_robust(RABBITMQ_URL) + channel = await connection.channel() + + # Объявляем тот же обменник, в который пишет SLS + exchange = await channel.declare_exchange(EXCHANGE_NAME, aio_pika.ExchangeType.TOPIC, durable=True) + + # Создаем свою очередь для VK (чтобы сообщения не пересекались с телеграмом) + queue = await channel.declare_queue("vk_publisher_queue", durable=True) + + # Подписываемся на события изменения файлов + # Маска: schedule_logging_service.event.excel.# + await queue.bind(exchange, routing_key="schedule_logging_service.event.excel.#") + + logger.info(f"[*] VK Publisher запущен. Ожидание событий из {EXCHANGE_NAME}...") + + await queue.consume(process_message) + + try: + await asyncio.Future() # Вечный цикл + finally: + await connection.close() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..69ee830 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aio_pika==9.6.2 +aiohttp +python-dotenv \ No newline at end of file