96de2d2573
Manage Telegram instances through Kubernetes with legacy adoption for forust and anna. Build and deploy the panel image alongside the runtime.
203 lines
6.5 KiB
Python
203 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import secrets
|
|
from contextlib import suppress
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from pyrogram import Client
|
|
from pyrogram.errors import (
|
|
ApiIdInvalid,
|
|
FloodWait,
|
|
PasswordHashInvalid,
|
|
PhoneCodeExpired,
|
|
PhoneCodeInvalid,
|
|
PhoneNumberInvalid,
|
|
RPCError,
|
|
SessionPasswordNeeded,
|
|
Unauthorized,
|
|
)
|
|
|
|
from .errors import PanelError
|
|
from .models import AccountBase, PhoneStart, StringSessionStart
|
|
|
|
|
|
@dataclass
|
|
class AuthFlow:
|
|
flow_id: str
|
|
account: PhoneStart
|
|
client: Client
|
|
phone_code_hash: str
|
|
expires_at: datetime
|
|
|
|
|
|
@dataclass
|
|
class AuthorizedAccount:
|
|
account: AccountBase
|
|
session_string: str
|
|
|
|
|
|
class TelegramAuthService:
|
|
def __init__(self, ttl_seconds: int = 600) -> None:
|
|
self.ttl = timedelta(seconds=ttl_seconds)
|
|
self.flows: dict[str, AuthFlow] = {}
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def start_phone(self, account: PhoneStart) -> str:
|
|
await self._cleanup_expired()
|
|
flow_id = secrets.token_urlsafe(24)
|
|
telegram = Client(
|
|
f"auth-{flow_id}",
|
|
api_id=account.api_id,
|
|
api_hash=account.api_hash,
|
|
in_memory=True,
|
|
no_updates=True,
|
|
)
|
|
try:
|
|
await telegram.connect()
|
|
sent = await telegram.send_code(account.phone)
|
|
except Exception as exc:
|
|
await self._disconnect(telegram)
|
|
raise self._translate(exc) from exc
|
|
|
|
flow = AuthFlow(
|
|
flow_id=flow_id,
|
|
account=account,
|
|
client=telegram,
|
|
phone_code_hash=sent.phone_code_hash,
|
|
expires_at=datetime.now(UTC) + self.ttl,
|
|
)
|
|
async with self._lock:
|
|
self.flows[flow_id] = flow
|
|
return flow_id
|
|
|
|
async def submit_code(
|
|
self,
|
|
flow_id: str,
|
|
code: str,
|
|
) -> AuthorizedAccount | None:
|
|
flow = await self._get(flow_id)
|
|
try:
|
|
await flow.client.sign_in(
|
|
flow.account.phone,
|
|
flow.phone_code_hash,
|
|
code,
|
|
)
|
|
except SessionPasswordNeeded:
|
|
return None
|
|
except PhoneCodeExpired as exc:
|
|
await self._discard(flow)
|
|
raise self._translate(exc) from exc
|
|
except Exception as exc:
|
|
raise self._translate(exc) from exc
|
|
return await self._finish(flow)
|
|
|
|
async def submit_password(
|
|
self,
|
|
flow_id: str,
|
|
password: str,
|
|
) -> AuthorizedAccount:
|
|
flow = await self._get(flow_id)
|
|
try:
|
|
await flow.client.check_password(password)
|
|
except Exception as exc:
|
|
raise self._translate(exc) from exc
|
|
return await self._finish(flow)
|
|
|
|
async def validate_string_session(
|
|
self,
|
|
account: StringSessionStart,
|
|
) -> AuthorizedAccount:
|
|
telegram = Client(
|
|
f"validate-{secrets.token_urlsafe(12)}",
|
|
api_id=account.api_id,
|
|
api_hash=account.api_hash,
|
|
session_string=account.session_string,
|
|
in_memory=True,
|
|
no_updates=True,
|
|
)
|
|
try:
|
|
await telegram.connect()
|
|
await telegram.get_me()
|
|
session_string = await telegram.export_session_string()
|
|
except Exception as exc:
|
|
raise self._translate(exc, session=True) from exc
|
|
finally:
|
|
await self._disconnect(telegram)
|
|
return AuthorizedAccount(account=account, session_string=session_string)
|
|
|
|
async def close(self) -> None:
|
|
async with self._lock:
|
|
flows = list(self.flows.values())
|
|
self.flows.clear()
|
|
await asyncio.gather(
|
|
*(self._disconnect(flow.client) for flow in flows),
|
|
return_exceptions=True,
|
|
)
|
|
|
|
async def _get(self, flow_id: str) -> AuthFlow:
|
|
await self._cleanup_expired()
|
|
async with self._lock:
|
|
flow = self.flows.get(flow_id)
|
|
if flow is None:
|
|
raise PanelError(410, "Authorization flow expired; start again")
|
|
return flow
|
|
|
|
async def _finish(self, flow: AuthFlow) -> AuthorizedAccount:
|
|
try:
|
|
await flow.client.get_me()
|
|
session_string = await flow.client.export_session_string()
|
|
except Exception as exc:
|
|
raise self._translate(exc) from exc
|
|
finally:
|
|
async with self._lock:
|
|
self.flows.pop(flow.flow_id, None)
|
|
await self._disconnect(flow.client)
|
|
return AuthorizedAccount(account=flow.account, session_string=session_string)
|
|
|
|
async def _discard(self, flow: AuthFlow) -> None:
|
|
async with self._lock:
|
|
self.flows.pop(flow.flow_id, None)
|
|
await self._disconnect(flow.client)
|
|
|
|
async def _cleanup_expired(self) -> None:
|
|
now = datetime.now(UTC)
|
|
async with self._lock:
|
|
expired = [
|
|
self.flows.pop(flow_id)
|
|
for flow_id, flow in list(self.flows.items())
|
|
if flow.expires_at <= now
|
|
]
|
|
if expired:
|
|
await asyncio.gather(
|
|
*(self._disconnect(flow.client) for flow in expired),
|
|
return_exceptions=True,
|
|
)
|
|
|
|
@staticmethod
|
|
async def _disconnect(telegram: Client) -> None:
|
|
with suppress(Exception):
|
|
if telegram.is_connected:
|
|
await telegram.disconnect()
|
|
|
|
@staticmethod
|
|
def _translate(exc: Exception, *, session: bool = False) -> PanelError:
|
|
if isinstance(exc, FloodWait):
|
|
return PanelError(429, f"Telegram rate limit; retry in {exc.value} seconds")
|
|
if isinstance(exc, ApiIdInvalid):
|
|
return PanelError(422, "Telegram API ID or API Hash is invalid")
|
|
if isinstance(exc, PhoneNumberInvalid):
|
|
return PanelError(422, "Phone number is invalid")
|
|
if isinstance(exc, PhoneCodeInvalid):
|
|
return PanelError(422, "Telegram code is invalid")
|
|
if isinstance(exc, PhoneCodeExpired):
|
|
return PanelError(410, "Telegram code expired; start again")
|
|
if isinstance(exc, PasswordHashInvalid):
|
|
return PanelError(422, "2FA password is invalid")
|
|
if session and isinstance(exc, (Unauthorized, RPCError)):
|
|
return PanelError(422, "StringSession is invalid or expired")
|
|
if isinstance(exc, RPCError):
|
|
return PanelError(422, "Telegram rejected the authorization request")
|
|
return PanelError(503, "Telegram authorization is unavailable")
|