feat(api,userbot,frontend): rename session device from the web ui

This commit is contained in:
hh
2026-08-06 00:51:58 +02:00
parent b1848a6620
commit 1898a51a9d
11 changed files with 207 additions and 13 deletions
@@ -0,0 +1,24 @@
"""account device model
Revision ID: b9e4d1a70c26
Revises: e7b4c2a9f861
Create Date: 2026-08-06 01:20:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "b9e4d1a70c26"
down_revision: str | None = "e7b4c2a9f861"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE accounts ADD COLUMN device_model text")
def downgrade() -> None:
op.execute("ALTER TABLE accounts DROP COLUMN device_model")
+22
View File
@@ -9,6 +9,7 @@ from pyrogram.errors import FloodWait, RPCError
from pyrogram.types import User from pyrogram.types import User
from api.login import LoginError, login_manager from api.login import LoginError, login_manager
from userbot import DEVICE_MODEL_LIMIT
from utils.read import accounts from utils.read import accounts
from utils.read.models import AccountView from utils.read.models import AccountView
@@ -35,6 +36,10 @@ class PasswordRequest(BaseModel):
password: str password: str
class DeviceModelRequest(BaseModel):
device_model: str
class LoginState(BaseModel): class LoginState(BaseModel):
login_id: str login_id: str
stage: Literal["code", "qr", "password", "done"] stage: Literal["code", "qr", "password", "done"]
@@ -121,6 +126,23 @@ async def cancel_login(login_id: str) -> None:
await login_manager.cancel(login_id) await login_manager.cancel(login_id)
@router.patch("/accounts/{account_id}/device")
async def rename_device(
account_id: int, body: DeviceModelRequest, pool: FromDishka[asyncpg.Pool]
) -> AccountView:
device_model = " ".join(body.device_model.split())
if not device_model or len(device_model) > DEVICE_MODEL_LIMIT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Имя устройства: от 1 до {DEVICE_MODEL_LIMIT} символов",
)
account = await accounts.set_device_model(pool, account_id, device_model)
if account is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Аккаунт не найден")
await accounts.notify_accounts_changed(pool)
return account
@router.delete("/accounts/{account_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/accounts/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
async def logout_account(account_id: int, pool: FromDishka[asyncpg.Pool]) -> None: async def logout_account(account_id: int, pool: FromDishka[asyncpg.Pool]) -> None:
if await accounts.deactivate_account(pool, account_id) is None: if await accounts.deactivate_account(pool, account_id) is None:
+2 -2
View File
@@ -1,3 +1,3 @@
from userbot.modules.client import PyroClient from userbot.modules.client import DEVICE_MODEL, DEVICE_MODEL_LIMIT, PyroClient
__all__ = ["PyroClient"] __all__ = ["DEVICE_MODEL", "DEVICE_MODEL_LIMIT", "PyroClient"]
+15 -4
View File
@@ -6,19 +6,30 @@ if TYPE_CHECKING:
from userbot.modules.capture import CaptureContext from userbot.modules.capture import CaptureContext
DEVICE_MODEL = "Beavergram"
DEVICE_MODEL_LIMIT = 32
class PyroClient(Client): class PyroClient(Client):
def __init__( def __init__(
self, name: str, *, workdir: str = "sessions", load_handlers: bool = True self,
name: str,
*,
workdir: str = "sessions",
device_model: str | None = None,
load_handlers: bool = True,
) -> None: ) -> None:
super().__init__( super().__init__(
name, name,
workdir=workdir, workdir=workdir,
api_id=2040, api_id=2040,
api_hash="b18441a1ff607e10a989891a5462e627", api_hash="b18441a1ff607e10a989891a5462e627",
device_model="Desktop", device_model=device_model or DEVICE_MODEL,
system_version="Windows 11 x64", system_version="Windows 11 x64",
app_version="6.7.8 x64", app_version="7.0.8 x64",
lang_pack="tdesktop", lang_pack="tdesktop",
lang_code="en",
system_lang_code="en-US",
client_platform=enums.ClientPlatform.DESKTOP, client_platform=enums.ClientPlatform.DESKTOP,
) )
self.capture: CaptureContext | None = None self.capture: CaptureContext | None = None
@@ -30,4 +41,4 @@ class PyroClient(Client):
self.add_handler(*handler) self.add_handler(*handler)
__all__ = ["PyroClient"] __all__ = ["DEVICE_MODEL", "DEVICE_MODEL_LIMIT", "PyroClient"]
+15 -5
View File
@@ -18,6 +18,7 @@ from utils.logging import logger, setup_logging
from utils.read.accounts import ( from utils.read.accounts import (
ACCOUNTS_CHANGED_CHANNEL, ACCOUNTS_CHANGED_CHANNEL,
inactive_session_names, inactive_session_names,
session_device_models,
sync_account, sync_account,
) )
from utils.read.watches import WATCHES_CHANGED_CHANNEL from utils.read.watches import WATCHES_CHANGED_CHANNEL
@@ -30,6 +31,7 @@ setup_logging()
class RunningAccount: class RunningAccount:
client: PyroClient client: PyroClient
consumer_task: asyncio.Task consumer_task: asyncio.Task
device_model: str | None
def _sessions_dir() -> Path: def _sessions_dir() -> Path:
@@ -70,14 +72,20 @@ class AccountRegistry:
async def sync(self) -> None: async def sync(self) -> None:
async with self._lock: async with self._lock:
inactive = await inactive_session_names(self._pool) inactive = await inactive_session_names(self._pool)
models = await session_device_models(self._pool)
present: set[str] = set() present: set[str] = set()
for path in sorted(_sessions_dir().glob("*.session")): for path in sorted(_sessions_dir().glob("*.session")):
if path.stem in inactive: if path.stem in inactive:
await self._log_out(path) await self._log_out(path)
continue continue
present.add(path.stem) present.add(path.stem)
if path.stem not in self._running: device_model = models.get(path.stem)
await self._start(path) running = self._running.get(path.stem)
if running is not None and running.device_model != device_model:
await self._stop(path.stem)
running = None
if running is None:
await self._start(path, device_model)
for session_name in set(self._running) - present: for session_name in set(self._running) - present:
await self._stop(session_name) await self._stop(session_name)
@@ -85,9 +93,11 @@ class AccountRegistry:
for session_name in list(self._running): for session_name in list(self._running):
await self._stop(session_name) await self._stop(session_name)
async def _start(self, path: Path) -> None: async def _start(self, path: Path, device_model: str | None) -> None:
session_name = path.stem session_name = path.stem
client = PyroClient(session_name, workdir=str(path.parent)) client = PyroClient(
session_name, workdir=str(path.parent), device_model=device_model
)
try: try:
await client.start() await client.start()
me = client.me me = client.me
@@ -105,7 +115,7 @@ class AccountRegistry:
return return
consumer = JobConsumer(client, self._pool, account_id) consumer = JobConsumer(client, self._pool, account_id)
self._running[session_name] = RunningAccount( self._running[session_name] = RunningAccount(
client, asyncio.create_task(consumer.run()) client, asyncio.create_task(consumer.run()), device_model
) )
logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})") 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_dialogs")
+21 -1
View File
@@ -7,7 +7,7 @@ from utils.read.models import AccountView
ACCOUNTS_CHANGED_CHANNEL = "accounts_changed" ACCOUNTS_CHANGED_CHANNEL = "accounts_changed"
_ACCOUNT_COLS = "account_id, label, phone, tg_user_id, is_active" _ACCOUNT_COLS = "account_id, label, phone, tg_user_id, is_active, device_model"
_UPSERT_ACCOUNT = """ _UPSERT_ACCOUNT = """
INSERT INTO accounts INSERT INTO accounts
@@ -23,6 +23,12 @@ ON CONFLICT (tg_user_id) DO UPDATE SET
RETURNING account_id RETURNING account_id
""" """
_SET_DEVICE_MODEL = f"""
UPDATE accounts SET device_model = $2, updated_at = now()
WHERE account_id = $1
RETURNING {_ACCOUNT_COLS}
""" # noqa: S608
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(
@@ -61,6 +67,20 @@ async def sync_account(pool: asyncpg.Pool, me: User, session_name: str) -> int:
) )
async def set_device_model(
pool: asyncpg.Pool, account_id: int, device_model: str
) -> AccountView | None:
row = await pool.fetchrow(_SET_DEVICE_MODEL, account_id, device_model)
return AccountView(**dict(row)) if row else None
async def session_device_models(pool: asyncpg.Pool) -> dict[str, str]:
rows = await pool.fetch(
"SELECT session_name, device_model FROM accounts WHERE device_model IS NOT NULL"
)
return {row["session_name"]: row["device_model"] for row in rows}
async def deactivate_account(pool: asyncpg.Pool, account_id: int) -> str | None: async def deactivate_account(pool: asyncpg.Pool, account_id: int) -> str | None:
return await pool.fetchval( return await pool.fetchval(
"UPDATE accounts SET is_active = FALSE, updated_at = now() " "UPDATE accounts SET is_active = FALSE, updated_at = now() "
+1
View File
@@ -22,6 +22,7 @@ class AccountView(BaseModel):
phone: str | None phone: str | None
tg_user_id: int | None tg_user_id: int | None
is_active: bool is_active: bool
device_model: str | None
class ChatListItem(BaseModel): class ChatListItem(BaseModel):
+10
View File
@@ -85,6 +85,16 @@ export function cancelLogin(loginId: string): Promise<void> {
return request<void>(`/accounts/login/${loginId}`, { method: "DELETE" }); return request<void>(`/accounts/login/${loginId}`, { method: "DELETE" });
} }
export function renameAccountDevice(
accountId: number,
deviceModel: string
): Promise<Account> {
return request<Account>(`/accounts/${accountId}/device`, {
method: "PATCH",
body: { device_model: deviceModel },
});
}
export function logoutAccount(accountId: number): Promise<void> { export function logoutAccount(accountId: number): Promise<void> {
return request<void>(`/accounts/${accountId}`, { method: "DELETE" }); return request<void>(`/accounts/${accountId}`, { method: "DELETE" });
} }
+1
View File
@@ -16,6 +16,7 @@ export type JobStatus =
export interface Account { export interface Account {
account_id: number; account_id: number;
device_model: string | null;
is_active: boolean; is_active: boolean;
label: string | null; label: string | null;
phone: string | null; phone: string | null;
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Dialog } from "bits-ui"; import { Dialog } from "bits-ui";
import { ripple } from "$lib/actions/ripple"; import { ripple } from "$lib/actions/ripple";
import { ApiError } from "$lib/api/client";
import AddAccountDialog from "$lib/components/settings/AddAccountDialog.svelte"; import AddAccountDialog from "$lib/components/settings/AddAccountDialog.svelte";
import SettingsItem from "$lib/components/settings/SettingsItem.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";
@@ -17,10 +18,42 @@
) )
); );
const DEVICE_LIMIT = 32;
const DEFAULT_DEVICE = "Beavergram";
let adding = $state(false); let adding = $state(false);
let confirming = $state(false); let confirming = $state(false);
let renaming = $state(false);
let deviceName = $state("");
let busy = $state(false); let busy = $state(false);
const deviceLabel = $derived(current?.device_model ?? DEFAULT_DEVICE);
function openRename() {
deviceName = deviceLabel;
renaming = true;
}
async function rename(event: SubmitEvent) {
event.preventDefault();
const value = deviceName.trim();
if (!(current && value) || busy) {
return;
}
busy = true;
try {
await accounts.renameDevice(current.account_id, value);
renaming = false;
toasts.success("Имя устройства обновлено");
} catch (error) {
toasts.error(
error instanceof ApiError ? error.detail : "Не удалось переименовать"
);
} finally {
busy = false;
}
}
async function logout() { async function logout() {
if (!current || busy) { if (!current || busy) {
return; return;
@@ -81,6 +114,14 @@
{/if} {/if}
<div class="actions"> <div class="actions">
{#if current?.is_active}
<SettingsItem
icon="active-sessions"
label="Имя устройства"
value={deviceLabel}
onclick={openRename}
/>
{/if}
<SettingsItem <SettingsItem
icon="add-user" icon="add-user"
label="Добавить аккаунт" label="Добавить аккаунт"
@@ -102,6 +143,50 @@
<AddAccountDialog bind:open={adding} /> <AddAccountDialog bind:open={adding} />
<Dialog.Root bind:open={renaming}>
<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={rename}>
<div class="dialog-body">
<p class="confirm-text">
Так сессия называется в списке устройств Telegram. Чтобы имя
применилось, соединение переподключится — сбор сообщений прервётся
на пару секунд.
</p>
<div class="input-group">
<input
id="device-name"
class="form-control"
type="text"
maxlength={DEVICE_LIMIT}
placeholder={DEFAULT_DEVICE}
bind:value={deviceName}
>
<label for="device-name">Название</label>
</div>
</div>
<div class="dialog-actions">
<Button
type="submit"
pill
loading={busy}
disabled={busy || deviceName.trim().length === 0}
>
Сохранить
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root bind:open={confirming}> <Dialog.Root bind:open={confirming}>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" /> <Dialog.Overlay class="dialog-overlay" />
+11 -1
View File
@@ -1,5 +1,9 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { listAccounts, logoutAccount } from "$lib/api/endpoints"; import {
listAccounts,
logoutAccount,
renameAccountDevice,
} 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";
@@ -63,6 +67,12 @@ function createAccounts() {
}, },
load, load,
select, select,
async renameDevice(id: number, deviceModel: string) {
const updated = await renameAccountDevice(id, deviceModel);
list = list.map((account) =>
account.account_id === id ? updated : account
);
},
async logout(id: number) { async logout(id: number) {
await logoutAccount(id); await logoutAccount(id);
if (id === selectedId) { if (id === selectedId) {