Files
userbot/panel/backend/app/main.py
T
forust 96de2d2573 feat(userbot): add Kubernetes control panel
Manage Telegram instances through Kubernetes with legacy adoption for forust and anna. Build and deploy the panel image alongside the runtime.
2026-07-26 19:39:09 +02:00

199 lines
6.1 KiB
Python

from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI, Query, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from .auth_service import TelegramAuthService
from .config import settings
from .errors import PanelError
from .kubernetes_service import KubernetesService
from .models import (
AuthResult,
CodeSubmit,
DeleteRequest,
InstanceSummary,
PasswordSubmit,
PhoneStart,
StringSessionStart,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.kubernetes = KubernetesService(settings)
app.state.telegram = TelegramAuthService(settings.auth_ttl_seconds)
yield
await app.state.telegram.close()
app = FastAPI(
title="Userbot Kubernetes Control",
version="1.0.0",
lifespan=lifespan,
)
@app.exception_handler(PanelError)
async def panel_error_handler(_request: Request, exc: PanelError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
_request: Request,
exc: RequestValidationError,
) -> JSONResponse:
errors = [
{
"loc": error.get("loc", ()),
"msg": error.get("msg", "Invalid value"),
"type": error.get("type", "value_error"),
}
for error in exc.errors()
]
return JSONResponse(status_code=422, content={"detail": errors})
def kube(request: Request) -> KubernetesService:
return request.app.state.kubernetes
def telegram(request: Request) -> TelegramAuthService:
return request.app.state.telegram
@app.get("/api/health")
def health(request: Request) -> dict[str, object]:
service = kube(request)
try:
service.apps.list_namespaced_deployment(
settings.namespace,
limit=1,
)
except Exception:
return {"ok": False, "kubernetes": False}
return {"ok": True, "kubernetes": True}
@app.get("/api/instances", response_model=list[InstanceSummary])
def list_instances(
request: Request,
query: str = "",
status: str = "",
) -> list[InstanceSummary]:
return kube(request).list_instances(query=query, status=status)
@app.get("/api/instances/{instance_id}", response_model=InstanceSummary)
def get_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).get_instance(instance_id)
@app.get("/api/instances/{instance_id}/logs")
def get_logs(
instance_id: str,
request: Request,
tail: Annotated[int, Query(ge=1, le=1000)] = 250,
) -> dict[str, str]:
return {"logs": kube(request).logs(instance_id, tail)}
@app.post("/api/instances/{instance_id}/start", response_model=InstanceSummary)
def start_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).scale(instance_id, 1)
@app.post("/api/instances/{instance_id}/stop", response_model=InstanceSummary)
def stop_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).scale(instance_id, 0)
@app.post("/api/instances/{instance_id}/restart", response_model=InstanceSummary)
def restart_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).restart(instance_id)
@app.post("/api/instances/{instance_id}/delete", status_code=204)
def delete_instance(
instance_id: str,
payload: DeleteRequest,
request: Request,
) -> Response:
if payload.confirmation != instance_id:
raise PanelError(422, "Type the instance id exactly to confirm deletion")
kube(request).delete(instance_id, delete_data=payload.delete_data)
return Response(status_code=204)
@app.post("/api/auth/phone/start", response_model=AuthResult)
async def auth_phone_start(
payload: PhoneStart,
request: Request,
) -> AuthResult:
service = kube(request)
service.ensure_prerequisites()
service.assert_available(payload.instance_id)
flow_id = await telegram(request).start_phone(payload)
return AuthResult(status="code_required", flow_id=flow_id)
@app.post("/api/auth/phone/{flow_id}/code", response_model=AuthResult)
async def auth_phone_code(
flow_id: str,
payload: CodeSubmit,
request: Request,
) -> AuthResult:
authorized = await telegram(request).submit_code(flow_id, payload.code)
if authorized is None:
return AuthResult(status="password_required", flow_id=flow_id)
instance = kube(request).provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
@app.post("/api/auth/phone/{flow_id}/password", response_model=AuthResult)
async def auth_phone_password(
flow_id: str,
payload: PasswordSubmit,
request: Request,
) -> AuthResult:
authorized = await telegram(request).submit_password(flow_id, payload.password)
instance = kube(request).provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
@app.post("/api/auth/string-session", response_model=AuthResult)
async def auth_string_session(
payload: StringSessionStart,
request: Request,
) -> AuthResult:
service = kube(request)
service.ensure_prerequisites()
service.assert_available(payload.instance_id)
authorized = await telegram(request).validate_string_session(payload)
instance = service.provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
static_dir = settings.static_dir
assets_dir = static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/", include_in_schema=False)
def index() -> FileResponse:
return FileResponse(static_dir / "index.html")
@app.get("/{path:path}", include_in_schema=False)
def spa_fallback(path: str) -> FileResponse:
candidate = (static_dir / path).resolve()
if candidate.is_file() and static_dir.resolve() in candidate.parents:
return FileResponse(candidate)
return FileResponse(static_dir / "index.html")