Compare commits

..
5 Commits
48 changed files with 2309 additions and 349 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")
@@ -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_%'"
)
+2
View File
@@ -20,6 +20,7 @@ from api.routers import (
backfill,
chats,
custom_emoji,
discover,
events,
folders,
media,
@@ -83,6 +84,7 @@ app.include_router(stories.router)
app.include_router(profile.router)
app.include_router(events.router)
app.include_router(peers.router)
app.include_router(discover.router)
app.include_router(annotations.router)
app.include_router(watches.router)
+183
View File
@@ -0,0 +1,183 @@
import asyncio
import contextlib
import secrets
import time
from dataclasses import dataclass, field
from pathlib import Path
from pyrogram.errors import AuthTokenExpired, SessionPasswordNeeded
from pyrogram.qrlogin import QRLogin
from pyrogram.types import User
from userbot import PyroClient
from utils.env import env
LOGIN_TTL_SECONDS = 900
QR_POLL_SECONDS = 25
PENDING_DIRNAME = "pending"
class LoginError(Exception):
pass
@dataclass
class QrState:
url: str
user: User | None = None
password_needed: bool = False
error: str | None = None
changed: asyncio.Event = field(default_factory=asyncio.Event)
@property
def settled(self) -> bool:
return self.user is not None or self.password_needed or self.error is not None
@dataclass
class PendingLogin:
client: PyroClient
started_at: float
phone: str = ""
phone_code_hash: str = ""
qr: QrState | None = None
watcher: asyncio.Task[None] | None = None
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
async def _stop_watcher(login: PendingLogin) -> None:
if login.watcher is None or login.watcher.done():
return
login.watcher.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await login.watcher
async def _watch_qr(qr: QRLogin, state: QrState) -> None:
while not state.settled:
try:
try:
state.user = await qr.wait()
except (TimeoutError, AuthTokenExpired):
await qr.recreate()
state.url = qr.url
except SessionPasswordNeeded:
state.password_needed = True
except Exception as exc:
state.error = str(exc)
state.changed.set()
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 _connect(self) -> tuple[str, PyroClient]:
await self._sweep()
login_id = secrets.token_hex(8)
client = PyroClient(login_id, workdir=str(_pending_dir()), load_handlers=False)
await client.connect()
return login_id, client
async def start(self, phone: str) -> str:
login_id, client = await self._connect()
try:
sent = await client.send_code(phone)
except Exception:
await self._discard(login_id, client)
raise
self._logins[login_id] = PendingLogin(
client, time.monotonic(), phone=phone, phone_code_hash=sent.phone_code_hash
)
return login_id
async def start_qr(self) -> tuple[str, str]:
login_id, client = await self._connect()
qr = QRLogin(client)
try:
await qr.recreate()
except Exception:
await self._discard(login_id, client)
raise
state = QrState(qr.url)
self._logins[login_id] = PendingLogin(
client,
time.monotonic(),
qr=state,
watcher=asyncio.create_task(_watch_qr(qr, state)),
)
return login_id, state.url
async def wait_qr(self, login_id: str) -> QrState:
login = self._get(login_id)
if login.qr is None:
msg = "Этот вход начат по номеру телефона"
raise LoginError(msg)
with contextlib.suppress(TimeoutError):
async with asyncio.timeout(QR_POLL_SECONDS):
await login.qr.changed.wait()
login.qr.changed.clear()
return login.qr
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 _stop_watcher(login)
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 _stop_watcher(login)
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()
+138 -1
View File
@@ -1,13 +1,150 @@
from collections.abc import Coroutine
from typing import Any, Literal
import asyncpg
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 userbot import DEVICE_MODEL_LIMIT
from utils.read import accounts
from utils.read.models import AccountView
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 DeviceModelRequest(BaseModel):
device_model: str
class LoginState(BaseModel):
login_id: str
stage: Literal["code", "qr", "password", "done"]
qr_url: str | None = None
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")
async def list_accounts(pool: FromDishka[asyncpg.Pool]) -> list[AccountView]:
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/qr")
async def start_qr_login() -> LoginState:
login_id, url = await _guard(login_manager.start_qr())
return LoginState(login_id=login_id, stage="qr", qr_url=url)
@router.get("/accounts/login/{login_id}/qr")
async def poll_qr_login(login_id: str, pool: FromDishka[asyncpg.Pool]) -> LoginState:
state = await _guard(login_manager.wait_qr(login_id))
if state.user is not None:
return await _complete(pool, login_id, state.user)
if state.password_needed:
return LoginState(login_id=login_id, stage="password")
if state.error is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, state.error)
return LoginState(login_id=login_id, stage="qr", qr_url=state.url)
@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.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:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Аккаунт не найден")
await accounts.notify_accounts_changed(pool)
+116
View File
@@ -0,0 +1,116 @@
import asyncio
import json
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from pydantic import BaseModel
from api.routers.policy import POLICY_CHANGED_CHANNEL
from utils.jobs import enqueue
from utils.policy import repository as policy_repository
from utils.policy.defaults import TRACKING
from utils.policy.models import ScopeType
from utils.read import discover
from utils.read.models import DiscoverItem
router = APIRouter(prefix="/api", tags=["discover"], route_class=DishkaRoute)
DEFAULT_LIMIT = 30
REMOTE_TIMEOUT_SECONDS = 20.0
POLL_INTERVAL_SECONDS = 0.2
FINISHED = ("done", "failed", "canceled")
_CHAT_POLICY_ID = """
SELECT id FROM capture_policy
WHERE account_id = $1 AND scope_type = 'chat' AND scope_id = $2
"""
AccountId = Annotated[int, Query()]
class TrackRequest(BaseModel):
account_id: int
backfill: bool = True
class SyncContactsRequest(BaseModel):
account_id: int
async def _remote_ids(
pool: asyncpg.Pool, account_id: int, query: str, limit: int
) -> list[int]:
job_id = await enqueue(
pool, account_id, "search_peers", {"query": query, "limit": limit}
)
loop = asyncio.get_running_loop()
deadline = loop.time() + REMOTE_TIMEOUT_SECONDS
while loop.time() < deadline:
await asyncio.sleep(POLL_INTERVAL_SECONDS)
row = await pool.fetchrow(
"SELECT status, progress FROM jobs WHERE id = $1", job_id
)
if row is not None and row["status"] in FINISHED:
await pool.execute("DELETE FROM jobs WHERE id = $1", job_id)
return json.loads(row["progress"]).get("ids", [])
return []
@router.get("/discover")
async def discover_peers(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
query: Annotated[str, Query()] = "",
remote: Annotated[bool, Query()] = False,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
) -> list[DiscoverItem]:
if not query.strip():
return []
items = await discover.search(pool, account_id, query, limit)
if not remote:
return items
known = {item.chat_id for item in items}
ids = await _remote_ids(pool, account_id, query, limit)
extra = await discover.by_ids(
pool, account_id, [chat_id for chat_id in ids if chat_id not in known]
)
return [*items, *extra]
@router.get("/discover/{chat_id}")
async def discover_chat(
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
) -> DiscoverItem:
return await discover.get_item(pool, account_id, chat_id)
@router.post("/chats/{chat_id}/track", status_code=201)
async def track_chat(
pool: FromDishka[asyncpg.Pool], chat_id: int, body: TrackRequest
) -> DiscoverItem:
kind = await discover.chat_kind(pool, body.account_id, chat_id)
toggles = TRACKING[kind]
policy_id = await pool.fetchval(_CHAT_POLICY_ID, body.account_id, chat_id)
if policy_id is None:
await policy_repository.create_policy(
pool, body.account_id, ScopeType.CHAT, chat_id, toggles
)
else:
await policy_repository.update_policy(pool, policy_id, toggles)
await pool.execute(f"NOTIFY {POLICY_CHANGED_CHANNEL}")
await enqueue(pool, body.account_id, "enrich_chat", {"chat_id": chat_id})
if body.backfill:
await enqueue(
pool, body.account_id, "backfill", {"chat_id": chat_id, "media": True}
)
return await discover.get_item(pool, body.account_id, chat_id)
@router.post("/contacts/sync", status_code=201)
async def sync_contacts(
pool: FromDishka[asyncpg.Pool], body: SyncContactsRequest
) -> dict[str, int]:
job_id = await enqueue(pool, body.account_id, "sync_contacts", {})
return {"job_id": job_id}
+8 -2
View File
@@ -71,9 +71,15 @@ async def get_policy(pool: FromDishka[asyncpg.Pool], policy_id: int) -> PolicyRe
@router.put("/{policy_id}")
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:
record = await repository.update_policy(pool, policy_id, body)
if account_id is None:
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:
raise HTTPException(status_code=404, detail="policy not found")
await pool.execute(f"NOTIFY {POLICY_CHANGED_CHANNEL}")
+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
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.2.4 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"]
@@ -4,6 +4,8 @@ from userbot.modules.jobs.handlers import (
fetch_avatar,
fetch_custom_emoji,
fetch_media,
search_peers,
sync_contacts,
sync_dialogs,
transcribe,
)
@@ -14,6 +16,8 @@ __all__ = [
"fetch_avatar",
"fetch_custom_emoji",
"fetch_media",
"search_peers",
"sync_contacts",
"sync_dialogs",
"transcribe",
]
@@ -0,0 +1,102 @@
import re
from pyrogram import Client, raw
from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import Chat
from userbot.modules.capture.context import CaptureContext
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.snapshots import save_chat
DEFAULT_LIMIT = 30
_USERNAME = re.compile(r"^[a-z][a-z0-9_]{3,31}$", re.IGNORECASE)
_PREFIXES = ("https://t.me/", "http://t.me/", "t.me/", "@")
def _normalize(query: str) -> str:
text = query.strip()
for prefix in _PREFIXES:
if text.lower().startswith(prefix):
text = text[len(prefix) :]
break
return text.strip("/")
_SOURCE_TYPES = (raw.types.User, raw.types.Chat, raw.types.Channel)
def _source(
peer: raw.base.Peer, users: dict, chats: dict
) -> raw.types.User | raw.types.Chat | raw.types.Channel | None:
if isinstance(peer, raw.types.PeerUser):
source = users.get(peer.user_id)
elif isinstance(peer, raw.types.PeerChannel):
source = chats.get(peer.channel_id)
elif isinstance(peer, raw.types.PeerChat):
source = chats.get(peer.chat_id)
else:
return None
return source if isinstance(source, _SOURCE_TYPES) else None
async def _save_found(
client: Client, ctx: CaptureContext, peer: raw.base.Peer, users: dict, chats: dict
) -> int | None:
source = _source(peer, users, chats)
if source is None:
return None
chat = Chat._parse_chat(client, source) # noqa: SLF001
if chat is None or chat.id is None:
return None
await save_chat(ctx, chat)
return chat.id
async def _resolve(client: Client, ctx: CaptureContext, query: str) -> int | None:
try:
chat = await client.get_chat(query)
except (BadRequest, Forbidden):
return None
if not isinstance(chat, Chat) or chat.id is None:
return None
await save_chat(ctx, chat)
return chat.id
async def _search(
client: Client, query: str, limit: int
) -> raw.base.contacts.Found | None:
try:
return await client.invoke(raw.functions.contacts.Search(q=query, limit=limit))
except (BadRequest, Forbidden):
return None
@register("search_peers")
async def search_peers(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
capture = getattr(client, "capture", None)
if capture is None:
return
query = _normalize(ctx.job.params.get("query", ""))
if not query:
await ctx.report_progress({"ids": [], "done": True})
return
limit = int(ctx.job.params.get("limit", DEFAULT_LIMIT))
found = await _search(client, query, limit)
ids: list[int] = []
if found is not None:
users = {user.id: user for user in found.users}
chats = {chat.id: chat for chat in found.chats}
for peer in (*found.my_results, *found.results):
peer_id = await _save_found(client, capture, peer, users, chats)
if peer_id is not None and peer_id not in ids:
ids.append(peer_id)
if _USERNAME.match(query):
resolved = await _resolve(client, capture, query)
if resolved is not None and resolved not in ids:
ids.insert(0, resolved)
await ctx.report_progress({"ids": ids, "done": True})
@@ -0,0 +1,36 @@
from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile
@register("sync_contacts")
async def sync_contacts(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
capture = getattr(client, "capture", None)
if capture is None:
return
contacts = await client.get_contacts()
processed = 0
for user in contacts:
if not isinstance(user, User):
continue
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
await write_profile(ctx.pool, ctx.account_id, user.id, fields, str(user))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool,
ctx.account_id,
user.id,
"peer",
photo_unique_id,
photo_file_id,
)
processed += 1
await capture.contacts.refresh()
await ctx.report_progress({"processed": processed, "done": True})
@@ -1,16 +1,14 @@
from datetime import UTC, datetime
from pyrogram import Client
from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import Chat, User
from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_chat, snapshot_from_high_level
from userbot.modules.profiles.parse import snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile
from userbot.modules.profiles.snapshots import save_group, save_private
SAVE_EVERY = 100
USERS_BATCH = 200
@@ -21,16 +19,6 @@ ON CONFLICT (account_id, chat_id) DO UPDATE SET updated_at = now()
"""
async def _save_private(ctx: CaptureContext, chat: Chat, chat_id: int) -> bool:
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, str(chat))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
)
return bool(fields.first_name or fields.last_name or fields.username)
async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) -> None:
for start in range(0, len(ids), USERS_BATCH):
batch = ids[start : start + USERS_BATCH]
@@ -55,28 +43,6 @@ async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) ->
)
async def _save_group(ctx: CaptureContext, chat: Chat, chat_id: int) -> None:
photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
await insert_chat_history(
ctx.pool,
ctx.account_id,
chat_id,
0,
"meta",
chat.title,
photo_unique_id,
None,
datetime.now(UTC),
str(chat),
)
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "chat", photo_unique_id, photo_file_id
)
@register("sync_dialogs")
async def sync_dialogs(ctx: JobContext) -> None:
client = ctx.client
@@ -94,10 +60,10 @@ async def sync_dialogs(ctx: JobContext) -> None:
chat_id = chat.id
try:
if chat_id > 0:
if not await _save_private(capture, chat, chat_id):
if not await save_private(capture, chat):
nameless.append(chat_id)
else:
await _save_group(capture, chat, chat_id)
await save_group(capture, chat)
except (BadRequest, Forbidden):
pass
await ctx.pool.execute(_UPSERT_DIALOG, ctx.account_id, chat_id)
@@ -0,0 +1,50 @@
from datetime import UTC, datetime
from pyrogram.types import Chat
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history
from userbot.modules.profiles.parse import snapshot_from_chat
from userbot.modules.profiles.repository import write_profile
async def save_private(ctx: CaptureContext, chat: Chat) -> bool:
chat_id = chat.id or 0
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, str(chat))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
)
return bool(fields.first_name or fields.last_name or fields.username)
async def save_group(ctx: CaptureContext, chat: Chat) -> None:
chat_id = chat.id or 0
photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
await insert_chat_history(
ctx.pool,
ctx.account_id,
chat_id,
0,
"meta",
chat.title,
photo_unique_id,
None,
datetime.now(UTC),
str(chat),
)
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "chat", photo_unique_id, photo_file_id
)
async def save_chat(ctx: CaptureContext, chat: Chat) -> None:
if (chat.id or 0) > 0:
await save_private(ctx, chat)
else:
await save_group(ctx, chat)
+140 -99
View File
@@ -1,7 +1,7 @@
import asyncio
import contextlib
import json
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -15,87 +15,154 @@ from userbot.modules.jobs import JobConsumer
from utils.env import env
from utils.jobs import enqueue
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
from utils.storage import ContentAddressedStorage
setup_logging()
_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
"""
@dataclass
class RunningAccount:
client: PyroClient
consumer_task: asyncio.Task
device_model: str | None
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)
return sorted(sessions_dir.glob("*.session"))
return sessions_dir
async def _sync_account(
pool: asyncpg.Pool, client: PyroClient, session_name: str
) -> int | None:
me = client.me
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 _cancel(task: asyncio.Task) -> None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def _setup_capture(
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:
async def _enqueue_once(pool: asyncpg.Pool, account_id: int, kind: str) -> None:
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",
account_id,
kind,
)
if existing is None:
await enqueue(pool, account_id, "sync_dialogs", {})
logger.info("[green]Queued sync_dialogs.[/]")
await enqueue(pool, account_id, kind, {})
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)
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)
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)
async def close(self) -> None:
for session_name in list(self._running):
await self._stop(session_name)
async def _start(self, path: Path, device_model: str | None) -> None:
session_name = path.stem
client = PyroClient(
session_name, workdir=str(path.parent), device_model=device_model
)
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()), device_model
)
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(
clients: list[PyroClient], tasks: set[asyncio.Task]
registry: AccountRegistry, tasks: set[asyncio.Task]
) -> 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(
make_coro: Callable[[CaptureContext], Coroutine[Any, Any, None]],
) -> None:
for client in clients:
if client.capture is None:
continue
task = asyncio.create_task(make_coro(client.capture))
tasks.add(task)
task.add_done_callback(tasks.discard)
for client in registry.clients:
if client.capture is not None:
spawn(make_coro(client.capture))
def on_policy(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
@@ -107,9 +174,15 @@ async def _listen_changes(
) -> None:
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)
await conn.add_listener("policy_changed", on_policy)
await conn.add_listener(WATCHES_CHANGED_CHANNEL, on_watch)
await conn.add_listener(ACCOUNTS_CHANGED_CHANNEL, on_accounts)
return conn
@@ -117,53 +190,21 @@ async def runner() -> None:
pool = await container.get(asyncpg.Pool)
storage = await container.get(ContentAddressedStorage)
sessions_dir = Path(env.tg.sessions_dir)
session_files = _discover_sessions(sessions_dir)
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] = []
registry = AccountRegistry(pool, storage)
tasks: set[asyncio.Task] = set()
listen_conn: asyncpg.Connection | None = None
try:
for session_path in session_files:
session_name = session_path.stem
client = PyroClient(session_name, workdir=str(sessions_dir))
await client.start()
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.[/]")
await asyncio.Event().wait()
await registry.sync()
if not registry.clients:
logger.warning("[yellow]No sessions yet. Add an account in the web UI.[/]")
listen_conn = await _listen_changes(registry, tasks)
logger.info("[green]Userbot running.[/]")
await asyncio.Event().wait()
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:
with contextlib.suppress(Exception):
await listen_conn.close()
for client in clients:
with contextlib.suppress(Exception):
await client.stop()
await registry.close()
await container.close()
+19
View File
@@ -16,3 +16,22 @@ DEFAULTS: dict[ChatKind, CaptureToggles] = {
backfill=True,
),
}
TRACKING: dict[ChatKind, CaptureToggles] = {
ChatKind.CHANNEL: CaptureToggles(
messages=True,
media=True,
reactions=True,
track_edits_deletes=True,
backfill=True,
),
ChatKind.GROUP: CaptureToggles(
messages=True,
media=True,
reactions=True,
track_edits_deletes=True,
profile_history=True,
backfill=True,
),
ChatKind.DM: DEFAULTS[ChatKind.DM],
}
+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
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]:
rows = await pool.fetch(
"SELECT * FROM capture_policy WHERE account_id = $1 OR account_id IS NULL "
"ORDER BY scope_type, scope_id",
"SELECT DISTINCT ON (scope_type, scope_id) * FROM capture_policy "
"WHERE account_id = $1 OR account_id IS NULL "
"ORDER BY scope_type, scope_id, account_id NULLS LAST",
account_id,
)
return [PolicyRecord(**dict(row)) for row in rows]
@@ -131,6 +145,22 @@ async def update_policy(
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:
result = await pool.execute("DELETE FROM capture_policy WHERE id = $1", policy_id)
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:
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,
)
policies = PolicySet()
+83 -2
View File
@@ -1,7 +1,34 @@
import json
import asyncpg
from pyrogram.types import User
from utils.read.models import AccountView
ACCOUNTS_CHANGED_CHANNEL = "accounts_changed"
_ACCOUNT_COLS = "account_id, label, phone, tg_user_id, is_active, device_model"
_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
"""
_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(
@@ -11,7 +38,61 @@ async def self_user_id(pool: asyncpg.Pool, account_id: int) -> int | None:
async def list_accounts(pool: asyncpg.Pool) -> list[AccountView]:
rows = await pool.fetch(
"SELECT account_id, label, phone, tg_user_id, is_active FROM accounts "
"ORDER BY account_id"
f"SELECT {_ACCOUNT_COLS} FROM accounts ORDER BY account_id" # noqa: S608
)
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 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() "
"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)
+3 -1
View File
@@ -56,7 +56,9 @@ async def list_chats(
rows = await pool.fetch(
"WITH ids AS ("
"SELECT DISTINCT chat_id FROM messages WHERE account_id = $1 "
"UNION SELECT chat_id FROM dialogs WHERE account_id = $1), "
"UNION SELECT chat_id FROM dialogs WHERE account_id = $1 "
"UNION SELECT scope_id FROM capture_policy WHERE account_id = $1 "
"AND scope_type = 'chat' AND scope_id IS NOT NULL), "
"agg AS (SELECT chat_id, count(*) AS message_count, max(date) AS last_date "
"FROM messages WHERE account_id = $1 GROUP BY chat_id) "
"SELECT ids.chat_id, COALESCE(agg.message_count, 0) AS message_count, "
+141
View File
@@ -0,0 +1,141 @@
import asyncpg
from utils.policy.models import ChatKind
from utils.read.models import DiscoverItem
_ESCAPE = str.maketrans({"\\": "\\\\", "%": r"\%", "_": r"\_"})
_IS_BROADCAST = """
SELECT COALESCE(raw->'chat'->>'type', raw->>'type') = 'ChatType.CHANNEL'
FROM chat_history
WHERE account_id = $1 AND chat_id = $2
AND COALESCE(raw->'chat'->>'type', raw->>'type') IS NOT NULL
ORDER BY ts DESC LIMIT 1
"""
_IS_TRACKED = """
SELECT EXISTS (
SELECT 1 FROM capture_policy
WHERE account_id = $1 AND scope_type = 'chat' AND scope_id = $2
)
"""
_ITEMS = """
WITH chat_meta AS (
SELECT DISTINCT ON (chat_id) chat_id, title,
COALESCE(raw->'chat'->>'username', raw->>'username') AS username,
COALESCE(raw->'chat'->>'type', raw->>'type') = 'ChatType.CHANNEL'
AS is_broadcast
FROM chat_history
WHERE account_id = $1 AND title IS NOT NULL
ORDER BY chat_id, ts DESC
), hits AS (
SELECT p.peer_id AS chat_id,
COALESCE(NULLIF(concat_ws(' ', p.first_name, p.last_name), ''), p.username)
AS title,
p.username,
COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, false) AS is_bot,
COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool, false)
AS is_contact,
false AS is_broadcast
FROM peers p
WHERE p.account_id = $1 AND (p.peer_id = ANY($3::bigint[]) OR ($2 <> '' AND (
concat_ws(' ', p.first_name, p.last_name) ILIKE $2
OR p.username ILIKE $2 OR p.phone ILIKE $2)))
UNION ALL
SELECT c.chat_id, c.title, c.username, false, false,
COALESCE(c.is_broadcast, false)
FROM chat_meta c
WHERE c.chat_id = ANY($3::bigint[])
OR ($2 <> '' AND (c.title ILIKE $2 OR c.username ILIKE $2))
), merged AS (
SELECT chat_id, max(title) AS title, max(username) AS username,
bool_or(is_bot) AS is_bot, bool_or(is_contact) AS is_contact,
bool_or(is_broadcast) AS is_broadcast
FROM hits GROUP BY chat_id
), counts AS (
SELECT chat_id, count(*) AS message_count FROM messages
WHERE account_id = $1 AND chat_id IN (SELECT chat_id FROM merged)
GROUP BY chat_id
)
SELECT m.chat_id, m.title, m.username, m.is_bot, m.is_contact, m.is_broadcast,
COALESCE(c.message_count, 0) AS message_count,
EXISTS (SELECT 1 FROM avatars a
WHERE a.account_id = $1 AND a.owner_id = m.chat_id) AS has_avatar,
EXISTS (SELECT 1 FROM dialogs d
WHERE d.account_id = $1 AND d.chat_id = m.chat_id) AS in_dialogs,
EXISTS (SELECT 1 FROM capture_policy cp WHERE cp.account_id = $1
AND cp.scope_type = 'chat' AND cp.scope_id = m.chat_id) AS tracked
FROM merged m LEFT JOIN counts c ON c.chat_id = m.chat_id
ORDER BY in_dialogs DESC, message_count DESC, is_contact DESC, m.title
LIMIT $4
"""
def _kind(chat_id: int, *, is_broadcast: bool) -> str:
if chat_id > 0:
return "private"
return "channel" if is_broadcast else "group"
def _to_item(row: asyncpg.Record) -> DiscoverItem:
return DiscoverItem(
chat_id=row["chat_id"],
title=row["title"],
username=row["username"],
kind=_kind(row["chat_id"], is_broadcast=row["is_broadcast"]),
is_bot=row["is_bot"],
is_contact=row["is_contact"],
has_avatar=row["has_avatar"],
message_count=row["message_count"],
in_dialogs=row["in_dialogs"],
tracked=row["tracked"],
)
async def search(
pool: asyncpg.Pool, account_id: int, query: str, limit: int
) -> list[DiscoverItem]:
text = query.strip()
if not text:
return []
rows = await pool.fetch(
_ITEMS, account_id, f"%{text.translate(_ESCAPE)}%", [], limit
)
return [_to_item(row) for row in rows]
async def by_ids(
pool: asyncpg.Pool, account_id: int, ids: list[int]
) -> list[DiscoverItem]:
if not ids:
return []
rows = await pool.fetch(_ITEMS, account_id, "", ids, len(ids))
by_id = {row["chat_id"]: _to_item(row) for row in rows}
return [by_id[chat_id] for chat_id in ids if chat_id in by_id]
async def get_item(pool: asyncpg.Pool, account_id: int, chat_id: int) -> DiscoverItem:
known = await by_ids(pool, account_id, [chat_id])
if known:
return known[0]
kind = await chat_kind(pool, account_id, chat_id)
return DiscoverItem(
chat_id=chat_id,
title=None,
username=None,
kind="private" if kind is ChatKind.DM else kind.value,
is_bot=False,
is_contact=False,
has_avatar=False,
message_count=0,
in_dialogs=False,
tracked=bool(await pool.fetchval(_IS_TRACKED, account_id, chat_id)),
)
async def chat_kind(pool: asyncpg.Pool, account_id: int, chat_id: int) -> ChatKind:
if chat_id > 0:
return ChatKind.DM
is_broadcast = await pool.fetchval(_IS_BROADCAST, account_id, chat_id)
return ChatKind.CHANNEL if is_broadcast else ChatKind.GROUP
+14
View File
@@ -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):
@@ -38,6 +39,19 @@ class ChatListItem(BaseModel):
last_sender_id: int | None
class DiscoverItem(BaseModel):
chat_id: int
title: str | None
username: str | None
kind: str
is_bot: bool
is_contact: bool
has_avatar: bool
message_count: int
in_dialogs: bool
tracked: bool
class EntityView(BaseModel):
type: str
offset: int
+3
View File
@@ -1 +1,4 @@
BEAVERGRAM_DOMAIN=beavergram.localhost
BEAVERGRAM_DEV_DOMAIN=dev.beavergram.localhost
CLOUDFLARE_API_TOKEN=
+3 -5
View File
@@ -12,10 +12,8 @@
}
}
<DOMAIN> {
reverse_proxy beavergram-api:8080
(compress) {
encode zstd gzip
}
dev.<DOMAIN> {
reverse_proxy beavergram-frontend:5173
}
import /etc/caddy/projects.d/*.caddy
+4 -3
View File
@@ -8,11 +8,12 @@ services:
- "0.0.0.0:443:443/udp"
networks:
- caddy
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
env_file:
- .env
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./site.caddy:/etc/caddy/projects.d/beavergram.caddy:ro
- caddy_data:/data
networks:
caddy:
+9
View File
@@ -0,0 +1,9 @@
{$BEAVERGRAM_DOMAIN} {
encode zstd gzip
reverse_proxy beavergram-api:8080
}
{$BEAVERGRAM_DEV_DOMAIN} {
encode zstd gzip
reverse_proxy beavergram-frontend:5173
}
+1
View File
@@ -52,6 +52,7 @@ services:
restart: unless-stopped
volumes:
- ./backend/src:/app/src
- ./backend/sessions:/app/sessions
- ./frontend/build:/app/static:ro
- ${STORAGE__ROOT:-./storage}:/app/storage
depends_on:
+3 -1
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "frontend",
@@ -8,6 +7,7 @@
"bits-ui": "^2.18.1",
"lottie-web": "^5.13.0",
"pako": "^2.1.0",
"uqr": "^0.1.3",
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
@@ -388,6 +388,8 @@
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="],
+2 -1
View File
@@ -34,6 +34,7 @@
"dependencies": {
"bits-ui": "^2.18.1",
"lottie-web": "^5.13.0",
"pako": "^2.1.0"
"pako": "^2.1.0",
"uqr": "^0.1.3"
}
}
+87
View File
@@ -9,10 +9,12 @@ import type {
Chat,
ChatLinkView,
DayCount,
DiscoverItem,
Folder,
JobStatus,
JobView,
LinkView,
LoginState,
MediaVersion,
MediaView,
MessageAt,
@@ -44,6 +46,59 @@ export function listAccounts(): Promise<Account[]> {
return request<Account[]>("/accounts");
}
export function startLogin(phone: string): Promise<LoginState> {
return request<LoginState>("/accounts/login", {
method: "POST",
body: { phone },
});
}
export function startQrLogin(): Promise<LoginState> {
return request<LoginState>("/accounts/login/qr", { method: "POST" });
}
export function pollQrLogin(loginId: string): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/qr`);
}
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 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" });
}
export function listChats(page: Page = {}): Promise<Chat[]> {
return request<Chat[]>("/chats", { account: true, query: { ...page } });
}
@@ -69,6 +124,7 @@ export function updatePolicy(
): Promise<PolicyRecord> {
return request<PolicyRecord>(`/policy/${id}`, {
method: "PUT",
account: true,
body: toggles,
});
}
@@ -301,6 +357,37 @@ export function enqueueBackfill(
});
}
export function discoverPeers(
query: string,
remote = false
): Promise<DiscoverItem[]> {
return request<DiscoverItem[]>("/discover", {
account: true,
query: { query, remote },
});
}
export function getDiscoverItem(chatId: number): Promise<DiscoverItem> {
return request<DiscoverItem>(`/discover/${chatId}`, { account: true });
}
export function trackChat(
chatId: number,
backfill = true
): Promise<DiscoverItem> {
return request<DiscoverItem>(`/chats/${chatId}/track`, {
method: "POST",
body: { account_id: accounts.selectedId, backfill },
});
}
export function syncContacts(): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/contacts/sync", {
method: "POST",
body: { account_id: accounts.selectedId },
});
}
export function syncDialogs(): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/dialogs/sync", {
method: "POST",
+25
View File
@@ -16,12 +16,22 @@ export type JobStatus =
export interface Account {
account_id: number;
device_model: string | null;
is_active: boolean;
label: string | null;
phone: string | null;
tg_user_id: number | null;
}
export type LoginStage = "code" | "qr" | "password" | "done";
export interface LoginState {
account: Account | null;
login_id: string;
qr_url: string | null;
stage: LoginStage;
}
export interface Chat {
chat_id: number;
has_avatar: boolean;
@@ -36,6 +46,21 @@ export interface Chat {
title: string | null;
}
export type DiscoverKind = "private" | "group" | "channel";
export interface DiscoverItem {
chat_id: number;
has_avatar: boolean;
in_dialogs: boolean;
is_bot: boolean;
is_contact: boolean;
kind: DiscoverKind;
message_count: number;
title: string | null;
tracked: boolean;
username: string | null;
}
export interface EntityView {
custom_emoji_id: string | null;
language: string | null;
@@ -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>
+20 -4
View File
@@ -15,6 +15,7 @@
import { formatPresence } from "$lib/format/presence";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { discover } from "$lib/stores/discover.svelte";
import { events } from "$lib/stores/events.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
@@ -27,6 +28,7 @@
const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId));
let peer = $state<PeerView | null>(null);
let presence = $state<PresenceSample | null>(null);
let backfilling = $state(false);
@@ -46,6 +48,13 @@
}
}
$effect(() => {
if (accounts.selectedId === null) {
return;
}
discover.ensure(chatId).catch(() => undefined);
});
$effect(() => {
if (accounts.selectedId === null || !isDm) {
peer = null;
@@ -103,7 +112,9 @@
});
const fallbackTitle = $derived(
chat?.title ?? (isDm ? "Удалённый аккаунт" : `Chat ${chatId}`)
chat?.title ??
discovered?.title ??
(isDm ? "Удалённый аккаунт" : `Chat ${chatId}`)
);
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
const subtitle = $derived.by(() => {
@@ -116,11 +127,16 @@
}
return peer?.phone ?? `ID ${chatId}`;
}
const count = chat?.message_count ?? 0;
return count > 0 ? `${count} messages` : "group";
const count = chat?.message_count ?? discovered?.message_count ?? 0;
if (count > 0) {
return `${count} messages`;
}
return discovered?.kind === "channel" ? "channel" : "group";
});
const avatarKind = $derived(isDm ? "peer" : "chat");
const hasAvatar = $derived(chat?.has_avatar ?? Boolean(peer?.has_avatar));
const hasAvatar = $derived(
chat?.has_avatar ?? Boolean(peer?.has_avatar || discovered?.has_avatar)
);
</script>
<header class="chat-header">
@@ -8,7 +8,7 @@
import MessageBubble from "$lib/components/MessageBubble.svelte";
import MessageVersions from "$lib/components/MessageVersions.svelte";
import PinnedBar from "$lib/components/PinnedBar.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import TrackChat from "$lib/components/TrackChat.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatDay } from "$lib/format/datetime";
@@ -450,10 +450,7 @@
{/each}
</div>
{:else if rows.length === 0}
<EmptyState
title="No messages"
description="This chat has no archived messages"
/>
<TrackChat {chatId} />
{:else}
<div class="messages-container">
{#if loadingOlder}
@@ -85,61 +85,6 @@
</Dialog.Root>
<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 {
overflow-y: auto;
padding: 0.75rem 1.25rem 1.25rem;
@@ -0,0 +1,118 @@
<script lang="ts">
import { enqueueBackfill, trackChat } from "$lib/api/endpoints";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { discover } from "$lib/stores/discover.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
let busy = $state(false);
const item = $derived(discover.get(chatId));
const tracked = $derived(item?.tracked ?? false);
const title = $derived(item?.title ?? "Этот чат");
$effect(() => {
if (accounts.selectedId === null) {
return;
}
discover.ensure(chatId).catch(() => undefined);
});
async function track() {
if (busy) {
return;
}
busy = true;
try {
discover.set(await trackChat(chatId, true));
toasts.success("Отслеживание включено, история загружается");
chats.refresh();
} catch {
toasts.error("Не удалось включить отслеживание");
} finally {
busy = false;
}
}
async function backfill() {
if (busy) {
return;
}
busy = true;
try {
await enqueueBackfill(chatId, true);
toasts.success("Бэкфилл запущен");
} catch {
toasts.error("Не удалось запустить бэкфилл");
} finally {
busy = false;
}
}
</script>
<div class="track">
<div class="title">Здесь пока нет сообщений</div>
<p class="description">
{#if tracked}
{title}
отслеживается — новые сообщения и статистика собираются автоматически.
{:else}
Включите отслеживание, чтобы собирать сообщения, медиа и статистику по
этому чату.
{/if}
</p>
<div class="actions">
{#if tracked}
<Button variant="secondary" pill loading={busy} onclick={backfill}>
<Icon name="cloud-download" />
<span>Загрузить историю</span>
</Button>
{:else}
<Button variant="primary" pill loading={busy} onclick={track}>
<Icon name="stats" />
<span>Начать отслеживать</span>
</Button>
{/if}
</div>
</div>
<style lang="scss">
.track {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 80%;
padding: 0 1.5rem;
}
.title {
margin-bottom: 0.25rem;
font-size: 1.25rem;
text-align: center;
}
.description {
max-width: 22rem;
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
text-align: center;
}
.actions {
display: flex;
gap: 0.5rem;
}
</style>
@@ -3,6 +3,7 @@
import type { JobStatus, JobView } from "$lib/api/types";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
@@ -93,7 +94,7 @@
}
$effect(() => {
if (version >= 0) {
if (version >= 0 && accounts.selectedId !== null) {
load().catch(() => {
loading = false;
});
@@ -1,7 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/state";
import { enqueueBackfill, syncDialogs } from "$lib/api/endpoints";
import {
enqueueBackfill,
syncContacts,
syncDialogs,
} from "$lib/api/endpoints";
import JobList from "$lib/components/jobs/JobList.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
@@ -17,6 +21,7 @@
let filter = $state("");
let starting = $state(false);
let syncing = $state(false);
let syncingContacts = $state(false);
const availableChats = $derived(
chats.list
@@ -71,6 +76,22 @@
}
}
async function syncContactList() {
if (syncingContacts) {
return;
}
syncingContacts = true;
try {
await syncContacts();
toasts.success("Синхронизация контактов запущена");
version += 1;
} catch {
toasts.error("Не удалось синхронизировать контакты");
} finally {
syncingContacts = false;
}
}
onMount(() => {
chats.load();
});
@@ -89,6 +110,21 @@
<span>Синхронизировать диалоги</span>
</Button>
</div>
<p class="hint">
Контакты подтягиваются отдельно — тогда в поиске появятся люди, с которыми
ещё не было переписки.
</p>
<div class="action">
<Button
variant="secondary"
fluid
loading={syncingContacts}
onclick={syncContactList}
>
<Icon name="user" />
<span>Синхронизировать контакты</span>
</Button>
</div>
</section>
<section>
@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from "svelte";
import {
createPolicy,
deletePolicy,
@@ -16,6 +15,7 @@
import CaptureToggleList from "$lib/components/policy/CaptureToggleList.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
@@ -110,7 +110,8 @@
p.id === record.id ? { ...p, [key]: value } : p
);
try {
await updatePolicy(record.id, next);
const saved = await updatePolicy(record.id, next);
policies = policies.map((p) => (p.id === record.id ? saved : p));
} catch {
toasts.error("Не удалось сохранить политику");
await reload();
@@ -143,7 +144,8 @@
}
}
onMount(async () => {
async function load(_account: number | null) {
loading = true;
chats.load();
try {
await reload();
@@ -152,6 +154,10 @@
} finally {
loading = false;
}
}
$effect(() => {
load(accounts.selectedId);
});
</script>
@@ -159,7 +165,10 @@
<div class="card">
<div class="card-head">
<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
type="button"
class="remove"
@@ -303,6 +312,11 @@
font-weight: var(--font-weight-medium);
}
.shared {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.remove {
cursor: pointer;
display: flex;
@@ -0,0 +1,122 @@
<script lang="ts">
import { ripple } from "$lib/actions/ripple";
import type { DiscoverItem } from "$lib/api/types";
import Avatar from "$lib/components/ui/Avatar.svelte";
interface Props {
item: DiscoverItem;
onclick: () => void;
}
let { item, onclick }: Props = $props();
const title = $derived(
item.title ?? (item.username ? `@${item.username}` : `Чат ${item.chat_id}`)
);
const avatarKind = $derived(item.chat_id > 0 ? "peer" : "chat");
const badge = $derived.by(() => {
if (item.is_bot) {
return "Бот";
}
if (item.kind === "channel") {
return "Канал";
}
if (item.kind === "group") {
return "Группа";
}
return item.is_contact ? "Контакт" : "Пользователь";
});
const status = $derived.by(() => {
if (item.message_count > 0) {
return `${item.message_count} сообщений`;
}
if (item.tracked) {
return "Отслеживается";
}
return "Нет в архиве";
});
</script>
<button type="button" class="Discover ListItem-button" use:ripple {onclick}>
<Avatar
name={title}
colorKey={item.chat_id}
avatar={{ kind: avatarKind, id: item.chat_id }}
hasAvatar={item.has_avatar}
/>
<div class="info">
<h3 class="title">{title}</h3>
<div class="subtitle">
<span class="badge">{badge}</span>
<span class="dot">·</span>
<span>{status}</span>
{#if item.username && item.title}
<span class="dot">·</span>
<span>@{item.username}</span>
{/if}
</div>
</div>
</button>
<style lang="scss">
.Discover {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.info {
overflow: hidden;
flex: 1;
}
.title {
overflow: hidden;
margin: 0;
font-weight: var(--font-weight-medium);
font-size: 1rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.subtitle {
display: flex;
gap: 0.25rem;
overflow: hidden;
font-size: 0.875rem;
color: var(--color-text-secondary);
white-space: nowrap;
text-overflow: ellipsis;
}
.badge {
color: var(--color-primary);
}
.dot {
opacity: 0.6;
}
</style>
@@ -2,6 +2,7 @@
import { goto } from "$app/navigation";
import { page } from "$app/state";
import ChatListItem from "$lib/components/ChatListItem.svelte";
import DiscoverResultItem from "$lib/components/search/DiscoverResultItem.svelte";
import SearchMessageItem from "$lib/components/search/SearchMessageItem.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
@@ -13,8 +14,11 @@
);
const hasChats = $derived(search.chatHits.length > 0);
const hasPeers = $derived(search.peerHits.length > 0);
const hasMessages = $derived(search.messageHits.length > 0);
const empty = $derived(!(search.loading || hasChats || hasMessages));
const empty = $derived(
!(search.loading || hasChats || hasPeers || hasMessages)
);
function openChat(chatId: number) {
search.close();
@@ -40,6 +44,13 @@
{/each}
{/if}
{#if hasPeers}
<div class="section-label">Контакты и каналы</div>
{#each search.peerHits as item (item.chat_id)}
<DiscoverResultItem {item} onclick={() => openChat(item.chat_id)} />
{/each}
{/if}
{#if search.loading && !hasMessages}
<div class="loading"><Spinner /></div>
{:else if hasMessages}
@@ -0,0 +1,258 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { ApiError } from "$lib/api/client";
import {
cancelLogin,
pollQrLogin,
startLogin,
startQrLogin,
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 QrCode from "$lib/components/ui/QrCode.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
type Step = "qr" | "phone" | "code" | "password";
let { open = $bindable(false) }: { open?: boolean } = $props();
let step = $state<Step>("qr");
let loginId = $state("");
let qrUrl = $state("");
let phone = $state("");
let code = $state("");
let password = $state("");
let busy = $state(false);
let attempt = 0;
const hints: Record<Step, string> = {
qr: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
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;
});
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;
}
function fail(error: unknown, fallback: string) {
toasts.error(error instanceof ApiError ? error.detail : fallback);
}
function drop() {
attempt += 1;
if (loginId) {
cancelLogin(loginId).catch(() => undefined);
loginId = "";
}
}
async function watchQr() {
const mine = attempt;
try {
let state = await startQrLogin();
while (mine === attempt && state.stage === "qr") {
loginId = state.login_id;
qrUrl = state.qr_url ?? "";
state = await pollQrLogin(state.login_id);
}
if (mine === attempt) {
await apply(state);
}
} catch (error) {
if (mine === attempt) {
fail(error, "Не удалось получить QR-код");
}
}
}
function useQr() {
drop();
step = "qr";
qrUrl = "";
watchQr();
}
function usePhone() {
drop();
step = "phone";
qrUrl = "";
}
function begin() {
attempt += 1;
loginId = "";
phone = "";
code = "";
password = "";
useQr();
}
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 submit(event: SubmitEvent) {
event.preventDefault();
if (busy || !filled) {
return;
}
busy = true;
try {
await apply(await next());
} catch (error) {
fail(error, "Не удалось войти");
} finally {
busy = false;
}
}
function onOpenChange(value: boolean) {
if (!value) {
drop();
}
}
$effect(() => {
if (open) {
untrack(begin);
}
});
</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>
{#if step === "qr"}
<div class="dialog-body">
<p class="hint">{hints.qr}</p>
<div class="qr-slot">
{#if qrUrl}
<QrCode value={qrUrl} label="QR-код для входа в Telegram" />
{/if}
</div>
</div>
<div class="dialog-actions">
<Button variant="text" pill onclick={usePhone}>
Войти по номеру телефона
</Button>
</div>
{:else}
<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">
{#if step === "phone"}
<Button variant="text" pill onclick={useQr}>QR-код</Button>
{/if}
<Button
type="submit"
pill
loading={busy}
disabled={busy || !filled}
>
{step === "phone" ? "Отправить код" : "Продолжить"}
</Button>
</div>
</form>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.hint {
margin: 0 0 1.5rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
.qr-slot {
display: grid;
place-items: center;
width: min(15rem, 100%);
aspect-ratio: 1;
margin: 0 auto;
background: var(--color-background-secondary);
border-radius: 0.75rem;
}
</style>
@@ -1,9 +1,15 @@
<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";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accountName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
const current = $derived(accounts.selected);
const others = $derived(
@@ -11,6 +17,58 @@
(account) => account.account_id !== accounts.selectedId
)
);
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;
}
busy = true;
try {
await accounts.logout(current.account_id);
confirming = false;
toasts.success("Сессия завершена");
} catch {
toasts.error("Не удалось выйти из аккаунта");
} finally {
busy = false;
}
}
</script>
<div class="my-account">
@@ -25,6 +83,9 @@
{#if current.phone}
<div class="phone">+{current.phone}</div>
{/if}
{#if !current.is_active}
<div class="inactive">Сессия завершена, доступен только архив</div>
{/if}
</div>
{/if}
@@ -43,13 +104,124 @@
size={2.25}
/>
<span>{accountName(account)}</span>
{#if !account.is_active}
<span class="tag">архив</span>
{/if}
<Icon name="arrow-right" size="1rem" class="chevron" />
</button>
{/each}
</div>
{/if}
<div class="actions">
{#if current?.is_active}
<SettingsItem
icon="active-sessions"
label="Имя устройства"
value={deviceLabel}
onclick={openRename}
/>
{/if}
<SettingsItem
icon="add-user"
label="Добавить аккаунт"
onclick={() => {
adding = true;
}}
/>
{#if current?.is_active}
<SettingsItem
icon="logout"
label="Выйти из аккаунта"
onclick={() => {
confirming = true;
}}
/>
{/if}
</div>
</div>
<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" />
<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">
.profile {
display: flex;
@@ -71,6 +243,11 @@
color: var(--color-text-secondary);
}
.inactive {
font-size: 0.8125rem;
color: var(--color-error);
}
.switch-row {
cursor: pointer;
position: relative;
@@ -99,6 +276,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) {
flex-shrink: 0;
color: var(--color-icon-secondary);
@@ -0,0 +1,40 @@
<script lang="ts">
import { encode } from "uqr";
const QUIET_ZONE = 2;
let { value, label }: { value: string; label: string } = $props();
const code = $derived.by(() => {
const qr = encode(value, { ecc: "M", border: 0 });
const path = qr.data
.flatMap((row, y) =>
row.flatMap((filled, x) => (filled ? [`M${x} ${y}h1v1h-1z`] : []))
)
.join("");
return { extent: qr.size + QUIET_ZONE * 2, path };
});
</script>
<svg
class="qr"
viewBox="0 0 {code.extent} {code.extent}"
role="img"
aria-label={label}
>
<rect width={code.extent} height={code.extent} fill="var(--qr-bg, #fff)" />
<path
d={code.path}
fill="var(--qr-fg, #000)"
transform="translate({QUIET_ZONE} {QUIET_ZONE})"
/>
</svg>
<style lang="scss">
.qr {
display: block;
width: 100%;
height: auto;
border-radius: 0.75rem;
}
</style>
+34 -14
View File
@@ -1,5 +1,9 @@
import { browser } from "$app/environment";
import { listAccounts } from "$lib/api/endpoints";
import {
listAccounts,
logoutAccount,
renameAccountDevice,
} from "$lib/api/endpoints";
import type { Account } from "$lib/api/types";
const STORAGE_KEY = "bg.account";
@@ -32,6 +36,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 {
get list() {
return list;
@@ -45,20 +65,20 @@ function createAccounts() {
get loaded() {
return loaded;
},
async 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;
selectedId = fallback ? fallback.account_id : null;
persist(selectedId);
}
load,
select,
async renameDevice(id: number, deviceModel: string) {
const updated = await renameAccountDevice(id, deviceModel);
list = list.map((account) =>
account.account_id === id ? updated : account
);
},
select(id: number) {
selectedId = id;
persist(id);
async logout(id: number) {
await logoutAccount(id);
if (id === selectedId) {
select(null);
}
await load();
},
};
}
@@ -0,0 +1,41 @@
import { getDiscoverItem } from "$lib/api/endpoints";
import type { DiscoverItem } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
function createDiscover() {
let items = $state<Record<number, DiscoverItem>>({});
let account: number | null = null;
const pending = new Set<number>();
function syncAccount() {
if (accounts.selectedId !== account) {
account = accounts.selectedId;
items = {};
pending.clear();
}
}
return {
get(chatId: number): DiscoverItem | undefined {
return items[chatId];
},
set(item: DiscoverItem) {
items = { ...items, [item.chat_id]: item };
},
async ensure(chatId: number) {
syncAccount();
if (account === null || items[chatId] || pending.has(chatId)) {
return;
}
pending.add(chatId);
try {
const item = await getDiscoverItem(chatId);
items = { ...items, [chatId]: item };
} finally {
pending.delete(chatId);
}
},
};
}
export const discover = createDiscover();
+46 -14
View File
@@ -1,16 +1,18 @@
import { searchMessages } from "$lib/api/endpoints";
import type { SearchHit } from "$lib/api/types";
import { discoverPeers, searchMessages } from "$lib/api/endpoints";
import type { DiscoverItem, SearchHit } from "$lib/api/types";
import { chats } from "$lib/stores/chats.svelte";
const DEBOUNCE_MS = 250;
const REMOTE_DEBOUNCE_MS = 700;
const MIN_LENGTH = 1;
function createSearch() {
let active = $state(false);
let query = $state("");
let messageHits = $state<SearchHit[]>([]);
let peerResults = $state<DiscoverItem[]>([]);
let loading = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let timers: ReturnType<typeof setTimeout>[] = [];
let seq = 0;
const trimmed = $derived(query.trim());
@@ -23,9 +25,12 @@ function createSearch() {
(chat.title ?? "").toLowerCase().includes(needle)
);
});
const peerHits = $derived.by(() => {
const shown = new Set(chatHits.map((chat) => chat.chat_id));
return peerResults.filter((item) => !shown.has(item.chat_id));
});
async function run(value: string) {
const current = ++seq;
async function runMessages(value: string, current: number) {
try {
const hits = await searchMessages(value);
if (current === seq) {
@@ -42,21 +47,46 @@ function createSearch() {
}
}
function schedule() {
if (timer) {
async function runPeers(value: string, current: number, remote: boolean) {
try {
const items = await discoverPeers(value, remote);
if (current === seq) {
peerResults = items;
}
} catch {
if (current === seq && !remote) {
peerResults = [];
}
}
}
function clearTimers() {
for (const timer of timers) {
clearTimeout(timer);
}
timers = [];
}
function schedule() {
clearTimers();
const value = trimmed;
const current = ++seq;
if (value.length < MIN_LENGTH) {
seq++;
messageHits = [];
peerResults = [];
loading = false;
return;
}
loading = true;
timer = setTimeout(() => {
run(value).catch(() => undefined);
}, DEBOUNCE_MS);
timers.push(
setTimeout(() => {
runMessages(value, current).catch(() => undefined);
runPeers(value, current, false).catch(() => undefined);
}, DEBOUNCE_MS),
setTimeout(() => {
runPeers(value, current, true).catch(() => undefined);
}, REMOTE_DEBOUNCE_MS)
);
}
return {
@@ -78,6 +108,9 @@ function createSearch() {
get chatHits() {
return chatHits;
},
get peerHits() {
return peerHits;
},
open() {
active = true;
},
@@ -89,11 +122,10 @@ function createSearch() {
active = false;
query = "";
messageHits = [];
peerResults = [];
loading = false;
seq++;
if (timer) {
clearTimeout(timer);
}
clearTimers();
},
};
}
+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 "spacing";
@use "forms";
@use "dialogs";
@use "dark-theme";
html,