diff --git a/backend/migrations/versions/b9e4d1a70c26_account_device_model.py b/backend/migrations/versions/b9e4d1a70c26_account_device_model.py new file mode 100644 index 0000000..60c2076 --- /dev/null +++ b/backend/migrations/versions/b9e4d1a70c26_account_device_model.py @@ -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") diff --git a/backend/src/api/routers/accounts.py b/backend/src/api/routers/accounts.py index a41a121..41fa8bf 100644 --- a/backend/src/api/routers/accounts.py +++ b/backend/src/api/routers/accounts.py @@ -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: diff --git a/backend/src/userbot/__init__.py b/backend/src/userbot/__init__.py index 5ea2b14..64586e2 100644 --- a/backend/src/userbot/__init__.py +++ b/backend/src/userbot/__init__.py @@ -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"] diff --git a/backend/src/userbot/modules/client.py b/backend/src/userbot/modules/client.py index 764fec2..c3a665e 100644 --- a/backend/src/userbot/modules/client.py +++ b/backend/src/userbot/modules/client.py @@ -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"] diff --git a/backend/src/userbot/runner.py b/backend/src/userbot/runner.py index 79156ee..b0f0cb7 100644 --- a/backend/src/userbot/runner.py +++ b/backend/src/userbot/runner.py @@ -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") diff --git a/backend/src/utils/read/accounts.py b/backend/src/utils/read/accounts.py index d691085..c8682fa 100644 --- a/backend/src/utils/read/accounts.py +++ b/backend/src/utils/read/accounts.py @@ -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() " diff --git a/backend/src/utils/read/models.py b/backend/src/utils/read/models.py index 10e3aa3..6411e57 100644 --- a/backend/src/utils/read/models.py +++ b/backend/src/utils/read/models.py @@ -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): diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index b60264a..6417015 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -85,6 +85,16 @@ export function cancelLogin(loginId: string): Promise { return request(`/accounts/login/${loginId}`, { method: "DELETE" }); } +export function renameAccountDevice( + accountId: number, + deviceModel: string +): Promise { + return request(`/accounts/${accountId}/device`, { + method: "PATCH", + body: { device_model: deviceModel }, + }); +} + export function logoutAccount(accountId: number): Promise { return request(`/accounts/${accountId}`, { method: "DELETE" }); } diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 345685e..c8a819a 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -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; diff --git a/frontend/src/lib/components/settings/MyAccount.svelte b/frontend/src/lib/components/settings/MyAccount.svelte index efda58c..fac52a5 100644 --- a/frontend/src/lib/components/settings/MyAccount.svelte +++ b/frontend/src/lib/components/settings/MyAccount.svelte @@ -1,6 +1,7 @@