diff --git a/backend/migrations/versions/e7b4c2a9f861_per_account_policy_defaults.py b/backend/migrations/versions/e7b4c2a9f861_per_account_policy_defaults.py new file mode 100644 index 0000000..390e759 --- /dev/null +++ b/backend/migrations/versions/e7b4c2a9f861_per_account_policy_defaults.py @@ -0,0 +1,43 @@ +"""per-account policy defaults + +Revision ID: e7b4c2a9f861 +Revises: d5f9b2c8e3a1 +Create Date: 2026-08-05 23:10:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "e7b4c2a9f861" +down_revision: str | None = "d5f9b2c8e3a1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_SCOPE_KEY = "capture_policy_account_id_scope_type_scope_id_key" + + +def upgrade() -> None: + op.execute("DROP INDEX ix_capture_policy_default") + op.execute(f"ALTER TABLE capture_policy DROP CONSTRAINT {_SCOPE_KEY}") + op.execute( + "CREATE UNIQUE INDEX ix_capture_policy_scope ON capture_policy " + "(account_id, scope_type, scope_id) NULLS NOT DISTINCT" + ) + + +def downgrade() -> None: + op.execute( + "DELETE FROM capture_policy WHERE account_id IS NOT NULL " + "AND scope_type LIKE 'default_%'" + ) + op.execute("DROP INDEX ix_capture_policy_scope") + op.execute( + f"ALTER TABLE capture_policy ADD CONSTRAINT {_SCOPE_KEY} " + "UNIQUE (account_id, scope_type, scope_id)" + ) + op.execute( + "CREATE UNIQUE INDEX ix_capture_policy_default " + "ON capture_policy (scope_type) WHERE scope_type LIKE 'default_%'" + ) diff --git a/backend/src/api/login.py b/backend/src/api/login.py new file mode 100644 index 0000000..f16039d --- /dev/null +++ b/backend/src/api/login.py @@ -0,0 +1,108 @@ +import contextlib +import secrets +import time +from dataclasses import dataclass +from pathlib import Path + +from pyrogram.errors import SessionPasswordNeeded +from pyrogram.types import User + +from userbot import PyroClient +from utils.env import env + +LOGIN_TTL_SECONDS = 900 +PENDING_DIRNAME = "pending" + + +class LoginError(Exception): + pass + + +@dataclass +class PendingLogin: + client: PyroClient + phone: str + phone_code_hash: str + started_at: float + + +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 + + +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 start(self, phone: str) -> str: + await self._sweep() + login_id = secrets.token_hex(8) + client = PyroClient(login_id, workdir=str(_pending_dir()), load_handlers=False) + await client.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() + ) + return login_id + + 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 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 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() diff --git a/backend/src/api/routers/accounts.py b/backend/src/api/routers/accounts.py index 8f5c5af..e2c25ef 100644 --- a/backend/src/api/routers/accounts.py +++ b/backend/src/api/routers/accounts.py @@ -1,13 +1,109 @@ +from collections.abc import Coroutine +from typing import Any, Literal + import asyncpg from dishka.integrations.fastapi import DishkaRoute, FromDishka -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel +from pyrogram.errors import FloodWait, RPCError +from pyrogram.types import User +from api.login import LoginError, login_manager from utils.read import accounts from utils.read.models import AccountView router = APIRouter(prefix="/api", tags=["accounts"], route_class=DishkaRoute) +_ERROR_MESSAGES = { + "PhoneNumberInvalid": "Неверный номер телефона", + "PhoneNumberBanned": "Номер заблокирован в Telegram", + "PhoneCodeInvalid": "Неверный код", + "PhoneCodeExpired": "Код истёк, запросите новый", + "PasswordHashInvalid": "Неверный пароль", +} + + +class PhoneRequest(BaseModel): + phone: str + + +class CodeRequest(BaseModel): + code: str + + +class PasswordRequest(BaseModel): + password: str + + +class LoginState(BaseModel): + login_id: str + stage: Literal["code", "password", "done"] + account: AccountView | None = None + + +async def _guard[T](coro: Coroutine[Any, Any, T]) -> T: + try: + return await coro + except LoginError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + except FloodWait as exc: + raise HTTPException( + status.HTTP_429_TOO_MANY_REQUESTS, + f"Слишком много попыток, подождите {exc.value} с", + ) from exc + except RPCError as exc: + detail = _ERROR_MESSAGES.get(type(exc).__name__, str(exc)) + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail) from exc + + +async def _complete(pool: asyncpg.Pool, login_id: str, user: User) -> LoginState: + session_name = str(user.id) + account_id = await accounts.sync_account(pool, user, session_name) + await login_manager.finalize(login_id, session_name) + await accounts.notify_accounts_changed(pool) + return LoginState( + login_id=login_id, + stage="done", + account=await accounts.get_account(pool, account_id), + ) + @router.get("/accounts") async def list_accounts(pool: FromDishka[asyncpg.Pool]) -> list[AccountView]: return await accounts.list_accounts(pool) + + +@router.post("/accounts/login") +async def start_login(body: PhoneRequest) -> LoginState: + login_id = await _guard(login_manager.start(body.phone)) + return LoginState(login_id=login_id, stage="code") + + +@router.post("/accounts/login/{login_id}/code") +async def submit_code( + login_id: str, body: CodeRequest, pool: FromDishka[asyncpg.Pool] +) -> LoginState: + user = await _guard(login_manager.submit_code(login_id, body.code)) + if user is None: + return LoginState(login_id=login_id, stage="password") + return await _complete(pool, login_id, user) + + +@router.post("/accounts/login/{login_id}/password") +async def submit_password( + login_id: str, body: PasswordRequest, pool: FromDishka[asyncpg.Pool] +) -> LoginState: + user = await _guard(login_manager.submit_password(login_id, body.password)) + return await _complete(pool, login_id, user) + + +@router.delete("/accounts/login/{login_id}", status_code=status.HTTP_204_NO_CONTENT) +async def cancel_login(login_id: str) -> None: + await login_manager.cancel(login_id) + + +@router.delete("/accounts/{account_id}", status_code=status.HTTP_204_NO_CONTENT) +async def logout_account(account_id: int, pool: FromDishka[asyncpg.Pool]) -> None: + if await accounts.deactivate_account(pool, account_id) is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Аккаунт не найден") + await accounts.notify_accounts_changed(pool) diff --git a/backend/src/api/routers/policy.py b/backend/src/api/routers/policy.py index 4970bf5..4241194 100644 --- a/backend/src/api/routers/policy.py +++ b/backend/src/api/routers/policy.py @@ -71,9 +71,15 @@ async def get_policy(pool: FromDishka[asyncpg.Pool], policy_id: int) -> PolicyRe @router.put("/{policy_id}") async def update_policy( - pool: FromDishka[asyncpg.Pool], policy_id: int, body: CaptureToggles + pool: FromDishka[asyncpg.Pool], + policy_id: int, + body: CaptureToggles, + account_id: Annotated[int | None, Query()] = None, ) -> PolicyRecord: - record = await repository.update_policy(pool, policy_id, body) + if account_id is None: + record = await repository.update_policy(pool, policy_id, body) + else: + record = await repository.override_policy(pool, policy_id, account_id, body) if record is None: raise HTTPException(status_code=404, detail="policy not found") await pool.execute(f"NOTIFY {POLICY_CHANGED_CHANNEL}") diff --git a/backend/src/userbot/runner.py b/backend/src/userbot/runner.py index ed958f5..79156ee 100644 --- a/backend/src/userbot/runner.py +++ b/backend/src/userbot/runner.py @@ -1,7 +1,7 @@ import asyncio import contextlib -import json from collections.abc import Callable, Coroutine +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -15,87 +15,144 @@ from userbot.modules.jobs import JobConsumer from utils.env import env from utils.jobs import enqueue from utils.logging import logger, setup_logging +from utils.read.accounts import ( + ACCOUNTS_CHANGED_CHANNEL, + inactive_session_names, + sync_account, +) from utils.read.watches import WATCHES_CHANGED_CHANNEL from utils.storage import ContentAddressedStorage setup_logging() -_UPSERT_ACCOUNT = """ -INSERT INTO accounts - (tg_user_id, label, phone, session_name, is_active, raw, updated_at) -VALUES ($1, $2, $3, $4, TRUE, $5::jsonb, now()) -ON CONFLICT (tg_user_id) DO UPDATE SET - label = EXCLUDED.label, - phone = EXCLUDED.phone, - session_name = EXCLUDED.session_name, - is_active = TRUE, - raw = EXCLUDED.raw, - updated_at = now() -RETURNING account_id -""" + +@dataclass +class RunningAccount: + client: PyroClient + consumer_task: asyncio.Task -def _discover_sessions(sessions_dir: Path) -> list[Path]: +def _sessions_dir() -> Path: + sessions_dir = Path(env.tg.sessions_dir) sessions_dir.mkdir(parents=True, exist_ok=True) - return sorted(sessions_dir.glob("*.session")) + return sessions_dir -async def _sync_account( - pool: asyncpg.Pool, client: PyroClient, session_name: str -) -> int | None: - me = client.me - if not me: - return None - raw = json.dumps( - { - "id": me.id, - "first_name": me.first_name, - "last_name": me.last_name, - "username": me.username, - "phone_number": me.phone_number, - } - ) - label = " ".join(filter(None, [me.first_name, me.last_name])) or me.username - account_id = await pool.fetchval( - _UPSERT_ACCOUNT, me.id, label, me.phone_number, session_name, raw - ) - logger.info(f"[green]Account synced:[/] {label} ({me.id})") - return account_id +async def _cancel(task: asyncio.Task) -> None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task -async def _setup_capture( - pool: asyncpg.Pool, - client: PyroClient, - account_id: int, - storage: ContentAddressedStorage, -) -> None: - client.capture = await build_capture_context(client, pool, storage, account_id) - logger.info("[green]Capture context ready.[/]") - - -async def _enqueue_sync_dialogs(pool: asyncpg.Pool, account_id: int) -> None: +async def _enqueue_once(pool: asyncpg.Pool, account_id: int, kind: str) -> None: existing = await pool.fetchval( - "SELECT 1 FROM jobs WHERE account_id = $1 AND kind = 'sync_dialogs' " + "SELECT 1 FROM jobs WHERE account_id = $1 AND kind = $2 " "AND status IN ('pending', 'running') LIMIT 1", account_id, + kind, ) if existing is None: - await enqueue(pool, account_id, "sync_dialogs", {}) - logger.info("[green]Queued sync_dialogs.[/]") + await enqueue(pool, account_id, kind, {}) + logger.info(f"[green]Queued {kind}.[/]") + + +class AccountRegistry: + def __init__(self, pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None: + self._pool = pool + self._storage = storage + self._running: dict[str, RunningAccount] = {} + self._lock = asyncio.Lock() + + @property + def clients(self) -> list[PyroClient]: + return [account.client for account in self._running.values()] + + async def sync(self) -> None: + async with self._lock: + inactive = await inactive_session_names(self._pool) + present: set[str] = set() + for path in sorted(_sessions_dir().glob("*.session")): + if path.stem in inactive: + await self._log_out(path) + continue + present.add(path.stem) + if path.stem not in self._running: + await self._start(path) + for session_name in set(self._running) - present: + await self._stop(session_name) + + async def close(self) -> None: + for session_name in list(self._running): + await self._stop(session_name) + + async def _start(self, path: Path) -> None: + session_name = path.stem + client = PyroClient(session_name, workdir=str(path.parent)) + try: + await client.start() + me = client.me + if me is None: + msg = f"session {session_name} is not authorized" + raise RuntimeError(msg) + account_id = await sync_account(self._pool, me, session_name) + client.capture = await build_capture_context( + client, self._pool, self._storage, account_id + ) + except Exception: + logger.exception(f"[red]Failed to start session:[/] {session_name}") + with contextlib.suppress(Exception): + await client.stop() + return + consumer = JobConsumer(client, self._pool, account_id) + self._running[session_name] = RunningAccount( + client, asyncio.create_task(consumer.run()) + ) + logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})") + await _enqueue_once(self._pool, account_id, "sync_dialogs") + await _enqueue_once(self._pool, account_id, "sync_contacts") + + async def _stop(self, session_name: str) -> None: + account = self._running.pop(session_name, None) + if account is None: + return + await _cancel(account.consumer_task) + with contextlib.suppress(Exception): + await account.client.stop() + logger.info(f"[yellow]Client stopped:[/] {session_name}") + + async def _log_out(self, path: Path) -> None: + account = self._running.pop(path.stem, None) + if account is not None: + await _cancel(account.consumer_task) + client = account.client + else: + client = PyroClient( + path.stem, workdir=str(path.parent), load_handlers=False + ) + with contextlib.suppress(Exception): + if account is None: + await client.start() + await client.log_out() + with contextlib.suppress(Exception): + await client.stop() + path.unlink(missing_ok=True) # noqa: ASYNC240 + logger.info(f"[yellow]Account logged out:[/] {path.stem}") async def _listen_changes( - clients: list[PyroClient], tasks: set[asyncio.Task] + registry: AccountRegistry, tasks: set[asyncio.Task] ) -> asyncpg.Connection: + def spawn(coro: Coroutine[Any, Any, None]) -> None: + task = asyncio.create_task(coro) + tasks.add(task) + task.add_done_callback(tasks.discard) + def reload( make_coro: Callable[[CaptureContext], Coroutine[Any, Any, None]], ) -> None: - for client in clients: - if client.capture is None: - continue - task = asyncio.create_task(make_coro(client.capture)) - tasks.add(task) - task.add_done_callback(tasks.discard) + for client in registry.clients: + if client.capture is not None: + spawn(make_coro(client.capture)) def on_policy( _conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str @@ -107,9 +164,15 @@ async def _listen_changes( ) -> None: reload(lambda capture: capture.watches.refresh()) + def on_accounts( + _conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str + ) -> None: + spawn(registry.sync()) + conn = await asyncpg.connect(dsn=env.db.connection_url) await conn.add_listener("policy_changed", on_policy) await conn.add_listener(WATCHES_CHANGED_CHANNEL, on_watch) + await conn.add_listener(ACCOUNTS_CHANGED_CHANNEL, on_accounts) return conn @@ -117,53 +180,21 @@ async def runner() -> None: pool = await container.get(asyncpg.Pool) storage = await container.get(ContentAddressedStorage) - sessions_dir = Path(env.tg.sessions_dir) - session_files = _discover_sessions(sessions_dir) - - if not session_files: - logger.warning( - f"[yellow]No .session files in {sessions_dir}/. " - f"Log in first, then restart userbot.[/]" - ) - - clients: list[PyroClient] = [] - reload_tasks: set[asyncio.Task] = set() - consumer_tasks: list[asyncio.Task] = [] + registry = AccountRegistry(pool, storage) + tasks: set[asyncio.Task] = set() listen_conn: asyncpg.Connection | None = None try: - for session_path in session_files: - session_name = session_path.stem - client = PyroClient(session_name, workdir=str(sessions_dir)) - await client.start() - clients.append(client) - logger.info( - f"[green]Client started:[/] " - f"{client.me.full_name if client.me else 'unknown'} " - f"{client.me.id if client.me else 'unknown'}" - ) - account_id = await _sync_account(pool, client, session_name) - if account_id is not None: - await _setup_capture(pool, client, account_id, storage) - consumer = JobConsumer(client, pool, account_id) - consumer_tasks.append(asyncio.create_task(consumer.run())) - await _enqueue_sync_dialogs(pool, account_id) - - if clients: - listen_conn = await _listen_changes(clients, reload_tasks) - logger.info("[green]Userbot running.[/]") - await asyncio.Event().wait() + await registry.sync() + if not registry.clients: + logger.warning("[yellow]No sessions yet. Add an account in the web UI.[/]") + listen_conn = await _listen_changes(registry, tasks) + logger.info("[green]Userbot running.[/]") + await asyncio.Event().wait() finally: - for task in consumer_tasks: - task.cancel() - for task in consumer_tasks: - with contextlib.suppress(asyncio.CancelledError): - await task if listen_conn is not None: with contextlib.suppress(Exception): await listen_conn.close() - for client in clients: - with contextlib.suppress(Exception): - await client.stop() + await registry.close() await container.close() diff --git a/backend/src/utils/policy/repository.py b/backend/src/utils/policy/repository.py index 1cb623f..6046bcf 100644 --- a/backend/src/utils/policy/repository.py +++ b/backend/src/utils/policy/repository.py @@ -110,10 +110,24 @@ async def get_policy(pool: asyncpg.Pool, policy_id: int) -> PolicyRecord | None: return PolicyRecord(**dict(row)) if row else None +async def find_policy( + pool: asyncpg.Pool, account_id: int, scope_type: ScopeType, scope_id: int | None +) -> PolicyRecord | None: + row = await pool.fetchrow( + "SELECT * FROM capture_policy WHERE account_id = $1 AND scope_type = $2 " + "AND scope_id IS NOT DISTINCT FROM $3", + account_id, + scope_type.value, + scope_id, + ) + return PolicyRecord(**dict(row)) if row else None + + async def list_policies(pool: asyncpg.Pool, account_id: int) -> list[PolicyRecord]: rows = await pool.fetch( - "SELECT * FROM capture_policy WHERE account_id = $1 OR account_id IS NULL " - "ORDER BY scope_type, scope_id", + "SELECT DISTINCT ON (scope_type, scope_id) * FROM capture_policy " + "WHERE account_id = $1 OR account_id IS NULL " + "ORDER BY scope_type, scope_id, account_id NULLS LAST", account_id, ) return [PolicyRecord(**dict(row)) for row in rows] @@ -131,6 +145,22 @@ async def update_policy( return PolicyRecord(**dict(row)) if row else None +async def override_policy( + pool: asyncpg.Pool, policy_id: int, account_id: int, toggles: CaptureToggles +) -> PolicyRecord | None: + record = await get_policy(pool, policy_id) + if record is None: + return None + if record.account_id == account_id: + return await update_policy(pool, policy_id, toggles) + existing = await find_policy(pool, account_id, record.scope_type, record.scope_id) + if existing is not None: + return await update_policy(pool, existing.id, toggles) + return await create_policy( + pool, account_id, record.scope_type, record.scope_id, toggles + ) + + async def delete_policy(pool: asyncpg.Pool, policy_id: int) -> bool: result = await pool.execute("DELETE FROM capture_policy WHERE id = $1", policy_id) return result.endswith("1") @@ -138,7 +168,8 @@ async def delete_policy(pool: asyncpg.Pool, policy_id: int) -> bool: async def load_policy_set(pool: asyncpg.Pool, account_id: int) -> PolicySet: rows = await pool.fetch( - "SELECT * FROM capture_policy WHERE account_id = $1 OR account_id IS NULL", + "SELECT * FROM capture_policy WHERE account_id = $1 OR account_id IS NULL " + "ORDER BY account_id NULLS FIRST", account_id, ) policies = PolicySet() diff --git a/backend/src/utils/read/accounts.py b/backend/src/utils/read/accounts.py index 365c12c..d691085 100644 --- a/backend/src/utils/read/accounts.py +++ b/backend/src/utils/read/accounts.py @@ -1,7 +1,28 @@ +import json + import asyncpg +from pyrogram.types import User from utils.read.models import AccountView +ACCOUNTS_CHANGED_CHANNEL = "accounts_changed" + +_ACCOUNT_COLS = "account_id, label, phone, tg_user_id, is_active" + +_UPSERT_ACCOUNT = """ +INSERT INTO accounts + (tg_user_id, label, phone, session_name, is_active, raw, updated_at) +VALUES ($1, $2, $3, $4, TRUE, $5::jsonb, now()) +ON CONFLICT (tg_user_id) DO UPDATE SET + label = EXCLUDED.label, + phone = EXCLUDED.phone, + session_name = EXCLUDED.session_name, + is_active = TRUE, + raw = EXCLUDED.raw, + updated_at = now() +RETURNING account_id +""" + async def self_user_id(pool: asyncpg.Pool, account_id: int) -> int | None: return await pool.fetchval( @@ -11,7 +32,47 @@ async def self_user_id(pool: asyncpg.Pool, account_id: int) -> int | None: async def list_accounts(pool: asyncpg.Pool) -> list[AccountView]: rows = await pool.fetch( - "SELECT account_id, label, phone, tg_user_id, is_active FROM accounts " - "ORDER BY account_id" + f"SELECT {_ACCOUNT_COLS} FROM accounts ORDER BY account_id" # noqa: S608 ) return [AccountView(**dict(row)) for row in rows] + + +async def get_account(pool: asyncpg.Pool, account_id: int) -> AccountView | None: + row = await pool.fetchrow( + f"SELECT {_ACCOUNT_COLS} FROM accounts WHERE account_id = $1", # noqa: S608 + account_id, + ) + return AccountView(**dict(row)) if row else None + + +async def sync_account(pool: asyncpg.Pool, me: User, session_name: str) -> int: + raw = json.dumps( + { + "id": me.id, + "first_name": me.first_name, + "last_name": me.last_name, + "username": me.username, + "phone_number": me.phone_number, + } + ) + label = " ".join(filter(None, [me.first_name, me.last_name])) or me.username + return await pool.fetchval( + _UPSERT_ACCOUNT, me.id, label, me.phone_number, session_name, raw + ) + + +async def deactivate_account(pool: asyncpg.Pool, account_id: int) -> str | None: + return await pool.fetchval( + "UPDATE accounts SET is_active = FALSE, updated_at = now() " + "WHERE account_id = $1 RETURNING session_name", + account_id, + ) + + +async def inactive_session_names(pool: asyncpg.Pool) -> set[str]: + rows = await pool.fetch("SELECT session_name FROM accounts WHERE NOT is_active") + return {row["session_name"] for row in rows} + + +async def notify_accounts_changed(pool: asyncpg.Pool) -> None: + await pool.execute("SELECT pg_notify($1, '')", ACCOUNTS_CHANGED_CHANNEL) diff --git a/docker-compose.yml b/docker-compose.yml index ed49374..933830b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,7 @@ services: restart: unless-stopped volumes: - ./backend/src:/app/src + - ./backend/sessions:/app/sessions - ./frontend/build:/app/static:ro - ${STORAGE__ROOT:-./storage}:/app/storage depends_on: diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 25162ab..1cb3d30 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -14,6 +14,7 @@ import type { JobStatus, JobView, LinkView, + LoginState, MediaVersion, MediaView, MessageAt, @@ -45,6 +46,41 @@ export function listAccounts(): Promise { return request("/accounts"); } +export function startLogin(phone: string): Promise { + return request("/accounts/login", { + method: "POST", + body: { phone }, + }); +} + +export function submitLoginCode( + loginId: string, + code: string +): Promise { + return request(`/accounts/login/${loginId}/code`, { + method: "POST", + body: { code }, + }); +} + +export function submitLoginPassword( + loginId: string, + password: string +): Promise { + return request(`/accounts/login/${loginId}/password`, { + method: "POST", + body: { password }, + }); +} + +export function cancelLogin(loginId: string): Promise { + return request(`/accounts/login/${loginId}`, { method: "DELETE" }); +} + +export function logoutAccount(accountId: number): Promise { + return request(`/accounts/${accountId}`, { method: "DELETE" }); +} + export function listChats(page: Page = {}): Promise { return request("/chats", { account: true, query: { ...page } }); } @@ -70,6 +106,7 @@ export function updatePolicy( ): Promise { return request(`/policy/${id}`, { method: "PUT", + account: true, body: toggles, }); } diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index f626aac..280440c 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -22,6 +22,14 @@ export interface Account { tg_user_id: number | null; } +export type LoginStage = "code" | "password" | "done"; + +export interface LoginState { + account: Account | null; + login_id: string; + stage: LoginStage; +} + export interface Chat { chat_id: number; has_avatar: boolean; diff --git a/frontend/src/lib/components/AccountSwitcher.svelte b/frontend/src/lib/components/AccountSwitcher.svelte deleted file mode 100644 index 14f715b..0000000 --- a/frontend/src/lib/components/AccountSwitcher.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - {#each accounts.list as account (account.account_id)} - accounts.select(account.account_id)} - > - - {accountName(account)} - {#if account.account_id === accounts.selectedId} - - {/if} - - {/each} - - - - - diff --git a/frontend/src/lib/components/MessageVersions.svelte b/frontend/src/lib/components/MessageVersions.svelte index 008cf0a..c497269 100644 --- a/frontend/src/lib/components/MessageVersions.svelte +++ b/frontend/src/lib/components/MessageVersions.svelte @@ -85,61 +85,6 @@ diff --git a/frontend/src/lib/components/settings/MyAccount.svelte b/frontend/src/lib/components/settings/MyAccount.svelte index 99ce993..efda58c 100644 --- a/frontend/src/lib/components/settings/MyAccount.svelte +++ b/frontend/src/lib/components/settings/MyAccount.svelte @@ -1,9 +1,14 @@ {/if} @@ -43,13 +71,72 @@ size={2.25} /> {accountName(account)} + {#if !account.is_active} + архив + {/if} {/each} {/if} + +
+ { + adding = true; + }} + /> + {#if current?.is_active} + { + confirming = true; + }} + /> + {/if} +
+ + + + + + +
+ Выйти из аккаунта? + + + +
+
+

+ Сессия {current ? accountName(current) : ""} будет завершена в + Telegram, новые сообщения перестанут собираться. Уже собранный архив + останется доступным. +

+
+
+ + +
+
+
+
+