mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Add poll sending and ticket API enhancements
This commit is contained in:
@@ -24,6 +24,7 @@ from ..schemas.logs import (
|
||||
SupportAuditLogEntry,
|
||||
SupportAuditLogListResponse,
|
||||
SystemLogPreviewResponse,
|
||||
SystemLogFullResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -141,6 +142,33 @@ async def download_system_log(
|
||||
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
|
||||
|
||||
|
||||
@router.get("/system/full", response_model=SystemLogFullResponse)
|
||||
async def get_system_log_full(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> SystemLogFullResponse:
|
||||
"""Получить полный системный лог-файл бота."""
|
||||
|
||||
log_path = _resolve_system_log_path()
|
||||
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Лог-файл не найден")
|
||||
|
||||
try:
|
||||
content, size_bytes, mtime = await _read_system_log(log_path)
|
||||
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
|
||||
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
|
||||
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
|
||||
|
||||
return SystemLogFullResponse(
|
||||
path=str(log_path),
|
||||
exists=True,
|
||||
updated_at=_format_timestamp(mtime),
|
||||
size_bytes=size_bytes,
|
||||
size_chars=len(content),
|
||||
content=content,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/monitoring", response_model=MonitoringLogListResponse)
|
||||
async def list_monitoring_logs(
|
||||
_: Any = Security(require_api_token),
|
||||
|
||||
@@ -2,6 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
@@ -23,6 +26,8 @@ from app.database.crud.poll import (
|
||||
get_poll_statistics,
|
||||
)
|
||||
from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse
|
||||
from app.handlers.admin.messages import get_custom_users, get_target_users
|
||||
from app.services.poll_service import send_poll_to_users
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.polls import (
|
||||
@@ -38,6 +43,8 @@ from ..schemas.polls import (
|
||||
PollStatisticsResponse,
|
||||
PollSummaryResponse,
|
||||
PollUserResponse,
|
||||
PollSendRequest,
|
||||
PollSendResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -306,3 +313,55 @@ async def get_poll_responses(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{poll_id}/send", response_model=PollSendResponse)
|
||||
async def send_poll(
|
||||
poll_id: int,
|
||||
payload: PollSendRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PollSendResponse:
|
||||
poll = await get_poll_by_id(db, poll_id)
|
||||
if not poll:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
|
||||
|
||||
target = payload.target.strip()
|
||||
if not target:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Target must not be empty")
|
||||
|
||||
if target.startswith("custom_"):
|
||||
users = await get_custom_users(db, target.replace("custom_", ""))
|
||||
else:
|
||||
users = await get_target_users(db, target)
|
||||
|
||||
if not users:
|
||||
return PollSendResponse(
|
||||
poll_id=poll_id,
|
||||
target=target,
|
||||
sent=0,
|
||||
failed=0,
|
||||
skipped=0,
|
||||
total=0,
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
try:
|
||||
result = await send_poll_to_users(bot, db, poll, users)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
return PollSendResponse(
|
||||
poll_id=poll_id,
|
||||
target=target,
|
||||
sent=result.get("sent", 0),
|
||||
failed=result.get("failed", 0),
|
||||
skipped=result.get("skipped", 0),
|
||||
total=result.get("total", 0),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,12 @@ from typing import Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.ticket import TicketCRUD
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
@@ -14,7 +19,10 @@ from ..schemas.tickets import (
|
||||
TicketMessageResponse,
|
||||
TicketPriorityUpdateRequest,
|
||||
TicketReplyBlockRequest,
|
||||
TicketReplyRequest,
|
||||
TicketReplyResponse,
|
||||
TicketResponse,
|
||||
TicketMediaResponse,
|
||||
TicketStatusUpdateRequest,
|
||||
)
|
||||
|
||||
@@ -29,6 +37,7 @@ def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=message.has_media,
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
@@ -183,3 +192,80 @@ async def clear_reply_block(
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/reply", response_model=TicketReplyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def reply_to_ticket(
|
||||
ticket_id: int,
|
||||
payload: TicketReplyRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketReplyResponse:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
|
||||
if not ticket:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
message_text = (payload.message_text or "").strip()
|
||||
if not message_text and not payload.media_file_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Message text or media is required")
|
||||
|
||||
final_message_text = message_text or (payload.media_caption or "").strip() or "[media]"
|
||||
|
||||
message = await TicketMessageCRUD.add_message(
|
||||
db,
|
||||
ticket_id=ticket_id,
|
||||
user_id=ticket.user_id,
|
||||
message_text=final_message_text,
|
||||
is_from_admin=True,
|
||||
media_type=payload.media_type,
|
||||
media_file_id=payload.media_file_id,
|
||||
media_caption=payload.media_caption,
|
||||
)
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
try:
|
||||
from app.handlers.admin.tickets import notify_user_about_ticket_reply
|
||||
|
||||
await notify_user_about_ticket_reply(bot, ticket, final_message_text, db)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
ticket_with_messages = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
|
||||
return TicketReplyResponse(
|
||||
ticket=_serialize_ticket(ticket_with_messages, include_messages=True),
|
||||
message=_serialize_message(message),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticket_id}/messages/{message_id}/media",
|
||||
response_model=TicketMediaResponse,
|
||||
)
|
||||
async def get_ticket_message_media(
|
||||
ticket_id: int,
|
||||
message_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketMediaResponse:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
if not ticket:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
message = next((m for m in ticket.messages if m.id == message_id), None)
|
||||
if not message:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Message not found")
|
||||
|
||||
if not message.has_media or not message.media_file_id or not message.media_type:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Media not found for this message")
|
||||
|
||||
return TicketMediaResponse(
|
||||
id=message.id,
|
||||
ticket_id=ticket.id,
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
)
|
||||
|
||||
@@ -88,3 +88,14 @@ class SystemLogPreviewResponse(BaseModel):
|
||||
default=None,
|
||||
description="Относительный путь до endpoint для скачивания лог-файла",
|
||||
)
|
||||
|
||||
|
||||
class SystemLogFullResponse(BaseModel):
|
||||
"""Полное содержимое системного лог-файла."""
|
||||
|
||||
path: str
|
||||
exists: bool
|
||||
updated_at: Optional[datetime] = None
|
||||
size_bytes: int
|
||||
size_chars: int
|
||||
content: str
|
||||
|
||||
@@ -169,3 +169,23 @@ class PollResponsesListResponse(BaseModel):
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PollSendRequest(BaseModel):
|
||||
target: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Аудитория для отправки опроса (например: all, active, trial, "
|
||||
"custom_today и т.д.)"
|
||||
),
|
||||
max_length=100,
|
||||
)
|
||||
|
||||
|
||||
class PollSendResponse(BaseModel):
|
||||
poll_id: int
|
||||
target: str
|
||||
sent: int
|
||||
failed: int
|
||||
skipped: int
|
||||
total: int
|
||||
|
||||
@@ -13,6 +13,7 @@ class TicketMessageResponse(BaseModel):
|
||||
is_from_admin: bool
|
||||
has_media: bool
|
||||
media_type: Optional[str] = None
|
||||
media_file_id: Optional[str] = None
|
||||
media_caption: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -42,3 +43,27 @@ class TicketPriorityUpdateRequest(BaseModel):
|
||||
class TicketReplyBlockRequest(BaseModel):
|
||||
permanent: bool = False
|
||||
until: Optional[datetime] = None
|
||||
|
||||
|
||||
class TicketReplyRequest(BaseModel):
|
||||
message_text: Optional[str] = Field(default=None, max_length=4000)
|
||||
media_type: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Тип медиа (photo, video, document, voice и т.д.)",
|
||||
max_length=32,
|
||||
)
|
||||
media_file_id: Optional[str] = Field(default=None, max_length=255)
|
||||
media_caption: Optional[str] = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class TicketReplyResponse(BaseModel):
|
||||
ticket: TicketResponse
|
||||
message: TicketMessageResponse
|
||||
|
||||
|
||||
class TicketMediaResponse(BaseModel):
|
||||
id: int
|
||||
ticket_id: int
|
||||
media_type: str
|
||||
media_file_id: str
|
||||
media_caption: Optional[str] = None
|
||||
|
||||
Reference in New Issue
Block a user