From e887dfc5cef434cc99dc38c78474b7b5794de8ec Mon Sep 17 00:00:00 2001 From: h Date: Thu, 6 Aug 2026 00:23:38 +0200 Subject: [PATCH] feat(api,frontend): add qr code login --- backend/src/api/login.py | 87 ++++++- backend/src/api/routers/accounts.py | 21 +- backend/src/userbot/modules/client.py | 2 +- frontend/bun.lock | 4 +- frontend/package.json | 3 +- frontend/src/lib/api/endpoints.ts | 8 + frontend/src/lib/api/types.ts | 3 +- .../settings/AddAccountDialog.svelte | 227 ++++++++++++------ frontend/src/lib/components/ui/QrCode.svelte | 40 +++ 9 files changed, 315 insertions(+), 80 deletions(-) create mode 100644 frontend/src/lib/components/ui/QrCode.svelte diff --git a/backend/src/api/login.py b/backend/src/api/login.py index f16039d..52b5b7f 100644 --- a/backend/src/api/login.py +++ b/backend/src/api/login.py @@ -1,16 +1,19 @@ +import asyncio import contextlib import secrets import time -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from pyrogram.errors import SessionPasswordNeeded +from pyrogram.errors import AuthTokenExpired, SessionPasswordNeeded +from pyrogram.qrlogin import QRLogin from pyrogram.types import User from userbot import PyroClient from utils.env import env LOGIN_TTL_SECONDS = 900 +QR_POLL_SECONDS = 25 PENDING_DIRNAME = "pending" @@ -18,12 +21,27 @@ 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 - phone: str - phone_code_hash: str started_at: float + phone: str = "" + phone_code_hash: str = "" + qr: QrState | None = None + watcher: asyncio.Task[None] | None = None def _sessions_dir() -> Path: @@ -38,6 +56,29 @@ def _pending_dir() -> Path: 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] = {} @@ -49,21 +90,53 @@ class LoginManager: raise LoginError(msg) return login - async def start(self, phone: str) -> str: + 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, phone, sent.phone_code_hash, time.monotonic() + 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: @@ -81,6 +154,7 @@ class LoginManager: 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") @@ -88,6 +162,7 @@ class LoginManager: 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: diff --git a/backend/src/api/routers/accounts.py b/backend/src/api/routers/accounts.py index e2c25ef..a41a121 100644 --- a/backend/src/api/routers/accounts.py +++ b/backend/src/api/routers/accounts.py @@ -37,7 +37,8 @@ class PasswordRequest(BaseModel): class LoginState(BaseModel): login_id: str - stage: Literal["code", "password", "done"] + stage: Literal["code", "qr", "password", "done"] + qr_url: str | None = None account: AccountView | None = None @@ -79,6 +80,24 @@ async def start_login(body: PhoneRequest) -> LoginState: return LoginState(login_id=login_id, stage="code") +@router.post("/accounts/login/qr") +async def start_qr_login() -> LoginState: + login_id, url = await _guard(login_manager.start_qr()) + return LoginState(login_id=login_id, stage="qr", qr_url=url) + + +@router.get("/accounts/login/{login_id}/qr") +async def poll_qr_login(login_id: str, pool: FromDishka[asyncpg.Pool]) -> LoginState: + state = await _guard(login_manager.wait_qr(login_id)) + if state.user is not None: + return await _complete(pool, login_id, state.user) + if state.password_needed: + return LoginState(login_id=login_id, stage="password") + if state.error is not None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, state.error) + return LoginState(login_id=login_id, stage="qr", qr_url=state.url) + + @router.post("/accounts/login/{login_id}/code") async def submit_code( login_id: str, body: CodeRequest, pool: FromDishka[asyncpg.Pool] diff --git a/backend/src/userbot/modules/client.py b/backend/src/userbot/modules/client.py index 1b9010a..764fec2 100644 --- a/backend/src/userbot/modules/client.py +++ b/backend/src/userbot/modules/client.py @@ -17,7 +17,7 @@ class PyroClient(Client): api_hash="b18441a1ff607e10a989891a5462e627", device_model="Desktop", system_version="Windows 11 x64", - app_version="6.2.4 x64", + app_version="6.7.8 x64", lang_pack="tdesktop", client_platform=enums.ClientPlatform.DESKTOP, ) diff --git a/frontend/bun.lock b/frontend/bun.lock index 4b63081..b9fc83f 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "frontend", @@ -8,6 +7,7 @@ "bits-ui": "^2.18.1", "lottie-web": "^5.13.0", "pako": "^2.1.0", + "uqr": "^0.1.3", }, "devDependencies": { "@biomejs/biome": "2.4.15", @@ -388,6 +388,8 @@ "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], diff --git a/frontend/package.json b/frontend/package.json index a078a2a..23340eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,7 @@ "dependencies": { "bits-ui": "^2.18.1", "lottie-web": "^5.13.0", - "pako": "^2.1.0" + "pako": "^2.1.0", + "uqr": "^0.1.3" } } diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 1cb3d30..b60264a 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -53,6 +53,14 @@ export function startLogin(phone: string): Promise { }); } +export function startQrLogin(): Promise { + return request("/accounts/login/qr", { method: "POST" }); +} + +export function pollQrLogin(loginId: string): Promise { + return request(`/accounts/login/${loginId}/qr`); +} + export function submitLoginCode( loginId: string, code: string diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 280440c..345685e 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -22,11 +22,12 @@ export interface Account { tg_user_id: number | null; } -export type LoginStage = "code" | "password" | "done"; +export type LoginStage = "code" | "qr" | "password" | "done"; export interface LoginState { account: Account | null; login_id: string; + qr_url: string | null; stage: LoginStage; } diff --git a/frontend/src/lib/components/settings/AddAccountDialog.svelte b/frontend/src/lib/components/settings/AddAccountDialog.svelte index 6c05bc8..1f20f3f 100644 --- a/frontend/src/lib/components/settings/AddAccountDialog.svelte +++ b/frontend/src/lib/components/settings/AddAccountDialog.svelte @@ -1,30 +1,37 @@ @@ -108,54 +163,78 @@ -
+ {#if step === "qr"}
-

{hints[step]}

- {#if step === "phone"} -
- - -
- {:else if step === "code"} -
- - -
- {:else} -
- - -
- {/if} +

{hints.qr}

+
+ {#if qrUrl} + + {/if} +
-
-
+ {:else} +
+
+

{hints[step]}

+ {#if step === "phone"} +
+ + +
+ {:else if step === "code"} +
+ + +
+ {:else} +
+ + +
+ {/if} +
+
+ {#if step === "phone"} + + {/if} + +
+
+ {/if}
@@ -166,4 +245,14 @@ font-size: 0.9375rem; color: var(--color-text-secondary); } + + .qr-slot { + display: grid; + place-items: center; + width: min(15rem, 100%); + aspect-ratio: 1; + margin: 0 auto; + background: var(--color-background-secondary); + border-radius: 0.75rem; + } diff --git a/frontend/src/lib/components/ui/QrCode.svelte b/frontend/src/lib/components/ui/QrCode.svelte new file mode 100644 index 0000000..5cbd6f3 --- /dev/null +++ b/frontend/src/lib/components/ui/QrCode.svelte @@ -0,0 +1,40 @@ + + + + + + + +