diff --git a/README.md b/README.md index cdbe132..02cf3d5 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,28 @@ docker build -t telegram-groupfactory . docker run telegram-groupfactory ``` +## REST API + +The userbot also exposes an internal FastAPI surface on `API_HOST:API_PORT` +for the separate conventional Telegram bot. Requests require `X-API-Key`. + +Relevant bot-facing endpoints: + +```text +POST /api/groups +GET /api/admin/default-users +PUT /api/admin/default-users +POST /api/admin/default-users +DELETE /api/admin/default-users +GET /api/admin/qr-backup?qr_group=default +PUT /api/admin/qr-backup +POST /api/admin/qr-backup/image +GET /api/admin/qr-groups +POST /api/admin/qr-groups/{qr_group}/assignments +DELETE /api/admin/qr-groups/assignments +POST /api/admin/qr-sync +``` + ## Documentation - **[ADMIN_IMPLEMENTATION.md](ADMIN_IMPLEMENTATION.md)** - Admin-only configuration features diff --git a/requirements.txt b/requirements.txt index 840fbc4..02807e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ python-dotenv>=0.19.0 fastapi>=0.95.0 uvicorn>=0.21.0 pydantic>=1.10.0 +python-multipart>=0.0.9 numpy>=1.26.0 opencv-python-headless>=4.9.0.80 qrcode[pil]>=7.4.2 diff --git a/src/api/routes/admin.py b/src/api/routes/admin.py index 83daec6..ad067fc 100644 --- a/src/api/routes/admin.py +++ b/src/api/routes/admin.py @@ -1,10 +1,16 @@ +import asyncio +import logging + from fastapi import APIRouter, Depends, Request +from fastapi import File, Form, Query, UploadFile from src.api.auth import verify_api_key from src.api.schemas import ( AddUserRequest, CommandResponse, QrBackupRequest, + QrGroupAssignmentRequest, + QrSyncRequest, UserIdsRequest, ) @@ -14,6 +20,7 @@ def _envelope(message: str) -> CommandResponse: router = APIRouter(prefix="/api/admin", tags=["admin"], dependencies=[Depends(verify_api_key)]) +logger = logging.getLogger(__name__) def _admin_chat_id(request: Request) -> int: @@ -54,15 +61,93 @@ async def add_user_to_db(payload: AddUserRequest, request: Request): @router.get("/qr-backup", response_model=CommandResponse) -async def get_qr_backup(request: Request): +async def get_qr_backup(request: Request, qr_group: str = Query("default")): handler = request.app.state.admin_handler - return _envelope(await handler.handle_get_qr_backup(_admin_chat_id(request))) + return _envelope(await handler.handle_get_qr_backup(_admin_chat_id(request), qr_group=qr_group)) @router.put("/qr-backup", response_model=CommandResponse) async def set_qr_backup(payload: QrBackupRequest, request: Request): handler = request.app.state.admin_handler - return _envelope(await handler.handle_set_qr_backup(_admin_chat_id(request), payload.qr_data)) + return _envelope(await handler.handle_set_qr_backup( + _admin_chat_id(request), + payload.qr_data, + qr_group=payload.qr_group, + )) + + +@router.post("/qr-backup/image", response_model=CommandResponse) +async def set_qr_backup_from_image( + request: Request, + qr_group: str = Form("default"), + file: UploadFile = File(...), +): + handler = request.app.state.admin_handler + image_bytes = await file.read() + return _envelope(await handler.handle_set_qr_backup_from_image( + _admin_chat_id(request), + image_bytes, + qr_group=qr_group, + )) + + +@router.get("/qr-groups", response_model=CommandResponse) +async def list_qr_groups(request: Request, qr_group: str = Query(None)): + handler = request.app.state.admin_handler + return _envelope(await handler.handle_list_qr_groups(_admin_chat_id(request), qr_group=qr_group)) + + +@router.post("/qr-groups/{qr_group}/assignments", response_model=CommandResponse) +async def assign_qr_group(qr_group: str, payload: QrGroupAssignmentRequest, request: Request): + handler = request.app.state.admin_handler + return _envelope(await handler.handle_assign_qr_group( + _admin_chat_id(request), + qr_group, + payload.group_ids, + )) + + +@router.delete("/qr-groups/assignments", response_model=CommandResponse) +async def remove_qr_group_assignments(payload: QrGroupAssignmentRequest, request: Request): + handler = request.app.state.admin_handler + return _envelope(await handler.handle_remove_qr_group_assignment( + _admin_chat_id(request), + payload.group_ids, + )) + + +@router.post("/qr-sync", response_model=CommandResponse) +async def sync_qr(payload: QrSyncRequest, request: Request): + sync_group = (payload.qr_group or "default").strip().lower() + current_task = request.app.state.api_background_tasks.get("grouphelp_qr_sync") + if current_task and not current_task.done(): + return CommandResponse(ok=True, message="ℹ️ GroupHelp QR sync is already running.") + + group_service = request.app.state.group_handler.group_service + + async def run_sync(): + if sync_group == "all": + await group_service.sync_all_grouphelp_qr_groups(delay_seconds=30) + else: + await group_service.sync_grouphelp_qr_to_owned_groups( + delay_seconds=30, + qr_group=sync_group, + ) + + def sync_done(task): + try: + task.result() + except asyncio.CancelledError: + pass + except Exception as e: + logger.error("API GroupHelp QR sync failed: %s", e) + finally: + request.app.state.api_background_tasks["grouphelp_qr_sync"] = None + + task = asyncio.create_task(run_sync(), name="api-grouphelp-qr-sync") + task.add_done_callback(sync_done) + request.app.state.api_background_tasks["grouphelp_qr_sync"] = task + return CommandResponse(ok=True, message=f"✅ GroupHelp QR sync `{sync_group}` started in background.") @router.get("/help", response_model=CommandResponse) diff --git a/src/api/schemas.py b/src/api/schemas.py index 722d1e8..b6a92eb 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List, Optional, Union from pydantic import BaseModel, Field @@ -25,8 +25,17 @@ class AddUsersToGroupRequest(BaseModel): class UserIdsRequest(BaseModel): - user_ids: List[int] = Field(..., min_items=1) + user_ids: List[Union[int, str]] = Field(..., min_items=1) class QrBackupRequest(BaseModel): qr_data: str = Field(..., min_length=1) + qr_group: Optional[str] = None + + +class QrGroupAssignmentRequest(BaseModel): + group_ids: List[Union[int, str]] = Field(..., min_items=1) + + +class QrSyncRequest(BaseModel): + qr_group: str = "default" diff --git a/src/api/server.py b/src/api/server.py index ccf9c20..f9678c1 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -28,6 +28,7 @@ def create_app( app.state.user_handler = user_handler app.state.group_handler = group_handler app.state.admin_handler = admin_handler + app.state.api_background_tasks = {"grouphelp_qr_sync": None} @app.get("/health", tags=["meta"]) async def health():