Files
Solo_bot/api/v1/routes/management.py
T

376 lines
13 KiB
Python

import asyncio
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import distinct, exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token, verify_admin_token_short
from config import API_TOKEN, BOT_SERVICE
from core.bootstrap import MANAGEMENT_CONFIG
from core.executor import run_io
from core.settings.management_config import update_management_config
from database import async_session_maker
from database.models import Key, ScheduledBroadcast, Server, User
from database.scheduled_broadcasts import (
cancel_scheduled_broadcast,
create_scheduled_broadcast,
get_scheduled_broadcast,
list_scheduled_broadcasts,
mark_scheduled_broadcast_failed,
mark_scheduled_broadcast_sent,
start_scheduled_broadcast,
update_scheduled_broadcast,
)
from handlers.admin.sender.scheduled_service import (
ensure_utc_datetime,
execute_broadcast_payload,
execute_scheduled_broadcast,
prepare_broadcast_payload,
scheduled_broadcast_to_dict,
)
from logger import logger
from utils.backup import backup_database
router = APIRouter()
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
class BroadcastLaunchPayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
channel: Literal["bot", "site", "both"] = "both"
text: str
photo: str | None = None
cluster_name: str | None = None
workers: int = 5
messages_per_second: int = 35
class ScheduledBroadcastCreatePayload(BroadcastLaunchPayload):
scheduled_for: datetime
class ScheduledBroadcastUpdatePayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] | None = None
channel: Literal["bot", "site", "both"] | None = None
text: str | None = None
photo: str | None = None
cluster_name: str | None = None
workers: int | None = None
messages_per_second: int | None = None
scheduled_for: datetime | None = None
_broadcast_bot: Bot | None = None
def _get_broadcast_bot() -> Bot:
global _broadcast_bot
if _broadcast_bot is None:
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
return _broadcast_bot
def _require_future_schedule(value: datetime) -> datetime:
scheduled_for = ensure_utc_datetime(value)
if scheduled_for <= datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="scheduled_for must be in the future")
return scheduled_for
def _resolve_update_payload(
payload: ScheduledBroadcastUpdatePayload,
current: ScheduledBroadcast,
) -> dict:
fields = payload.model_fields_set
text_changed = "text" in fields
send_to = payload.send_to if "send_to" in fields else current.send_to
channel = payload.channel if "channel" in fields else current.channel
text = payload.text if "text" in fields else current.text
photo = payload.photo if "photo" in fields else current.photo
cluster_name = payload.cluster_name if "cluster_name" in fields else current.cluster_name
workers = payload.workers if "workers" in fields else current.workers
messages_per_second = (
payload.messages_per_second if "messages_per_second" in fields else current.messages_per_second
)
prepared = prepare_broadcast_payload(
send_to=send_to,
text=text,
photo=photo,
cluster_name=cluster_name,
workers=workers,
messages_per_second=messages_per_second,
channel=channel,
)
if not text_changed:
prepared["text"] = current.text
prepared["keyboard_json"] = current.keyboard_json
if "scheduled_for" in fields:
prepared["scheduled_for"] = _require_future_schedule(payload.scheduled_for)
return prepared
async def _restart_bot() -> None:
await asyncio.sleep(1)
try:
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
await run_io(lambda: subprocess.run(["sudo", "systemctl", "restart", BOT_SERVICE], check=True))
else:
python_exe = sys.executable
script_path = os.path.abspath(sys.argv[0])
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
except Exception:
os._exit(1)
@router.get("/status")
async def get_status(admin=Depends(verify_admin_token)):
return {
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
"management": dict(MANAGEMENT_CONFIG or {}),
}
@router.post("/maintenance")
async def set_maintenance(
payload: MaintenanceUpdate,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
current_config = dict(MANAGEMENT_CONFIG or {})
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
await update_management_config(session, current_config)
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
@router.post("/restart")
async def restart_bot(
background: BackgroundTasks,
admin=Depends(verify_admin_token),
):
background.add_task(_restart_bot)
return {"status": "restarting"}
@router.post("/change-domain")
async def change_domain(
payload: DomainChange,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
new_domain_url = f"https://{domain}"
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
result = await session.execute(stmt)
return {"updated": result.rowcount or 0}
@router.post("/restore-trials")
async def restore_trials(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.user_id).where(Key.user_id == User.id)),
)
.values(trial=0)
)
result = await session.execute(stmt)
return {"restored": result.rowcount or 0}
@router.post("/backup")
async def trigger_backup(admin=Depends(verify_admin_token)):
async def _run_backup() -> None:
exception = await backup_database()
if exception:
logger.error(f"[Management] Backup finished with error: {exception}")
asyncio.create_task(_run_backup())
return {"status": "backup_started"}
@router.get("/broadcast/clusters")
async def get_broadcast_clusters(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
clusters = sorted([row[0] for row in result.all() if row and row[0]])
return {"clusters": clusters}
@router.post("/broadcast")
async def launch_broadcast(
payload: BroadcastLaunchPayload,
admin=Depends(verify_admin_token_short),
):
"""Запуск рассылки. Сессия БД не держится на время рассылки."""
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
text=payload.text,
photo=payload.photo,
cluster_name=payload.cluster_name,
workers=payload.workers,
messages_per_second=payload.messages_per_second,
channel=payload.channel,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return await execute_broadcast_payload(prepared, bot=_get_broadcast_bot())
@router.post("/broadcast/scheduled")
async def create_broadcast_schedule(
payload: ScheduledBroadcastCreatePayload,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
text=payload.text,
photo=payload.photo,
cluster_name=payload.cluster_name,
workers=payload.workers,
messages_per_second=payload.messages_per_second,
channel=payload.channel,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
broadcast = await create_scheduled_broadcast(
session,
created_by_tg_id=getattr(admin, "tg_id", None),
send_to=prepared["send_to"],
channel=prepared["channel"],
cluster_name=prepared["cluster_name"],
text=prepared["text"],
photo=prepared["photo"],
keyboard_json=prepared["keyboard_json"],
scheduled_for=_require_future_schedule(payload.scheduled_for),
workers=prepared["workers"],
messages_per_second=prepared["messages_per_second"],
)
return {"success": True, "item": scheduled_broadcast_to_dict(broadcast)}
@router.get("/broadcast/scheduled")
async def list_broadcast_schedules(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
status: str | None = Query(None),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
statuses = [item.strip() for item in (status or "").split(",") if item.strip()] or None
items = await list_scheduled_broadcasts(session, statuses=statuses, limit=limit, offset=offset)
return {"items": [scheduled_broadcast_to_dict(item) for item in items], "limit": limit, "offset": offset}
@router.get("/broadcast/scheduled/{broadcast_id}")
async def get_broadcast_schedule(
broadcast_id: str,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
item = await get_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
return {"item": scheduled_broadcast_to_dict(item)}
@router.patch("/broadcast/scheduled/{broadcast_id}")
async def update_broadcast_schedule(
broadcast_id: str,
payload: ScheduledBroadcastUpdatePayload,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
current = await get_scheduled_broadcast(session, broadcast_id)
if current is None:
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
try:
values = _resolve_update_payload(payload, current)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
updated = await update_scheduled_broadcast(session, broadcast_id, **values)
if updated is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be edited")
return {"success": True, "item": scheduled_broadcast_to_dict(updated)}
@router.post("/broadcast/scheduled/{broadcast_id}/cancel")
async def cancel_broadcast_schedule(
broadcast_id: str,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
item = await cancel_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be cancelled")
return {"success": True, "item": scheduled_broadcast_to_dict(item)}
@router.post("/broadcast/scheduled/{broadcast_id}/send-now")
async def send_broadcast_schedule_now(
broadcast_id: str,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
item = await start_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
try:
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
except Exception as exc:
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
raise HTTPException(status_code=500, detail=str(exc)) from exc
if result.get("success"):
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
else:
item = await mark_scheduled_broadcast_failed(session, broadcast_id, result.get("message", "Broadcast failed"))
return {"success": bool(result.get("success")), "item": scheduled_broadcast_to_dict(item), "result": result}