Add moderation system
This commit is contained in:
+66
-1
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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==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
@@ -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