96de2d2573
Manage Telegram instances through Kubernetes with legacy adoption for forust and anna. Build and deploy the panel image alongside the runtime.
199 lines
6.1 KiB
Python
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")
|