feat(api,userbot,frontend): rename session device from the web ui
This commit is contained in:
@@ -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")
|
||||
@@ -9,6 +9,7 @@ from pyrogram.errors import FloodWait, RPCError
|
||||
from pyrogram.types import User
|
||||
|
||||
from api.login import LoginError, login_manager
|
||||
from userbot import DEVICE_MODEL_LIMIT
|
||||
from utils.read import accounts
|
||||
from utils.read.models import AccountView
|
||||
|
||||
@@ -35,6 +36,10 @@ class PasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class DeviceModelRequest(BaseModel):
|
||||
device_model: str
|
||||
|
||||
|
||||
class LoginState(BaseModel):
|
||||
login_id: str
|
||||
stage: Literal["code", "qr", "password", "done"]
|
||||
@@ -121,6 +126,23 @@ async def cancel_login(login_id: str) -> None:
|
||||
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)
|
||||
async def logout_account(account_id: int, pool: FromDishka[asyncpg.Pool]) -> None:
|
||||
if await accounts.deactivate_account(pool, account_id) is None:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -6,19 +6,30 @@ if TYPE_CHECKING:
|
||||
from userbot.modules.capture import CaptureContext
|
||||
|
||||
|
||||
DEVICE_MODEL = "Beavergram"
|
||||
DEVICE_MODEL_LIMIT = 32
|
||||
|
||||
|
||||
class PyroClient(Client):
|
||||
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:
|
||||
super().__init__(
|
||||
name,
|
||||
workdir=workdir,
|
||||
api_id=2040,
|
||||
api_hash="b18441a1ff607e10a989891a5462e627",
|
||||
device_model="Desktop",
|
||||
device_model=device_model or DEVICE_MODEL,
|
||||
system_version="Windows 11 x64",
|
||||
app_version="6.7.8 x64",
|
||||
app_version="7.0.8 x64",
|
||||
lang_pack="tdesktop",
|
||||
lang_code="en",
|
||||
system_lang_code="en-US",
|
||||
client_platform=enums.ClientPlatform.DESKTOP,
|
||||
)
|
||||
self.capture: CaptureContext | None = None
|
||||
@@ -30,4 +41,4 @@ class PyroClient(Client):
|
||||
self.add_handler(*handler)
|
||||
|
||||
|
||||
__all__ = ["PyroClient"]
|
||||
__all__ = ["DEVICE_MODEL", "DEVICE_MODEL_LIMIT", "PyroClient"]
|
||||
|
||||
@@ -18,6 +18,7 @@ from utils.logging import logger, setup_logging
|
||||
from utils.read.accounts import (
|
||||
ACCOUNTS_CHANGED_CHANNEL,
|
||||
inactive_session_names,
|
||||
session_device_models,
|
||||
sync_account,
|
||||
)
|
||||
from utils.read.watches import WATCHES_CHANGED_CHANNEL
|
||||
@@ -30,6 +31,7 @@ setup_logging()
|
||||
class RunningAccount:
|
||||
client: PyroClient
|
||||
consumer_task: asyncio.Task
|
||||
device_model: str | None
|
||||
|
||||
|
||||
def _sessions_dir() -> Path:
|
||||
@@ -70,14 +72,20 @@ class AccountRegistry:
|
||||
async def sync(self) -> None:
|
||||
async with self._lock:
|
||||
inactive = await inactive_session_names(self._pool)
|
||||
models = await session_device_models(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)
|
||||
device_model = models.get(path.stem)
|
||||
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:
|
||||
await self._stop(session_name)
|
||||
|
||||
@@ -85,9 +93,11 @@ class AccountRegistry:
|
||||
for session_name in list(self._running):
|
||||
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
|
||||
client = PyroClient(session_name, workdir=str(path.parent))
|
||||
client = PyroClient(
|
||||
session_name, workdir=str(path.parent), device_model=device_model
|
||||
)
|
||||
try:
|
||||
await client.start()
|
||||
me = client.me
|
||||
@@ -105,7 +115,7 @@ class AccountRegistry:
|
||||
return
|
||||
consumer = JobConsumer(client, self._pool, account_id)
|
||||
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})")
|
||||
await _enqueue_once(self._pool, account_id, "sync_dialogs")
|
||||
|
||||
@@ -7,7 +7,7 @@ from utils.read.models import AccountView
|
||||
|
||||
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 = """
|
||||
INSERT INTO accounts
|
||||
@@ -23,6 +23,12 @@ ON CONFLICT (tg_user_id) DO UPDATE SET
|
||||
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:
|
||||
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:
|
||||
return await pool.fetchval(
|
||||
"UPDATE accounts SET is_active = FALSE, updated_at = now() "
|
||||
|
||||
@@ -22,6 +22,7 @@ class AccountView(BaseModel):
|
||||
phone: str | None
|
||||
tg_user_id: int | None
|
||||
is_active: bool
|
||||
device_model: str | None
|
||||
|
||||
|
||||
class ChatListItem(BaseModel):
|
||||
|
||||
@@ -85,6 +85,16 @@ export function cancelLogin(loginId: string): Promise<void> {
|
||||
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> {
|
||||
return request<void>(`/accounts/${accountId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export type JobStatus =
|
||||
|
||||
export interface Account {
|
||||
account_id: number;
|
||||
device_model: string | null;
|
||||
is_active: boolean;
|
||||
label: string | null;
|
||||
phone: string | null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog } from "bits-ui";
|
||||
import { ripple } from "$lib/actions/ripple";
|
||||
import { ApiError } from "$lib/api/client";
|
||||
import AddAccountDialog from "$lib/components/settings/AddAccountDialog.svelte";
|
||||
import SettingsItem from "$lib/components/settings/SettingsItem.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 confirming = $state(false);
|
||||
let renaming = $state(false);
|
||||
let deviceName = $state("");
|
||||
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() {
|
||||
if (!current || busy) {
|
||||
return;
|
||||
@@ -81,6 +114,14 @@
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
{#if current?.is_active}
|
||||
<SettingsItem
|
||||
icon="active-sessions"
|
||||
label="Имя устройства"
|
||||
value={deviceLabel}
|
||||
onclick={openRename}
|
||||
/>
|
||||
{/if}
|
||||
<SettingsItem
|
||||
icon="add-user"
|
||||
label="Добавить аккаунт"
|
||||
@@ -102,6 +143,50 @@
|
||||
|
||||
<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.Portal>
|
||||
<Dialog.Overlay class="dialog-overlay" />
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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";
|
||||
|
||||
const STORAGE_KEY = "bg.account";
|
||||
@@ -63,6 +67,12 @@ function createAccounts() {
|
||||
},
|
||||
load,
|
||||
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) {
|
||||
await logoutAccount(id);
|
||||
if (id === selectedId) {
|
||||
|
||||
Reference in New Issue
Block a user