Add moderation system
This commit is contained in:
+66
-1
@@ -1,17 +1,24 @@
|
||||
import os
|
||||
import secrets
|
||||
import logging
|
||||
|
||||
import uvicorn
|
||||
import asyncio
|
||||
|
||||
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.base_schemas import Card
|
||||
from mongo_worker import MongoWorker
|
||||
from rabbit_worker import RabbitWorker
|
||||
from tools.base_moderation import moderate_text
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
load_dotenv()
|
||||
disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true"
|
||||
|
||||
@@ -24,7 +31,34 @@ app: FastAPI = FastAPI(
|
||||
redoc_url=None if disable_docs else "/redoc",
|
||||
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()
|
||||
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")
|
||||
@@ -111,11 +145,42 @@ async def add_card(
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
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)
|
||||
|
||||
# Отправляем карточку в 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)
|
||||
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||
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)
|
||||
async def select_choice(
|
||||
choice_data: SelectChoice,
|
||||
|
||||
@@ -141,6 +141,27 @@ class MongoWorker:
|
||||
logger.error("Failed to insert card: %s", exc)
|
||||
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:
|
||||
if choice == "A":
|
||||
count_field = "count_choice_A"
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,8 @@
|
||||
fastapi==0.115.7
|
||||
fastapi_guard==7.6.0
|
||||
motor==3.7.0
|
||||
python-dotenv==1.0.1
|
||||
uvicorn==0.34.0
|
||||
aio-pika==10.0.1
|
||||
aiogram==3.18.0
|
||||
aiohttp==3.11.18
|
||||
+178
@@ -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)
|
||||
Reference in New Issue
Block a user