Files
remnawave-bedolaga-telegram…/app/webapi/routes/pinned_messages.py
2025-12-22 15:01:26 +03:00

378 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PinnedMessage
from app.services.pinned_message_service import (
broadcast_pinned_message,
deactivate_active_pinned_message,
get_active_pinned_message,
set_active_pinned_message,
unpin_active_pinned_message,
)
from ..dependencies import get_db_session, require_api_token
from ..schemas.pinned_messages import (
PinnedMessageBroadcastResponse,
PinnedMessageCreateRequest,
PinnedMessageListResponse,
PinnedMessageResponse,
PinnedMessageSettingsRequest,
PinnedMessageUnpinResponse,
PinnedMessageUpdateRequest,
)
router = APIRouter()
def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse:
return PinnedMessageResponse(
id=msg.id,
content=msg.content,
media_type=msg.media_type,
media_file_id=msg.media_file_id,
send_before_menu=msg.send_before_menu,
send_on_every_start=msg.send_on_every_start,
is_active=msg.is_active,
created_by=msg.created_by,
created_at=msg.created_at,
updated_at=msg.updated_at,
)
def _get_bot() -> Bot:
"""Создать экземпляр бота для API операций."""
return Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
@router.get("", response_model=PinnedMessageListResponse)
async def list_pinned_messages(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
active_only: bool = Query(False),
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PinnedMessageListResponse:
"""Получить список всех закреплённых сообщений."""
query = select(PinnedMessage).order_by(PinnedMessage.created_at.desc())
count_query = select(func.count(PinnedMessage.id))
if active_only:
query = query.where(PinnedMessage.is_active.is_(True))
count_query = count_query.where(PinnedMessage.is_active.is_(True))
total = await db.scalar(count_query) or 0
result = await db.execute(query.offset(offset).limit(limit))
items = result.scalars().all()
return PinnedMessageListResponse(
items=[_serialize_pinned_message(msg) for msg in items],
total=total,
limit=limit,
offset=offset,
)
@router.get("/active", response_model=Optional[PinnedMessageResponse])
async def get_active_message(
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Optional[PinnedMessageResponse]:
"""Получить текущее активное закреплённое сообщение."""
msg = await get_active_pinned_message(db)
if not msg:
return None
return _serialize_pinned_message(msg)
@router.get("/{message_id}", response_model=PinnedMessageResponse)
async def get_pinned_message(
message_id: int,
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PinnedMessageResponse:
"""Получить закреплённое сообщение по ID."""
result = await db.execute(
select(PinnedMessage).where(PinnedMessage.id == message_id)
)
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found")
return _serialize_pinned_message(msg)
@router.post("", response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_pinned_message(
payload: PinnedMessageCreateRequest,
broadcast: bool = Query(False, description="Разослать сообщение всем пользователям (по умолчанию False — только при /start)"),
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PinnedMessageBroadcastResponse:
"""
Создать новое закреплённое сообщение.
Автоматически деактивирует предыдущее активное сообщение.
- broadcast=False (по умолчанию): пользователи увидят при следующем /start
- broadcast=True: рассылает сообщение всем активным пользователям сразу
"""
content = payload.content.strip()
if not content and not payload.media:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Either content or media must be provided"
)
media_type = payload.media.type if payload.media else None
media_file_id = payload.media.file_id if payload.media else None
try:
msg = await set_active_pinned_message(
db=db,
content=content,
created_by=None,
media_type=media_type,
media_file_id=media_file_id,
send_before_menu=payload.send_before_menu,
send_on_every_start=payload.send_on_every_start,
)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
sent_count = 0
failed_count = 0
if broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.patch("/{message_id}", response_model=PinnedMessageResponse)
async def update_pinned_message(
message_id: int,
payload: PinnedMessageUpdateRequest,
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PinnedMessageResponse:
"""
Обновить закреплённое сообщение.
Можно обновить контент, медиа и настройки показа.
Не делает рассылку — для рассылки используйте POST /pinned-messages/{id}/broadcast.
"""
result = await db.execute(
select(PinnedMessage).where(PinnedMessage.id == message_id)
)
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found")
if payload.content is not None:
from app.utils.validators import sanitize_html, validate_html_tags
sanitized = sanitize_html(payload.content)
is_valid, error = validate_html_tags(sanitized)
if not is_valid:
raise HTTPException(status.HTTP_400_BAD_REQUEST, error)
msg.content = sanitized
if payload.media is not None:
if payload.media.type not in ("photo", "video"):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Only photo or video media types are supported"
)
msg.media_type = payload.media.type
msg.media_file_id = payload.media.file_id
if payload.send_before_menu is not None:
msg.send_before_menu = payload.send_before_menu
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
return _serialize_pinned_message(msg)
@router.patch("/{message_id}/settings", response_model=PinnedMessageResponse)
async def update_pinned_message_settings(
message_id: int,
payload: PinnedMessageSettingsRequest,
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PinnedMessageResponse:
"""
Обновить только настройки закреплённого сообщения.
- send_before_menu: показывать до или после меню
- send_on_every_start: показывать при каждом /start или только один раз
"""
result = await db.execute(
select(PinnedMessage).where(PinnedMessage.id == message_id)
)
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found")
if payload.send_before_menu is not None:
msg.send_before_menu = payload.send_before_menu
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
return _serialize_pinned_message(msg)
@router.post("/{message_id}/activate", response_model=PinnedMessageBroadcastResponse)
async def activate_pinned_message(
message_id: int,
broadcast: bool = Query(False, description="Разослать сообщение всем пользователям (по умолчанию False — только при /start)"),
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PinnedMessageBroadcastResponse:
"""
Активировать закреплённое сообщение.
Деактивирует текущее активное сообщение и активирует указанное.
- broadcast=False (по умолчанию): пользователи увидят при следующем /start
- broadcast=True: рассылает сообщение всем активным пользователям сразу
"""
result = await db.execute(
select(PinnedMessage).where(PinnedMessage.id == message_id)
)
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found")
# Деактивируем все активные
await db.execute(
update(PinnedMessage)
.where(PinnedMessage.is_active.is_(True))
.values(is_active=False, updated_at=datetime.utcnow())
)
# Активируем указанное
msg.is_active = True
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
sent_count = 0
failed_count = 0
if broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.post("/{message_id}/broadcast", response_model=PinnedMessageBroadcastResponse)
async def broadcast_message(
message_id: int,
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PinnedMessageBroadcastResponse:
"""
Разослать закреплённое сообщение всем активным пользователям.
Работает для любого сообщения, независимо от его статуса активности.
"""
result = await db.execute(
select(PinnedMessage).where(PinnedMessage.id == message_id)
)
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found")
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.post("/active/deactivate", response_model=Optional[PinnedMessageResponse])
async def deactivate_active_message(
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Optional[PinnedMessageResponse]:
"""
Деактивировать текущее активное закреплённое сообщение.
Не удаляет сообщение и не открепляет у пользователей.
"""
msg = await deactivate_active_pinned_message(db)
if not msg:
return None
return _serialize_pinned_message(msg)
@router.post("/active/unpin", response_model=PinnedMessageUnpinResponse)
async def unpin_active_message(
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PinnedMessageUnpinResponse:
"""
Открепить сообщение у всех пользователей и деактивировать.
Удаляет закреплённое сообщение из чатов всех активных пользователей.
"""
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
return PinnedMessageUnpinResponse(
unpinned_count=unpinned_count,
failed_count=failed_count,
was_active=was_active,
)
@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
async def delete_pinned_message(
message_id: int,
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> None:
"""
Удалить закреплённое сообщение.
Если сообщение активно, сначала будет деактивировано.
Не открепляет сообщение у пользователей — для этого используйте /active/unpin.
"""
result = await db.execute(
select(PinnedMessage).where(PinnedMessage.id == message_id)
)
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found")
await db.delete(msg)
await db.commit()