From 58e393c99a7f621a40626f17290685f17463fa2b Mon Sep 17 00:00:00 2001 From: haojiakang Date: Sun, 28 Jun 2026 00:09:44 +0800 Subject: [PATCH] Fix Telegram client startup under uvicorn in Docker Defer Pyrogram Client creation until the FastAPI lifespan runs so clients bind to uvicorn's event loop instead of failing with "Future attached to a different loop". Register bot handlers after clients are built, resolve stale tg_client imports in auth/streaming routers, and mount the session volume at /app/session so rebuilds are not masked by a persisted /app tree. Fixes #5 Co-authored-by: Cursor --- backend/app/main.py | 3 --- backend/app/routers/auth.py | 4 +-- backend/app/routers/streaming.py | 11 ++++---- backend/app/telegram.py | 45 ++++++++++++++++++-------------- docker-compose.yml | 4 +-- 5 files changed, 36 insertions(+), 31 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 692718b..13fb347 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,9 +21,6 @@ from .telegram import start_telegram_client, stop_telegram_client from .routers import files_router, folders_router, streaming_router, auth_router, tv_router -# Import bot to register handlers -from . import bot # noqa - settings = get_settings() # Rate limiter - uses IP address by default diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 9c4b13c..e7c89cd 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -27,7 +27,7 @@ verify_token_payload, get_current_user, ) -from ..telegram import tg_client +from .. import telegram # Get limiter from main app limiter = Limiter(key_func=get_remote_address) @@ -40,7 +40,7 @@ async def get_bot_info_endpoint(): """Get bot username and name for the login screen.""" try: - me = await tg_client.get_me() + me = await telegram.tg_client.get_me() return BotInfoResponse( username=me.username, name=f"{me.first_name} {me.last_name or ''}".strip(), diff --git a/backend/app/routers/streaming.py b/backend/app/routers/streaming.py index d5f57c4..365b3d0 100644 --- a/backend/app/routers/streaming.py +++ b/backend/app/routers/streaming.py @@ -13,7 +13,8 @@ from ..database import get_db from ..models import File, User from ..auth import get_current_user -from ..telegram import get_message_from_channel, tg_client +from .. import telegram +from ..telegram import get_message_from_channel from ..streaming import stream_file as stream_file_generator # Logger for internal debugging (not exposed to users) @@ -82,7 +83,7 @@ async def stream_file( async def file_streamer(): """Generator that streams file chunks from Telegram MTProto.""" async for chunk in stream_file_generator( - tg_client, + telegram.tg_client, message, from_bytes, until_bytes @@ -149,14 +150,14 @@ async def get_thumbnail( # Try using the file_id directly if stored (fallback) if file.thumbnail_file_id: try: - thumb_bytes = await tg_client.download_media(file.thumbnail_file_id, in_memory=True) + thumb_bytes = await telegram.tg_client.download_media(file.thumbnail_file_id, in_memory=True) return Response(content=thumb_bytes.getvalue(), media_type="image/jpeg") except Exception: pass raise HTTPException(status_code=404, detail="Thumbnail not found in message") # Download thumbnail to memory - thumb_bytes = await tg_client.download_media(thumbnail.file_id, in_memory=True) + thumb_bytes = await telegram.tg_client.download_media(thumbnail.file_id, in_memory=True) return Response( content=thumb_bytes.getvalue(), @@ -208,7 +209,7 @@ async def stream_public_file( async def file_streamer(): """Generator that streams file chunks from Telegram MTProto.""" async for chunk in stream_file_generator( - tg_client, + telegram.tg_client, message, from_bytes, until_bytes diff --git a/backend/app/telegram.py b/backend/app/telegram.py index d616f22..e4ee23f 100644 --- a/backend/app/telegram.py +++ b/backend/app/telegram.py @@ -21,25 +21,30 @@ def get_session_name(index: int) -> str: return str(SESSION_DIR / f"bot_{index}") -# Build a pool: main token first, then any helper tokens -tokens = settings.all_bot_tokens -clients = [] - -for i, token in enumerate(tokens): - client = Client( - name=get_session_name(i), - api_id=settings.telegram_api_id, - api_hash=settings.telegram_api_hash, - bot_token=token, - ipv6=False, - max_concurrent_transmissions=settings.telegram_client_concurrency, - no_updates=(i > 0), # only main client receives updates - ) - client.pool_index = i # custom attr for logging - clients.append(client) - -# Main client — used for bot commands, message fetching, forwarding, etc. -tg_client = clients[0] +# Created in build_clients() inside the running event loop (see start_telegram_client). +clients: list[Client] = [] +tg_client: Client | None = None + + +def build_clients() -> None: + """Build client pool lazily so Pyrogram binds to uvicorn's event loop.""" + global tg_client + if clients: + return + SESSION_DIR.mkdir(parents=True, exist_ok=True) + for i, token in enumerate(settings.all_bot_tokens): + client = Client( + name=get_session_name(i), + api_id=settings.telegram_api_id, + api_hash=settings.telegram_api_hash, + bot_token=token, + ipv6=False, + max_concurrent_transmissions=settings.telegram_client_concurrency, + no_updates=(i > 0), # only main client receives updates + ) + client.pool_index = i # custom attr for logging + clients.append(client) + tg_client = clients[0] # ── lifecycle helpers ──────────────────────────────────────────────── @@ -75,6 +80,8 @@ async def stop_all_clients(): async def start_telegram_client(): """Called from app lifespan — starts the full pool.""" + build_clients() + from . import bot # noqa: F401 — register handlers after client exists await start_all_clients() diff --git a/docker-compose.yml b/docker-compose.yml index 9cd6a72..3f1f035 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,8 +65,8 @@ services: ports: - "${BACKEND_PORT:-8000}:8000" volumes: - # Persist Telegram session - - telegram_session:/app + # Persist Telegram session files only (do not mount over /app — that hides rebuilt code) + - telegram_session:/app/session networks: - telegram-tv-network