Add moderation system

This commit is contained in:
IgorVolochay
2026-08-21 12:11:03 +03:00
parent 122209bdf4
commit 328a20a7e3
5 changed files with 340 additions and 2 deletions
+66 -1
View File
@@ -1,17 +1,24 @@
import os import os
import secrets
import logging
import uvicorn import uvicorn
import asyncio import asyncio
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI, Depends, Response, status from fastapi import FastAPI, Depends, Response, Header, HTTPException, status
from guard import SecurityMiddleware, SecurityConfig
from schemas.api_schemas import BaseResponse, AddUserBody, AddCardBody, SelectChoice, ReactionCard, AddCommentBody from schemas.api_schemas import BaseResponse, AddUserBody, AddCardBody, SelectChoice, ReactionCard, AddCommentBody
from schemas.base_schemas import Card from schemas.base_schemas import Card
from mongo_worker import MongoWorker from mongo_worker import MongoWorker
from rabbit_worker import RabbitWorker
from tools.base_moderation import moderate_text from tools.base_moderation import moderate_text
logger = logging.getLogger(__name__)
load_dotenv() load_dotenv()
disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true"
@@ -24,7 +31,34 @@ app: FastAPI = FastAPI(
redoc_url=None if disable_docs else "/redoc", redoc_url=None if disable_docs else "/redoc",
openapi_url=None if disable_docs else "/openapi.json", openapi_url=None if disable_docs else "/openapi.json",
) )
config = SecurityConfig(
enable_rate_limiting=True,
rate_limit=120,
rate_limit_window=60,
enable_redis=False,
enable_ip_banning=True,
auto_ban_threshold=3,
auto_ban_duration=3600,
custom_log_file="security.log",
)
app.add_middleware(SecurityMiddleware, config=config)
mongo_worker = MongoWorker() mongo_worker = MongoWorker()
rabbit_worker = RabbitWorker()
MODERATION_SECRET = os.getenv("MODERATION_SECRET", "change-me-in-production")
async def verify_moderation_secret(
x_moderation_secret: str = Header(..., alias="X-Moderation-Secret"),
) -> str:
"""Проверяет секретный ключ модерации в заголовке запроса."""
if not secrets.compare_digest(x_moderation_secret, MODERATION_SECRET):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid moderation secret",
)
return x_moderation_secret
@app.on_event("startup") @app.on_event("startup")
@@ -111,11 +145,42 @@ async def add_card(
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B): if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B):
card = await mongo.add_card_by_api(new_card.choice_A, new_card.choice_B, new_card.author_id) card = await mongo.add_card_by_api(new_card.choice_A, new_card.choice_B, new_card.author_id)
# Отправляем карточку в RabbitMQ на ручную модерацию админом
try:
await rabbit_worker.send_to_moderation(card)
except Exception as exc:
logger.error("Failed to send card %s to moderation queue: %s", card.card_id, exc)
return BaseResponse(result=card) return BaseResponse(result=card)
response.status_code = status.HTTP_400_BAD_REQUEST response.status_code = status.HTTP_400_BAD_REQUEST
return BaseResponse(result="Card has not passed base moderation", error=True) return BaseResponse(result="Card has not passed base moderation", error=True)
@app.patch("/card_accept", status_code=200, dependencies=[Depends(verify_moderation_secret)])
async def card_accept(
card_id: int,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
"""Принимает карточку — доступ только с секретным ключом."""
result = await mongo.accept_card(card_id)
if result.error:
response.status_code = status.HTTP_404_NOT_FOUND
return result
@app.patch("/card_reject", status_code=200, dependencies=[Depends(verify_moderation_secret)])
async def card_reject(
card_id: int,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
"""Отклоняет карточку — доступ только с секретным ключом."""
result = await mongo.reject_card(card_id)
if result.error:
response.status_code = status.HTTP_404_NOT_FOUND
return result
@app.patch("/select_choice", status_code=200) @app.patch("/select_choice", status_code=200)
async def select_choice( async def select_choice(
choice_data: SelectChoice, choice_data: SelectChoice,
+21
View File
@@ -141,6 +141,27 @@ class MongoWorker:
logger.error("Failed to insert card: %s", exc) logger.error("Failed to insert card: %s", exc)
raise raise
async def accept_card(self, card_id: int) -> BaseResponse:
"""Принимает карточку: ставит active_status=True и moderation_date=сейчас."""
result = await self.game_data.find_one_and_update(
{"card_id": card_id},
{"$set": {
"active_status": True,
"moderation_date": datetime.now().isoformat(),
}},
return_document=ReturnDocument.AFTER,
)
if not result:
return BaseResponse(result="Card doesn't exist", error=True)
return BaseResponse(result=Card.model_validate(result))
async def reject_card(self, card_id: int) -> BaseResponse:
"""Отклоняет карточку: удаляет её из БД."""
result = await self.game_data.delete_one({"card_id": card_id})
if result.deleted_count == 0:
return BaseResponse(result="Card doesn't exist", error=True)
return BaseResponse(result=f"Card {card_id} rejected and deleted")
async def select_choice(self, card_id: int, choice: str) -> BaseResponse: async def select_choice(self, card_id: int, choice: str) -> BaseResponse:
if choice == "A": if choice == "A":
count_field = "count_choice_A" count_field = "count_choice_A"
+70
View File
@@ -0,0 +1,70 @@
import os
import json
import asyncio
import logging
from typing import Callable, Awaitable
import aio_pika
from aio_pika.abc import AbstractIncomingMessage
from dotenv import load_dotenv
from schemas.base_schemas import Card
logger = logging.getLogger(__name__)
class RabbitWorker:
def __init__(self):
load_dotenv()
self.url = (
f"amqp://{os.getenv('RABBIT_USER')}:{os.getenv('RABBIT_PASS')}"
f"@{os.getenv('RABBIT_HOST')}:{os.getenv('RABBIT_PORT')}"
)
async def send_to_moderation(self, card: Card) -> None:
"""Отправляет карточку в очередь модерации."""
connection = await aio_pika.connect_robust(self.url)
async with connection:
channel = await connection.channel()
queue = await channel.declare_queue("moderation", durable=True)
await channel.default_exchange.publish(
aio_pika.Message(
body=card.model_dump_json().encode(),
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
),
routing_key="moderation",
)
logger.info("Card %s sent to moderation queue", card.card_id)
async def consume_moderation(
self,
callback: Callable[[Card], Awaitable[None]],
) -> None:
"""Бесконечно слушает очередь модерации и вызывает callback для каждой карточки."""
connection = await aio_pika.connect_robust(self.url)
async with connection:
channel = await connection.channel()
await channel.set_qos(prefetch_count=1)
queue = await channel.declare_queue("moderation", durable=True)
logger.info("Started consuming moderation queue...")
async def on_message(message: AbstractIncomingMessage) -> None:
async with message.process():
try:
card_data = json.loads(message.body.decode())
card = Card.model_validate(card_data)
await callback(card)
except Exception as exc:
logger.error("Error processing moderation message: %s", exc)
await queue.consume(on_message)
# Держим consumer живым, но позволяем отмену (Ctrl+C)
stop_event = asyncio.Event()
try:
await stop_event.wait()
except asyncio.CancelledError:
logger.info("Moderation consumer shutting down...")
raise
+5 -1
View File
@@ -1,4 +1,8 @@
fastapi==0.115.7 fastapi==0.115.7
fastapi_guard==7.6.0
motor==3.7.0 motor==3.7.0
python-dotenv==1.0.1 python-dotenv==1.0.1
uvicorn==0.34.0 uvicorn==0.34.0
aio-pika==10.0.1
aiogram==3.18.0
aiohttp==3.11.18
+178
View File
@@ -0,0 +1,178 @@
"""
Telegram-бот модерации карточек.
Слушает очередь RabbitMQ «moderation» и отправляет карточки
в чат администратору с inline-кнопками «Принять » / «Отклонить ».
При нажатии кнопки бот вызывает защищённые эндпоинты
/card_accept или /card_reject с секретным заголовком.
"""
import os
import json
import asyncio
import logging
from datetime import datetime
import aiohttp
from dotenv import load_dotenv
from aiogram import Bot, Dispatcher, F
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from schemas.base_schemas import Card
from rabbit_worker import RabbitWorker
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ── Конфигурация ──────────────────────────────────────────────
BOT_TOKEN = os.getenv("TG_BOT_TOKEN")
ADMIN_CHAT_ID = int(os.getenv("TG_ADMIN_CHAT_ID", "0"))
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:5000")
MODERATION_SECRET = os.getenv("MODERATION_SECRET", "change-me-in-production")
bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()
rabbit = RabbitWorker()
# ── Отправка карточки администратору ──────────────────────────
async def _get_author_username(author_id: int) -> str:
"""Запрашивает username автора через API."""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{API_BASE_URL}/get_user", params={"user_id": author_id}
) as resp:
if resp.status == 200:
data = await resp.json()
username = data.get("result", {}).get("username", "")
if username:
return f"@{username}"
except Exception as exc:
logger.warning("Failed to fetch username for %s: %s", author_id, exc)
return str(author_id)
def _format_date(iso_date: str) -> str:
"""Преобразует ISO-дату в формат ДД.ММ.ГГГГ ЧЧ:ММ:СС."""
try:
dt = datetime.fromisoformat(iso_date)
return dt.strftime("%d.%m.%Y %H:%M:%S")
except (ValueError, TypeError):
return iso_date
async def send_card_to_admin(card: Card) -> None:
"""Формирует сообщение и inline-клавиатуру для карточки."""
author_display = await _get_author_username(card.author_id)
date_display = _format_date(card.creation_date)
text = (
f"🆕 <b>Новая карточка #{card.card_id}</b>\n\n"
f"🅰️ {card.choice_A}\n"
f"🅱️ {card.choice_B}\n\n"
f"👤 Автор: {author_display}\n"
f"📅 Создана: {date_display}"
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="Принять ✅",
callback_data=f"accept:{card.card_id}",
),
InlineKeyboardButton(
text="Отклонить ❌",
callback_data=f"reject:{card.card_id}",
),
]
]
)
await bot.send_message(
chat_id=ADMIN_CHAT_ID,
text=text,
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info("Sent card %s to admin chat", card.card_id)
# ── Вызов защищённых эндпоинтов API ──────────────────────────
async def call_moderation_api(action: str, card_id: int) -> dict:
"""
Вызывает /card_accept или /card_reject с секретным заголовком.
action: 'accept' | 'reject'
"""
endpoint = f"{API_BASE_URL}/card_{action}"
headers = {"X-Moderation-Secret": MODERATION_SECRET}
params = {"card_id": card_id}
async with aiohttp.ClientSession() as session:
async with session.patch(endpoint, headers=headers, params=params) as resp:
data = await resp.json()
return data
# ── Обработчики callback-кнопок ───────────────────────────────
@dp.callback_query(F.data.startswith("accept:"))
async def on_accept(callback: CallbackQuery) -> None:
card_id = int(callback.data.split(":")[1])
result = await call_moderation_api("accept", card_id)
if result.get("error"):
await callback.answer(f"Ошибка: {result['result']}", show_alert=True)
return
await callback.message.edit_text(
callback.message.text + "\n\n✅ <b>ПРИНЯТА</b>",
parse_mode="HTML",
)
await callback.answer("Карточка принята!")
logger.info("Card %s accepted by admin", card_id)
@dp.callback_query(F.data.startswith("reject:"))
async def on_reject(callback: CallbackQuery) -> None:
card_id = int(callback.data.split(":")[1])
result = await call_moderation_api("reject", card_id)
if result.get("error"):
await callback.answer(f"Ошибка: {result['result']}", show_alert=True)
return
await callback.message.edit_text(
callback.message.text + "\n\n❌ <b>ОТКЛОНЕНА</b>",
parse_mode="HTML",
)
await callback.answer("Карточка отклонена!")
logger.info("Card %s rejected by admin", card_id)
# ── Lifecycle-хуки aiogram ────────────────────────────────────
_rabbit_task: asyncio.Task | None = None
@dp.startup()
async def on_startup() -> None:
global _rabbit_task
_rabbit_task = asyncio.create_task(
rabbit.consume_moderation(send_card_to_admin)
)
logger.info("Moderation bot started, RabbitMQ consumer running")
@dp.shutdown()
async def on_shutdown() -> None:
if _rabbit_task:
_rabbit_task.cancel()
try:
await _rabbit_task
except asyncio.CancelledError:
pass
logger.info("Moderation bot stopped")
if __name__ == "__main__":
dp.run_polling(bot)