mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-04 04:43:21 +00:00
Add files via upload
This commit is contained in:
366
app/webapi/routes/pinned_messages.py
Normal file
366
app/webapi/routes/pinned_messages.py
Normal file
@@ -0,0 +1,366 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot import bot
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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(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(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(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(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)
|
||||
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()
|
||||
Reference in New Issue
Block a user