Files
beavergram/backend/src/api/login.py
T

184 lines
5.7 KiB
Python

import asyncio
import contextlib
import secrets
import time
from dataclasses import dataclass, field
from pathlib import Path
from pyrogram.errors import AuthTokenExpired, SessionPasswordNeeded
from pyrogram.qrlogin import QRLogin
from pyrogram.types import User
from userbot.modules.client import PyroClient
from utils.env import env
LOGIN_TTL_SECONDS = 900
QR_POLL_SECONDS = 25
PENDING_DIRNAME = "pending"
class LoginError(Exception):
pass
@dataclass
class QrState:
url: str
user: User | None = None
password_needed: bool = False
error: str | None = None
changed: asyncio.Event = field(default_factory=asyncio.Event)
@property
def settled(self) -> bool:
return self.user is not None or self.password_needed or self.error is not None
@dataclass
class PendingLogin:
client: PyroClient
started_at: float
phone: str = ""
phone_code_hash: str = ""
qr: QrState | None = None
watcher: asyncio.Task[None] | None = None
def _sessions_dir() -> Path:
path = Path(env.tg.sessions_dir)
path.mkdir(parents=True, exist_ok=True)
return path
def _pending_dir() -> Path:
path = _sessions_dir() / PENDING_DIRNAME
path.mkdir(parents=True, exist_ok=True)
return path
async def _stop_watcher(login: PendingLogin) -> None:
if login.watcher is None or login.watcher.done():
return
login.watcher.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await login.watcher
async def _watch_qr(qr: QRLogin, state: QrState) -> None:
while not state.settled:
try:
try:
state.user = await qr.wait()
except (TimeoutError, AuthTokenExpired):
await qr.recreate()
state.url = qr.url
except SessionPasswordNeeded:
state.password_needed = True
except Exception as exc:
state.error = str(exc)
state.changed.set()
class LoginManager:
def __init__(self) -> None:
self._logins: dict[str, PendingLogin] = {}
def _get(self, login_id: str) -> PendingLogin:
login = self._logins.get(login_id)
if login is None:
msg = "Сессия входа истекла, начните заново"
raise LoginError(msg)
return login
async def _connect(self) -> tuple[str, PyroClient]:
await self._sweep()
login_id = secrets.token_hex(8)
client = PyroClient(login_id, workdir=str(_pending_dir()), load_handlers=False)
await client.connect()
return login_id, client
async def start(self, phone: str) -> str:
login_id, client = await self._connect()
try:
sent = await client.send_code(phone)
except Exception:
await self._discard(login_id, client)
raise
self._logins[login_id] = PendingLogin(
client, time.monotonic(), phone=phone, phone_code_hash=sent.phone_code_hash
)
return login_id
async def start_qr(self) -> tuple[str, str]:
login_id, client = await self._connect()
qr = QRLogin(client)
try:
await qr.recreate()
except Exception:
await self._discard(login_id, client)
raise
state = QrState(qr.url)
self._logins[login_id] = PendingLogin(
client,
time.monotonic(),
qr=state,
watcher=asyncio.create_task(_watch_qr(qr, state)),
)
return login_id, state.url
async def wait_qr(self, login_id: str) -> QrState:
login = self._get(login_id)
if login.qr is None:
msg = "Этот вход начат по номеру телефона"
raise LoginError(msg)
with contextlib.suppress(TimeoutError):
async with asyncio.timeout(QR_POLL_SECONDS):
await login.qr.changed.wait()
login.qr.changed.clear()
return login.qr
async def submit_code(self, login_id: str, code: str) -> User | None:
login = self._get(login_id)
try:
user = await login.client.sign_in(login.phone, login.phone_code_hash, code)
except SessionPasswordNeeded:
return None
if not isinstance(user, User):
await self.cancel(login_id)
msg = "Этот номер не зарегистрирован в Telegram"
raise LoginError(msg)
return user
async def submit_password(self, login_id: str, password: str) -> User:
return await self._get(login_id).client.check_password(password)
async def finalize(self, login_id: str, session_name: str) -> None:
login = self._logins.pop(login_id)
await _stop_watcher(login)
await login.client.disconnect()
source = _pending_dir() / f"{login_id}.session"
source.replace(_sessions_dir() / f"{session_name}.session")
async def cancel(self, login_id: str) -> None:
login = self._logins.pop(login_id, None)
if login is not None:
await _stop_watcher(login)
await self._discard(login_id, login.client)
async def _discard(self, login_id: str, client: PyroClient) -> None:
with contextlib.suppress(Exception):
await client.disconnect()
(_pending_dir() / f"{login_id}.session").unlink(missing_ok=True)
async def _sweep(self) -> None:
now = time.monotonic()
for login_id, login in list(self._logins.items()):
if now - login.started_at > LOGIN_TTL_SECONDS:
await self.cancel(login_id)
for path in _pending_dir().glob("*.session"):
if path.stem not in self._logins:
path.unlink(missing_ok=True)
login_manager = LoginManager()