feat(api,userbot,frontend): add accounts from the web ui and isolate per-account settings

This commit is contained in:
hh
2026-08-05 23:49:52 +02:00
parent 92fd20137e
commit 9c265af3d3
19 changed files with 917 additions and 268 deletions
@@ -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_%'"
)
+108
View File
@@ -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()
+97 -1
View File
@@ -1,13 +1,109 @@
from collections.abc import Coroutine
from typing import Any, Literal
import asyncpg import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka 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 import accounts
from utils.read.models import AccountView from utils.read.models import AccountView
router = APIRouter(prefix="/api", tags=["accounts"], route_class=DishkaRoute) 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") @router.get("/accounts")
async def list_accounts(pool: FromDishka[asyncpg.Pool]) -> list[AccountView]: async def list_accounts(pool: FromDishka[asyncpg.Pool]) -> list[AccountView]:
return await accounts.list_accounts(pool) 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)
+7 -1
View File
@@ -71,9 +71,15 @@ async def get_policy(pool: FromDishka[asyncpg.Pool], policy_id: int) -> PolicyRe
@router.put("/{policy_id}") @router.put("/{policy_id}")
async def update_policy( 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: ) -> PolicyRecord:
if account_id is None:
record = await repository.update_policy(pool, policy_id, body) 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: if record is None:
raise HTTPException(status_code=404, detail="policy not found") raise HTTPException(status_code=404, detail="policy not found")
await pool.execute(f"NOTIFY {POLICY_CHANGED_CHANNEL}") await pool.execute(f"NOTIFY {POLICY_CHANGED_CHANNEL}")
+128 -97
View File
@@ -1,7 +1,7 @@
import asyncio import asyncio
import contextlib import contextlib
import json
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -15,87 +15,144 @@ from userbot.modules.jobs import JobConsumer
from utils.env import env from utils.env import env
from utils.jobs import enqueue from utils.jobs import enqueue
from utils.logging import logger, setup_logging 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.read.watches import WATCHES_CHANGED_CHANNEL
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
setup_logging() setup_logging()
_UPSERT_ACCOUNT = """
INSERT INTO accounts @dataclass
(tg_user_id, label, phone, session_name, is_active, raw, updated_at) class RunningAccount:
VALUES ($1, $2, $3, $4, TRUE, $5::jsonb, now()) client: PyroClient
ON CONFLICT (tg_user_id) DO UPDATE SET consumer_task: asyncio.Task
label = EXCLUDED.label,
phone = EXCLUDED.phone,
session_name = EXCLUDED.session_name,
is_active = TRUE,
raw = EXCLUDED.raw,
updated_at = now()
RETURNING account_id
"""
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) sessions_dir.mkdir(parents=True, exist_ok=True)
return sorted(sessions_dir.glob("*.session")) return sessions_dir
async def _sync_account( async def _cancel(task: asyncio.Task) -> None:
pool: asyncpg.Pool, client: PyroClient, session_name: str task.cancel()
) -> int | None: with contextlib.suppress(asyncio.CancelledError):
me = client.me await task
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 _setup_capture( async def _enqueue_once(pool: asyncpg.Pool, account_id: int, kind: str) -> None:
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:
existing = await pool.fetchval( 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", "AND status IN ('pending', 'running') LIMIT 1",
account_id, account_id,
kind,
) )
if existing is None: if existing is None:
await enqueue(pool, account_id, "sync_dialogs", {}) await enqueue(pool, account_id, kind, {})
logger.info("[green]Queued sync_dialogs.[/]") 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( async def _listen_changes(
clients: list[PyroClient], tasks: set[asyncio.Task] registry: AccountRegistry, tasks: set[asyncio.Task]
) -> asyncpg.Connection: ) -> 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( def reload(
make_coro: Callable[[CaptureContext], Coroutine[Any, Any, None]], make_coro: Callable[[CaptureContext], Coroutine[Any, Any, None]],
) -> None: ) -> None:
for client in clients: for client in registry.clients:
if client.capture is None: if client.capture is not None:
continue spawn(make_coro(client.capture))
task = asyncio.create_task(make_coro(client.capture))
tasks.add(task)
task.add_done_callback(tasks.discard)
def on_policy( def on_policy(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str _conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
@@ -107,9 +164,15 @@ async def _listen_changes(
) -> None: ) -> None:
reload(lambda capture: capture.watches.refresh()) 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) conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener("policy_changed", on_policy) await conn.add_listener("policy_changed", on_policy)
await conn.add_listener(WATCHES_CHANGED_CHANNEL, on_watch) await conn.add_listener(WATCHES_CHANGED_CHANNEL, on_watch)
await conn.add_listener(ACCOUNTS_CHANGED_CHANNEL, on_accounts)
return conn return conn
@@ -117,53 +180,21 @@ async def runner() -> None:
pool = await container.get(asyncpg.Pool) pool = await container.get(asyncpg.Pool)
storage = await container.get(ContentAddressedStorage) storage = await container.get(ContentAddressedStorage)
sessions_dir = Path(env.tg.sessions_dir) registry = AccountRegistry(pool, storage)
session_files = _discover_sessions(sessions_dir) tasks: set[asyncio.Task] = set()
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] = []
listen_conn: asyncpg.Connection | None = None listen_conn: asyncpg.Connection | None = None
try: try:
for session_path in session_files: await registry.sync()
session_name = session_path.stem if not registry.clients:
client = PyroClient(session_name, workdir=str(sessions_dir)) logger.warning("[yellow]No sessions yet. Add an account in the web UI.[/]")
await client.start() listen_conn = await _listen_changes(registry, tasks)
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.[/]") logger.info("[green]Userbot running.[/]")
await asyncio.Event().wait() await asyncio.Event().wait()
finally: 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: if listen_conn is not None:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await listen_conn.close() await listen_conn.close()
for client in clients: await registry.close()
with contextlib.suppress(Exception):
await client.stop()
await container.close() await container.close()
+34 -3
View File
@@ -110,10 +110,24 @@ async def get_policy(pool: asyncpg.Pool, policy_id: int) -> PolicyRecord | None:
return PolicyRecord(**dict(row)) if row else 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]: async def list_policies(pool: asyncpg.Pool, account_id: int) -> list[PolicyRecord]:
rows = await pool.fetch( rows = await pool.fetch(
"SELECT * FROM capture_policy WHERE account_id = $1 OR account_id IS NULL " "SELECT DISTINCT ON (scope_type, scope_id) * FROM capture_policy "
"ORDER BY scope_type, scope_id", "WHERE account_id = $1 OR account_id IS NULL "
"ORDER BY scope_type, scope_id, account_id NULLS LAST",
account_id, account_id,
) )
return [PolicyRecord(**dict(row)) for row in rows] return [PolicyRecord(**dict(row)) for row in rows]
@@ -131,6 +145,22 @@ async def update_policy(
return PolicyRecord(**dict(row)) if row else None 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: async def delete_policy(pool: asyncpg.Pool, policy_id: int) -> bool:
result = await pool.execute("DELETE FROM capture_policy WHERE id = $1", policy_id) result = await pool.execute("DELETE FROM capture_policy WHERE id = $1", policy_id)
return result.endswith("1") 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: async def load_policy_set(pool: asyncpg.Pool, account_id: int) -> PolicySet:
rows = await pool.fetch( 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, account_id,
) )
policies = PolicySet() policies = PolicySet()
+63 -2
View File
@@ -1,7 +1,28 @@
import json
import asyncpg import asyncpg
from pyrogram.types import User
from utils.read.models import AccountView 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: async def self_user_id(pool: asyncpg.Pool, account_id: int) -> int | None:
return await pool.fetchval( 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]: async def list_accounts(pool: asyncpg.Pool) -> list[AccountView]:
rows = await pool.fetch( rows = await pool.fetch(
"SELECT account_id, label, phone, tg_user_id, is_active FROM accounts " f"SELECT {_ACCOUNT_COLS} FROM accounts ORDER BY account_id" # noqa: S608
"ORDER BY account_id"
) )
return [AccountView(**dict(row)) for row in rows] 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)
+1
View File
@@ -52,6 +52,7 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./backend/src:/app/src - ./backend/src:/app/src
- ./backend/sessions:/app/sessions
- ./frontend/build:/app/static:ro - ./frontend/build:/app/static:ro
- ${STORAGE__ROOT:-./storage}:/app/storage - ${STORAGE__ROOT:-./storage}:/app/storage
depends_on: depends_on:
+37
View File
@@ -14,6 +14,7 @@ import type {
JobStatus, JobStatus,
JobView, JobView,
LinkView, LinkView,
LoginState,
MediaVersion, MediaVersion,
MediaView, MediaView,
MessageAt, MessageAt,
@@ -45,6 +46,41 @@ export function listAccounts(): Promise<Account[]> {
return request<Account[]>("/accounts"); return request<Account[]>("/accounts");
} }
export function startLogin(phone: string): Promise<LoginState> {
return request<LoginState>("/accounts/login", {
method: "POST",
body: { phone },
});
}
export function submitLoginCode(
loginId: string,
code: string
): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/code`, {
method: "POST",
body: { code },
});
}
export function submitLoginPassword(
loginId: string,
password: string
): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/password`, {
method: "POST",
body: { password },
});
}
export function cancelLogin(loginId: string): Promise<void> {
return request<void>(`/accounts/login/${loginId}`, { method: "DELETE" });
}
export function logoutAccount(accountId: number): Promise<void> {
return request<void>(`/accounts/${accountId}`, { method: "DELETE" });
}
export function listChats(page: Page = {}): Promise<Chat[]> { export function listChats(page: Page = {}): Promise<Chat[]> {
return request<Chat[]>("/chats", { account: true, query: { ...page } }); return request<Chat[]>("/chats", { account: true, query: { ...page } });
} }
@@ -70,6 +106,7 @@ export function updatePolicy(
): Promise<PolicyRecord> { ): Promise<PolicyRecord> {
return request<PolicyRecord>(`/policy/${id}`, { return request<PolicyRecord>(`/policy/${id}`, {
method: "PUT", method: "PUT",
account: true,
body: toggles, body: toggles,
}); });
} }
+8
View File
@@ -22,6 +22,14 @@ export interface Account {
tg_user_id: number | null; 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 { export interface Chat {
chat_id: number; chat_id: number;
has_avatar: boolean; has_avatar: boolean;
@@ -1,87 +0,0 @@
<script lang="ts">
import { DropdownMenu } from "bits-ui";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accountName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
const current = $derived(accounts.selected);
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="account-trigger">
{#if current}
<Avatar
name={accountName(current)}
colorKey={current.account_id}
size={2.25}
/>
<span class="account-name">{accountName(current)}</span>
{:else}
<span class="account-name">No account</span>
{/if}
<Icon name="down" size="1.25rem" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="bg-menu-content" sideOffset={6} align="start">
{#each accounts.list as account (account.account_id)}
<DropdownMenu.Item
class="bg-menu-item"
data-selected={account.account_id === accounts.selectedId
? ""
: undefined}
onSelect={() => accounts.select(account.account_id)}
>
<Avatar
name={accountName(account)}
colorKey={account.account_id}
size={1.75}
/>
<span>{accountName(account)}</span>
{#if account.account_id === accounts.selectedId}
<Icon name="check" size="1.125rem" class="trailing" />
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<style lang="scss">
:global(.account-trigger) {
cursor: pointer;
display: flex;
flex: 1;
align-items: center;
gap: 0.625rem;
min-width: 0;
padding: 0.375rem 0.5rem;
border: 0;
border-radius: 0.625rem;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--color-chat-hover);
}
}
.account-name {
overflow: hidden;
flex: 1;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.bg-menu-item .trailing) {
margin-inline-start: auto;
color: var(--color-primary);
}
</style>
@@ -85,61 +85,6 @@
</Dialog.Root> </Dialog.Root>
<style lang="scss"> <style lang="scss">
:global(.dialog-overlay) {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background-color: rgba(0, 0, 0, 0.5);
}
:global(.dialog-content) {
position: fixed;
z-index: var(--z-modal);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
width: min(32rem, 92vw);
max-height: 80vh;
border-radius: var(--border-radius-default);
background-color: var(--color-background);
box-shadow: 0 0.5rem 2rem var(--color-default-shadow);
outline: none;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-borders);
}
:global(.dialog-title) {
margin: 0;
font-size: 1.125rem;
font-weight: var(--font-weight-medium);
}
:global(.dialog-close) {
cursor: pointer;
display: flex;
padding: 0.375rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.versions { .versions {
overflow-y: auto; overflow-y: auto;
padding: 0.75rem 1.25rem 1.25rem; padding: 0.75rem 1.25rem 1.25rem;
@@ -3,6 +3,7 @@
import type { JobStatus, JobView } from "$lib/api/types"; import type { JobStatus, JobView } from "$lib/api/types";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime"; import { formatFull } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
interface Props { interface Props {
@@ -93,7 +94,7 @@
} }
$effect(() => { $effect(() => {
if (version >= 0) { if (version >= 0 && accounts.selectedId !== null) {
load().catch(() => { load().catch(() => {
loading = false; loading = false;
}); });
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { import {
createPolicy, createPolicy,
deletePolicy, deletePolicy,
@@ -16,6 +15,7 @@
import CaptureToggleList from "$lib/components/policy/CaptureToggleList.svelte"; import CaptureToggleList from "$lib/components/policy/CaptureToggleList.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte"; import { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
@@ -110,7 +110,8 @@
p.id === record.id ? { ...p, [key]: value } : p p.id === record.id ? { ...p, [key]: value } : p
); );
try { try {
await updatePolicy(record.id, next); const saved = await updatePolicy(record.id, next);
policies = policies.map((p) => (p.id === record.id ? saved : p));
} catch { } catch {
toasts.error("Не удалось сохранить политику"); toasts.error("Не удалось сохранить политику");
await reload(); await reload();
@@ -143,7 +144,8 @@
} }
} }
onMount(async () => { async function load(_account: number | null) {
loading = true;
chats.load(); chats.load();
try { try {
await reload(); await reload();
@@ -152,6 +154,10 @@
} finally { } finally {
loading = false; loading = false;
} }
}
$effect(() => {
load(accounts.selectedId);
}); });
</script> </script>
@@ -159,7 +165,10 @@
<div class="card"> <div class="card">
<div class="card-head"> <div class="card-head">
<span class="card-title">{title}</span> <span class="card-title">{title}</span>
{#if onremove} {#if record.account_id === null}
<span class="shared">для всех аккаунтов</span>
{/if}
{#if onremove && record.account_id !== null}
<button <button
type="button" type="button"
class="remove" class="remove"
@@ -303,6 +312,11 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
.shared {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.remove { .remove {
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@@ -0,0 +1,169 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { ApiError } from "$lib/api/client";
import {
cancelLogin,
startLogin,
submitLoginCode,
submitLoginPassword,
} from "$lib/api/endpoints";
import type { LoginState } from "$lib/api/types";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
type Step = "phone" | "code" | "password";
let { open = $bindable(false) }: { open?: boolean } = $props();
let step = $state<Step>("phone");
let loginId = $state("");
let phone = $state("");
let code = $state("");
let password = $state("");
let busy = $state(false);
const hints: Record<Step, string> = {
phone: "Номер телефона в международном формате, например +79991234567.",
code: "Код отправлен в Telegram на этот номер.",
password: "Аккаунт защищён двухэтапной аутентификацией.",
};
const filled = $derived.by(() => {
if (step === "phone") {
return phone.trim().length > 0;
}
if (step === "code") {
return code.trim().length > 0;
}
return password.length > 0;
});
function reset() {
step = "phone";
loginId = "";
phone = "";
code = "";
password = "";
}
function next(): Promise<LoginState> {
if (step === "phone") {
return startLogin(phone.trim());
}
if (step === "code") {
return submitLoginCode(loginId, code.trim());
}
return submitLoginPassword(loginId, password);
}
async function apply(state: LoginState) {
if (state.stage !== "done") {
loginId = state.login_id;
step = state.stage;
return;
}
loginId = "";
await accounts.load();
if (state.account) {
accounts.select(state.account.account_id);
}
toasts.success("Аккаунт добавлен");
open = false;
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || !filled) {
return;
}
busy = true;
try {
await apply(await next());
} catch (error) {
toasts.error(
error instanceof ApiError ? error.detail : "Не удалось войти"
);
} finally {
busy = false;
}
}
function onOpenChange(value: boolean) {
if (!value && loginId) {
cancelLogin(loginId).catch(() => undefined);
}
reset();
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Добавить аккаунт</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<form onsubmit={submit}>
<div class="dialog-body">
<p class="hint">{hints[step]}</p>
{#if step === "phone"}
<div class="input-group">
<input
id="login-phone"
class="form-control"
type="tel"
autocomplete="tel"
placeholder="+7 999 123-45-67"
bind:value={phone}
>
<label for="login-phone">Номер телефона</label>
</div>
{:else if step === "code"}
<div class="input-group">
<input
id="login-code"
class="form-control"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="12345"
bind:value={code}
>
<label for="login-code">Код подтверждения</label>
</div>
{:else}
<div class="input-group">
<input
id="login-password"
class="form-control"
type="password"
autocomplete="current-password"
placeholder="Пароль"
bind:value={password}
>
<label for="login-password">Облачный пароль</label>
</div>
{/if}
</div>
<div class="dialog-actions">
<Button type="submit" pill loading={busy} disabled={busy || !filled}>
{step === "phone" ? "Отправить код" : "Продолжить"}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.hint {
margin: 0 0 1.5rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
</style>
@@ -1,9 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Dialog } from "bits-ui";
import { ripple } from "$lib/actions/ripple"; import { ripple } from "$lib/actions/ripple";
import AddAccountDialog from "$lib/components/settings/AddAccountDialog.svelte";
import SettingsItem from "$lib/components/settings/SettingsItem.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte"; import Avatar from "$lib/components/ui/Avatar.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { accountName } from "$lib/format/peer"; import { accountName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
const current = $derived(accounts.selected); const current = $derived(accounts.selected);
const others = $derived( const others = $derived(
@@ -11,6 +16,26 @@
(account) => account.account_id !== accounts.selectedId (account) => account.account_id !== accounts.selectedId
) )
); );
let adding = $state(false);
let confirming = $state(false);
let busy = $state(false);
async function logout() {
if (!current || busy) {
return;
}
busy = true;
try {
await accounts.logout(current.account_id);
confirming = false;
toasts.success("Сессия завершена");
} catch {
toasts.error("Не удалось выйти из аккаунта");
} finally {
busy = false;
}
}
</script> </script>
<div class="my-account"> <div class="my-account">
@@ -25,6 +50,9 @@
{#if current.phone} {#if current.phone}
<div class="phone">+{current.phone}</div> <div class="phone">+{current.phone}</div>
{/if} {/if}
{#if !current.is_active}
<div class="inactive">Сессия завершена, доступен только архив</div>
{/if}
</div> </div>
{/if} {/if}
@@ -43,13 +71,72 @@
size={2.25} size={2.25}
/> />
<span>{accountName(account)}</span> <span>{accountName(account)}</span>
{#if !account.is_active}
<span class="tag">архив</span>
{/if}
<Icon name="arrow-right" size="1rem" class="chevron" /> <Icon name="arrow-right" size="1rem" class="chevron" />
</button> </button>
{/each} {/each}
</div> </div>
{/if} {/if}
<div class="actions">
<SettingsItem
icon="add-user"
label="Добавить аккаунт"
onclick={() => {
adding = true;
}}
/>
{#if current?.is_active}
<SettingsItem
icon="logout"
label="Выйти из аккаунта"
onclick={() => {
confirming = true;
}}
/>
{/if}
</div>
</div> </div>
<AddAccountDialog bind:open={adding} />
<Dialog.Root bind:open={confirming}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Выйти из аккаунта?</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
<p class="confirm-text">
Сессия {current ? accountName(current) : ""} будет завершена в
Telegram, новые сообщения перестанут собираться. Уже собранный архив
останется доступным.
</p>
</div>
<div class="dialog-actions">
<Button
variant="text"
pill
onclick={() => {
confirming = false;
}}
>
Отмена
</Button>
<Button variant="danger" pill loading={busy} onclick={logout}>
Выйти
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss"> <style lang="scss">
.profile { .profile {
display: flex; display: flex;
@@ -71,6 +158,11 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.inactive {
font-size: 0.8125rem;
color: var(--color-error);
}
.switch-row { .switch-row {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
@@ -99,6 +191,22 @@
} }
} }
.tag {
flex: 0 0 auto !important;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.actions {
padding: 0.5rem 0;
border-top: 1px solid var(--color-borders);
}
.confirm-text {
margin: 0;
color: var(--color-text-secondary);
}
:global(.switch-row .chevron) { :global(.switch-row .chevron) {
flex-shrink: 0; flex-shrink: 0;
color: var(--color-icon-secondary); color: var(--color-icon-secondary);
+24 -14
View File
@@ -1,5 +1,5 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { listAccounts } from "$lib/api/endpoints"; import { listAccounts, logoutAccount } from "$lib/api/endpoints";
import type { Account } from "$lib/api/types"; import type { Account } from "$lib/api/types";
const STORAGE_KEY = "bg.account"; const STORAGE_KEY = "bg.account";
@@ -32,6 +32,22 @@ function createAccounts() {
} }
} }
function select(id: number | null) {
selectedId = id;
persist(id);
}
async function load() {
list = await listAccounts();
loaded = true;
const exists = list.some((account) => account.account_id === selectedId);
if (!exists) {
const fallback =
list.find((account) => account.is_active) ?? list.at(0) ?? null;
select(fallback ? fallback.account_id : null);
}
}
return { return {
get list() { get list() {
return list; return list;
@@ -45,20 +61,14 @@ function createAccounts() {
get loaded() { get loaded() {
return loaded; return loaded;
}, },
async load() { load,
list = await listAccounts(); select,
loaded = true; async logout(id: number) {
const exists = list.some((account) => account.account_id === selectedId); await logoutAccount(id);
if (!exists) { if (id === selectedId) {
const fallback = select(null);
list.find((account) => account.is_active) ?? list.at(0) ?? null;
selectedId = fallback ? fallback.account_id : null;
persist(selectedId);
} }
}, await load();
select(id: number) {
selectedId = id;
persist(id);
}, },
}; };
} }
+66
View File
@@ -0,0 +1,66 @@
.dialog-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background-color: rgba(0, 0, 0, 0.5);
}
.dialog-content {
position: fixed;
z-index: var(--z-modal);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
width: min(32rem, 92vw);
max-height: 80vh;
border-radius: var(--border-radius-default);
background-color: var(--color-background);
box-shadow: 0 0.5rem 2rem var(--color-default-shadow);
outline: none;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-borders);
}
.dialog-title {
margin: 0;
font-size: 1.125rem;
font-weight: var(--font-weight-medium);
}
.dialog-close {
cursor: pointer;
display: flex;
padding: 0.375rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.dialog-body {
overflow-y: auto;
padding: 1.25rem;
}
.dialog-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
padding: 0 1.25rem 1.25rem;
}
+1
View File
@@ -1,6 +1,7 @@
@use "variables"; @use "variables";
@use "spacing"; @use "spacing";
@use "forms"; @use "forms";
@use "dialogs";
@use "dark-theme"; @use "dark-theme";
html, html,