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 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:
|
||||||
|
|||||||
@@ -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
|
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"]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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() "
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user