Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ad62fb59 | ||
|
|
f5a8649855 | ||
|
|
23c1504001 | ||
|
|
92e5e737d2 | ||
|
|
f32b772ddc | ||
|
|
516b8e307f | ||
|
|
33ceea3dfc | ||
|
|
4ce9c5d340 | ||
|
|
42469eb9f1 | ||
|
|
3373c31a02 | ||
|
|
17e27beb59 | ||
|
|
54424d0aa0 | ||
|
|
50922ef69a | ||
|
|
a1d3552519 | ||
|
|
3460246749 | ||
|
|
3d2f670e87 | ||
|
|
305de9f871 |
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
_commit: v0.7.1
|
_commit: v0.6.0
|
||||||
_src_path: https://git.kotikot.com/templates/backend-python.git
|
_src_path: https://git.kotikot.com/templates/backend-python.git
|
||||||
author_email: h@kotikot.com
|
author_email: h@kotikot.com
|
||||||
author_name: h
|
author_name: h
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
_commit: v0.8.0
|
_commit: v0.7.0
|
||||||
_src_path: https://git.kotikot.com/templates/frontend-svelte.git
|
_src_path: https://git.kotikot.com/templates/frontend-svelte.git
|
||||||
install_deps: false
|
install_deps: false
|
||||||
layout: part
|
layout: part
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim
|
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:7dd72af08bdef012fdf433082190d2b5133a96b6a43aca0bb71c2aa793e47e20
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""capture reliability
|
||||||
|
|
||||||
|
Revision ID: f8c2d6e1a4b7
|
||||||
|
Revises: c9e2a7f4b1d8
|
||||||
|
Create Date: 2026-10-19 03:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "f8c2d6e1a4b7"
|
||||||
|
down_revision: str | None = "c9e2a7f4b1d8"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"jobs", sa.Column("run_after", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE accounts "
|
||||||
|
"ADD COLUMN catchup_pts integer, "
|
||||||
|
"ADD COLUMN catchup_qts integer, "
|
||||||
|
"ADD COLUMN catchup_date integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE accounts "
|
||||||
|
"DROP COLUMN IF EXISTS catchup_pts, "
|
||||||
|
"DROP COLUMN IF EXISTS catchup_qts, "
|
||||||
|
"DROP COLUMN IF EXISTS catchup_date"
|
||||||
|
)
|
||||||
|
op.drop_column("jobs", "run_after")
|
||||||
@@ -5,8 +5,8 @@ from typing import Any
|
|||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.env import env
|
|
||||||
from utils.events import BG_EVENTS_CHANNEL
|
from utils.events import BG_EVENTS_CHANNEL
|
||||||
|
from utils.listen import Listener
|
||||||
from utils.read import chats as chats_read
|
from utils.read import chats as chats_read
|
||||||
from utils.read import presence as presence_read
|
from utils.read import presence as presence_read
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ class EventHub:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._subscribers: set[Subscriber] = set()
|
self._subscribers: set[Subscriber] = set()
|
||||||
self._pool: asyncpg.Pool | None = None
|
self._pool: asyncpg.Pool | None = None
|
||||||
self._conn: asyncpg.Connection | None = None
|
self._listener = Listener("realtime", {BG_EVENTS_CHANNEL: self._on_notify})
|
||||||
self._tasks: set[asyncio.Task] = set()
|
self._tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
def subscribe(self, account_id: int, chat_id: int | None) -> Subscriber:
|
def subscribe(self, account_id: int, chat_id: int | None) -> Subscriber:
|
||||||
@@ -39,21 +39,14 @@ class EventHub:
|
|||||||
|
|
||||||
async def start(self, pool: asyncpg.Pool) -> None:
|
async def start(self, pool: asyncpg.Pool) -> None:
|
||||||
self._pool = pool
|
self._pool = pool
|
||||||
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
await self._listener.start()
|
||||||
await conn.add_listener(BG_EVENTS_CHANNEL, self._on_notify)
|
|
||||||
self._conn = conn
|
|
||||||
logger.info("Realtime hub listening on %s", BG_EVENTS_CHANNEL)
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
for task in self._tasks:
|
for task in self._tasks:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
if self._conn is not None:
|
await self._listener.stop()
|
||||||
await self._conn.close()
|
|
||||||
self._conn = None
|
|
||||||
|
|
||||||
def _on_notify(
|
def _on_notify(self, payload: str) -> None:
|
||||||
self, _conn: asyncpg.Connection, _pid: int, _channel: str, payload: str
|
|
||||||
) -> None:
|
|
||||||
task = asyncio.create_task(self._dispatch(payload))
|
task = asyncio.create_task(self._dispatch(payload))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(self._tasks.discard)
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ async def on_deleted_messages(client: PyroClient, messages: list[Message]) -> No
|
|||||||
box.append(message.id)
|
box.append(message.id)
|
||||||
else:
|
else:
|
||||||
channels.setdefault(chat_id, []).append(message.id)
|
channels.setdefault(chat_id, []).append(message.id)
|
||||||
|
ctx.pending_deletes.remember(None, box)
|
||||||
|
for chat_id, ids in channels.items():
|
||||||
|
ctx.pending_deletes.remember(chat_id, ids)
|
||||||
if box:
|
if box:
|
||||||
await repository.mark_deleted_box(ctx.pool, ctx.account_id, box)
|
await repository.mark_deleted_box(ctx.pool, ctx.account_id, box)
|
||||||
await notify_bg_event(ctx.pool, "delete", ctx.account_id, message_ids=box)
|
await notify_bg_event(ctx.pool, "delete", ctx.account_id, message_ids=box)
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ from pyrogram.types import Message
|
|||||||
|
|
||||||
from userbot.modules.capture import repository
|
from userbot.modules.capture import repository
|
||||||
from userbot.modules.capture.chat_meta import meta_from_chat
|
from userbot.modules.capture.chat_meta import meta_from_chat
|
||||||
from userbot.modules.capture.message import sender_id
|
from userbot.modules.capture.message import message_row
|
||||||
from userbot.modules.client import PyroClient
|
from userbot.modules.client import PyroClient
|
||||||
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
|
from userbot.modules.media import capture_media
|
||||||
from userbot.modules.raw_json import raw_json
|
|
||||||
from utils.events import notify_bg_event
|
from utils.events import notify_bg_event
|
||||||
|
|
||||||
|
|
||||||
@@ -27,18 +26,7 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
|
|||||||
if not toggles.track_edits_deletes:
|
if not toggles.track_edits_deletes:
|
||||||
return
|
return
|
||||||
changed = await repository.add_version(
|
changed = await repository.add_version(
|
||||||
ctx.pool,
|
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
|
||||||
ctx.account_id,
|
|
||||||
chat_id,
|
|
||||||
message.id,
|
|
||||||
message.date,
|
|
||||||
sender_id(message),
|
|
||||||
message.text or message.caption,
|
|
||||||
raw_json(message),
|
|
||||||
message.edit_date,
|
|
||||||
media_unique_id(message),
|
|
||||||
has_media=message.media is not None,
|
|
||||||
is_self_destruct=self_destruct_ttl(message) is not None,
|
|
||||||
)
|
)
|
||||||
if not changed:
|
if not changed:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -13,18 +13,19 @@ async def on_user_status(client: PyroClient, user: User) -> None:
|
|||||||
ctx = client.capture
|
ctx = client.capture
|
||||||
if ctx is None or user.status is None:
|
if ctx is None or user.status is None:
|
||||||
return
|
return
|
||||||
await repository.insert_status(
|
if ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).presence:
|
||||||
ctx.pool,
|
await repository.insert_status(
|
||||||
ctx.account_id,
|
ctx.pool,
|
||||||
user.id,
|
ctx.account_id,
|
||||||
datetime.now(UTC),
|
user.id,
|
||||||
user.status.name.lower(),
|
datetime.now(UTC),
|
||||||
user.last_online_date,
|
user.status.name.lower(),
|
||||||
user.next_offline_date,
|
user.last_online_date,
|
||||||
raw_json(user.raw),
|
user.next_offline_date,
|
||||||
)
|
raw_json(user.raw),
|
||||||
|
)
|
||||||
|
await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id)
|
||||||
await ctx.watches.on_status(user.id, is_online=user.status.name.lower() == "online")
|
await ctx.watches.on_status(user.id, is_online=user.status.name.lower() == "online")
|
||||||
await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id)
|
|
||||||
|
|
||||||
|
|
||||||
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
|
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
|
||||||
@@ -25,8 +25,14 @@ async def _handle_user(
|
|||||||
current = await get_peer(ctx.pool, ctx.account_id, update.user_id)
|
current = await get_peer(ctx.pool, ctx.account_id, update.user_id)
|
||||||
if current == fields:
|
if current == fields:
|
||||||
return
|
return
|
||||||
|
is_bot = bool(getattr(raw_user, "bot", False))
|
||||||
await write_profile(
|
await write_profile(
|
||||||
ctx.pool, ctx.account_id, update.user_id, fields, raw_json(raw_user)
|
ctx.pool,
|
||||||
|
ctx.account_id,
|
||||||
|
update.user_id,
|
||||||
|
fields,
|
||||||
|
raw_json(raw_user),
|
||||||
|
history=ctx.resolve_peer(update.user_id, is_bot=is_bot).profile_history,
|
||||||
)
|
)
|
||||||
changed_photo = current is None or current.photo_unique_id != photo_unique_id
|
changed_photo = current is None or current.photo_unique_id != photo_unique_id
|
||||||
if photo_file_id and photo_unique_id and changed_photo:
|
if photo_file_id and photo_unique_id and changed_photo:
|
||||||
@@ -51,7 +57,14 @@ async def _merge_partial(
|
|||||||
merged = replace(current or ProfileFields(), **overrides)
|
merged = replace(current or ProfileFields(), **overrides)
|
||||||
if merged == current:
|
if merged == current:
|
||||||
return
|
return
|
||||||
await write_profile(ctx.pool, ctx.account_id, peer_id, merged, raw_str)
|
await write_profile(
|
||||||
|
ctx.pool,
|
||||||
|
ctx.account_id,
|
||||||
|
peer_id,
|
||||||
|
merged,
|
||||||
|
raw_str,
|
||||||
|
history=ctx.resolve_peer(peer_id).profile_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def handle(
|
async def handle(
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
from pyrogram.types import Story
|
from pyrogram.types import Story
|
||||||
|
|
||||||
from userbot.modules.client import PyroClient
|
from userbot.modules.client import PyroClient
|
||||||
from userbot.modules.stories.service import save_story
|
from userbot.modules.stories.service import save_story, story_peer_id
|
||||||
|
|
||||||
|
|
||||||
@PyroClient.on_story()
|
@PyroClient.on_story()
|
||||||
async def on_story(client: PyroClient, story: Story) -> None:
|
async def on_story(client: PyroClient, story: Story) -> None:
|
||||||
if client.capture is None:
|
ctx = client.capture
|
||||||
|
if ctx is None:
|
||||||
return
|
return
|
||||||
await save_story(client, client.capture, story)
|
peer_id = story_peer_id(story)
|
||||||
|
is_bot = bool(story.from_user and story.from_user.is_bot)
|
||||||
|
if peer_id > 0 and not ctx.resolve_peer(peer_id, is_bot=is_bot).stories:
|
||||||
|
return
|
||||||
|
await save_story(client, ctx, story)
|
||||||
|
|
||||||
|
|
||||||
handlers = on_story.handlers # ty: ignore[unresolved-attribute]
|
handlers = on_story.handlers # ty: ignore[unresolved-attribute]
|
||||||
@@ -2,10 +2,11 @@ import asyncpg
|
|||||||
from pyrogram import Client
|
from pyrogram import Client
|
||||||
|
|
||||||
from userbot.modules.capture.identity import ChatMetaCache, PeerIdentityCache
|
from userbot.modules.capture.identity import ChatMetaCache, PeerIdentityCache
|
||||||
|
from userbot.modules.capture.pending import PendingDeletes
|
||||||
from userbot.modules.contacts import ContactCache
|
from userbot.modules.contacts import ContactCache
|
||||||
from userbot.modules.folders import FolderCache
|
from userbot.modules.folders import FolderCache
|
||||||
from userbot.modules.watches import WatchCache
|
from userbot.modules.watches import WatchCache
|
||||||
from utils.policy.models import CaptureToggles, ChatMeta, PolicySet
|
from utils.policy.models import CaptureToggles, ChatKind, ChatMeta, PolicySet
|
||||||
from utils.policy.repository import load_policy_set
|
from utils.policy.repository import load_policy_set
|
||||||
from utils.policy.resolver import resolve
|
from utils.policy.resolver import resolve
|
||||||
from utils.storage import ContentAddressedStorage
|
from utils.storage import ContentAddressedStorage
|
||||||
@@ -28,6 +29,7 @@ class CaptureContext:
|
|||||||
self.watches = WatchCache(pool, account_id)
|
self.watches = WatchCache(pool, account_id)
|
||||||
self.peer_identity = PeerIdentityCache()
|
self.peer_identity = PeerIdentityCache()
|
||||||
self.chat_meta = ChatMetaCache()
|
self.chat_meta = ChatMetaCache()
|
||||||
|
self.pending_deletes = PendingDeletes()
|
||||||
self.policies = PolicySet()
|
self.policies = PolicySet()
|
||||||
|
|
||||||
async def reload_policies(self) -> None:
|
async def reload_policies(self) -> None:
|
||||||
@@ -36,6 +38,16 @@ class CaptureContext:
|
|||||||
def resolve(self, chat: ChatMeta) -> CaptureToggles:
|
def resolve(self, chat: ChatMeta) -> CaptureToggles:
|
||||||
return resolve(chat, self.folders.folders, self.policies)
|
return resolve(chat, self.folders.folders, self.policies)
|
||||||
|
|
||||||
|
def resolve_peer(self, peer_id: int, *, is_bot: bool = False) -> CaptureToggles:
|
||||||
|
return self.resolve(
|
||||||
|
ChatMeta(
|
||||||
|
chat_id=peer_id,
|
||||||
|
kind=ChatKind.DM,
|
||||||
|
is_bot=is_bot,
|
||||||
|
is_contact=peer_id in self.contacts.ids,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def build_capture_context(
|
async def build_capture_context(
|
||||||
client: Client,
|
client: Client,
|
||||||
|
|||||||
@@ -58,7 +58,14 @@ async def _capture_peer(message: Message, ctx: CaptureContext) -> None:
|
|||||||
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||||
if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields):
|
if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields):
|
||||||
return
|
return
|
||||||
await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
|
await write_profile(
|
||||||
|
ctx.pool,
|
||||||
|
ctx.account_id,
|
||||||
|
user.id,
|
||||||
|
fields,
|
||||||
|
raw_json(user),
|
||||||
|
history=ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).profile_history,
|
||||||
|
)
|
||||||
if photo_file_id and photo_unique_id:
|
if photo_file_id and photo_unique_id:
|
||||||
from userbot.modules.avatars import note_avatar # noqa: PLC0415
|
from userbot.modules.avatars import note_avatar # noqa: PLC0415
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from pyrogram import Client
|
from pyrogram import Client
|
||||||
from pyrogram.types import Message
|
from pyrogram.types import Message
|
||||||
|
|
||||||
from userbot.modules.capture import repository
|
from userbot.modules.capture import repository
|
||||||
from userbot.modules.capture.context import CaptureContext
|
from userbot.modules.capture.context import CaptureContext
|
||||||
|
from userbot.modules.capture.repository import MessageRow
|
||||||
from userbot.modules.links import extract_links
|
from userbot.modules.links import extract_links
|
||||||
from userbot.modules.links import repository as links_repository
|
from userbot.modules.links import repository as links_repository
|
||||||
from userbot.modules.media import capture_media, self_destruct_ttl
|
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
|
||||||
from userbot.modules.raw_json import raw_json
|
from userbot.modules.raw_json import raw_json
|
||||||
from utils.policy.models import CaptureToggles
|
from utils.policy.models import CaptureToggles
|
||||||
|
|
||||||
@@ -34,27 +37,39 @@ def callbacks(message: Message) -> list[tuple[int, str | None, bytes | None]]:
|
|||||||
return buttons
|
return buttons
|
||||||
|
|
||||||
|
|
||||||
|
def message_row(message: Message, chat_id: int, date: datetime) -> MessageRow:
|
||||||
|
return MessageRow(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
date=date,
|
||||||
|
sender_id=sender_id(message),
|
||||||
|
text=message.text or message.caption,
|
||||||
|
raw=raw_json(message),
|
||||||
|
has_media=message.media is not None,
|
||||||
|
is_self_destruct=self_destruct_ttl(message) is not None,
|
||||||
|
edit_date=message.edit_date,
|
||||||
|
media_unique_id=media_unique_id(message),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def capture_message(
|
async def capture_message(
|
||||||
client: Client, message: Message, ctx: CaptureContext, toggles: CaptureToggles
|
client: Client, message: Message, ctx: CaptureContext, toggles: CaptureToggles
|
||||||
) -> None:
|
) -> None:
|
||||||
if message.empty or message.chat is None or message.date is None:
|
if message.empty or message.chat is None or message.date is None:
|
||||||
return
|
return
|
||||||
chat_id = message.chat.id or 0
|
chat_id = message.chat.id or 0
|
||||||
await repository.upsert_message(
|
current = await repository.upsert_message(
|
||||||
ctx.pool,
|
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
|
||||||
ctx.account_id,
|
|
||||||
chat_id,
|
|
||||||
message.id,
|
|
||||||
message.date,
|
|
||||||
sender_id(message),
|
|
||||||
message.text or message.caption,
|
|
||||||
raw_json(message),
|
|
||||||
has_media=message.media is not None,
|
|
||||||
is_self_destruct=self_destruct_ttl(message) is not None,
|
|
||||||
)
|
)
|
||||||
|
if ctx.pending_deletes.take(chat_id, message.id):
|
||||||
|
await repository.mark_deleted_late(
|
||||||
|
ctx.pool, ctx.account_id, chat_id, message.id, message.date
|
||||||
|
)
|
||||||
from userbot.modules.capture.identity import capture_identity # noqa: PLC0415
|
from userbot.modules.capture.identity import capture_identity # noqa: PLC0415
|
||||||
|
|
||||||
await capture_identity(message, ctx)
|
await capture_identity(message, ctx)
|
||||||
|
if not current:
|
||||||
|
return
|
||||||
await capture_media(client, message, ctx, chat_id, message.id, toggles)
|
await capture_media(client, message, ctx, chat_id, message.id, toggles)
|
||||||
buttons = callbacks(message)
|
buttons = callbacks(message)
|
||||||
if buttons:
|
if buttons:
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
|
||||||
|
|
||||||
|
PENDING_DELETE_SECONDS = 120.0
|
||||||
|
PENDING_DELETE_LIMIT = 10_000
|
||||||
|
|
||||||
|
DeleteKey = tuple[int | None, int]
|
||||||
|
|
||||||
|
|
||||||
|
def _key(chat_id: int | None, message_id: int) -> DeleteKey:
|
||||||
|
if chat_id is None or chat_id > CHANNEL_ID_THRESHOLD:
|
||||||
|
return None, message_id
|
||||||
|
return chat_id, message_id
|
||||||
|
|
||||||
|
|
||||||
|
class PendingDeletes:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._expires: dict[DeleteKey, float] = {}
|
||||||
|
|
||||||
|
def remember(self, chat_id: int | None, message_ids: list[int]) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
while self._expires:
|
||||||
|
oldest = next(iter(self._expires))
|
||||||
|
fresh = self._expires[oldest] > now
|
||||||
|
if fresh and len(self._expires) < PENDING_DELETE_LIMIT:
|
||||||
|
break
|
||||||
|
del self._expires[oldest]
|
||||||
|
for message_id in message_ids:
|
||||||
|
key = _key(chat_id, message_id)
|
||||||
|
self._expires.pop(key, None)
|
||||||
|
self._expires[key] = now + PENDING_DELETE_SECONDS
|
||||||
|
|
||||||
|
def take(self, chat_id: int, message_id: int) -> bool:
|
||||||
|
expires = self._expires.pop(_key(chat_id, message_id), None)
|
||||||
|
return expires is not None and expires > time.monotonic()
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
@@ -21,13 +22,19 @@ _TOUCH_EDITED = """
|
|||||||
INSERT INTO messages
|
INSERT INTO messages
|
||||||
(account_id, chat_id, message_id, date, sender_id, text, raw,
|
(account_id, chat_id, message_id, date, sender_id, text, raw,
|
||||||
has_media, is_self_destruct, edited_at)
|
has_media, is_self_destruct, edited_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, now())
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, CASE WHEN $10 THEN now() END)
|
||||||
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
|
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
|
||||||
text = EXCLUDED.text,
|
text = EXCLUDED.text,
|
||||||
raw = EXCLUDED.raw,
|
raw = EXCLUDED.raw,
|
||||||
has_media = EXCLUDED.has_media,
|
has_media = EXCLUDED.has_media,
|
||||||
is_self_destruct = EXCLUDED.is_self_destruct,
|
is_self_destruct = EXCLUDED.is_self_destruct,
|
||||||
edited_at = now()
|
edited_at = coalesce(EXCLUDED.edited_at, messages.edited_at)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LOCK_MESSAGE = """
|
||||||
|
SELECT pg_advisory_xact_lock(
|
||||||
|
hashtextextended(concat_ws(':', 'message', $1::int, $2::bigint, $3::bigint), 0)
|
||||||
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_FIND_DELETED = """
|
_FIND_DELETED = """
|
||||||
@@ -40,6 +47,12 @@ UPDATE messages SET deleted_at = now()
|
|||||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4
|
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_MARK_DELETED_LATE = """
|
||||||
|
UPDATE messages SET deleted_at = now()
|
||||||
|
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
|
||||||
_INSERT_VERSION = """
|
_INSERT_VERSION = """
|
||||||
INSERT INTO message_versions
|
INSERT INTO message_versions
|
||||||
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
||||||
@@ -52,7 +65,7 @@ INSERT INTO message_versions
|
|||||||
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
||||||
SELECT account_id, chat_id, message_id, clock_timestamp(), NULL, text, raw
|
SELECT account_id, chat_id, message_id, clock_timestamp(), NULL, text, raw
|
||||||
FROM messages m
|
FROM messages m
|
||||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
|
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM message_versions v
|
SELECT 1 FROM message_versions v
|
||||||
WHERE v.account_id = m.account_id AND v.chat_id = m.chat_id
|
WHERE v.account_id = m.account_id AND v.chat_id = m.chat_id
|
||||||
@@ -61,15 +74,37 @@ WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
|
|||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_INSERT_PAST_VERSION = """
|
||||||
|
INSERT INTO message_versions
|
||||||
|
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
||||||
|
SELECT $1::int, $2::bigint, $3::bigint,
|
||||||
|
coalesce(
|
||||||
|
(SELECT min(v.observed_at) - interval '1 microsecond'
|
||||||
|
FROM message_versions v
|
||||||
|
WHERE v.account_id = $1 AND v.chat_id = $2 AND v.message_id = $3
|
||||||
|
AND v.edit_date > $4::timestamptz),
|
||||||
|
$4::timestamptz,
|
||||||
|
$5::timestamptz
|
||||||
|
),
|
||||||
|
$4::timestamptz, $6::text, $7::jsonb
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM message_versions v
|
||||||
|
WHERE v.account_id = $1 AND v.chat_id = $2 AND v.message_id = $3
|
||||||
|
AND v.edit_date IS NOT DISTINCT FROM $4::timestamptz
|
||||||
|
AND v.text IS NOT DISTINCT FROM $6::text
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
|
||||||
_CURRENT_CONTENT = """
|
_CURRENT_CONTENT = """
|
||||||
SELECT
|
SELECT
|
||||||
m.text AS text,
|
m.text AS text,
|
||||||
|
m.raw->>'edit_date' AS edit_date,
|
||||||
(SELECT d.unique_id FROM media d
|
(SELECT d.unique_id FROM media d
|
||||||
WHERE d.account_id = m.account_id AND d.chat_id = m.chat_id
|
WHERE d.account_id = m.account_id AND d.chat_id = m.chat_id
|
||||||
AND d.message_id = m.message_id) AS media_unique_id
|
AND d.message_id = m.message_id) AS media_unique_id
|
||||||
FROM messages m
|
FROM messages m
|
||||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
|
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
|
||||||
ORDER BY m.date DESC LIMIT 1
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_CURRENT_MEDIA = """
|
_CURRENT_MEDIA = """
|
||||||
@@ -121,33 +156,110 @@ WHERE evicted_key = $2 OR (unique_id = $1 AND evicted_at IS NOT NULL)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def upsert_message( # noqa: PLR0913
|
@dataclass(frozen=True, slots=True)
|
||||||
pool: asyncpg.Pool,
|
class MessageRow:
|
||||||
account_id: int,
|
chat_id: int
|
||||||
chat_id: int,
|
message_id: int
|
||||||
message_id: int,
|
date: datetime
|
||||||
date: datetime,
|
sender_id: int | None
|
||||||
sender_id: int | None,
|
text: str | None
|
||||||
text: str | None,
|
raw: str
|
||||||
raw: str,
|
has_media: bool
|
||||||
*,
|
is_self_destruct: bool
|
||||||
has_media: bool,
|
edit_date: datetime | None
|
||||||
is_self_destruct: bool,
|
media_unique_id: str | None
|
||||||
) -> None:
|
|
||||||
await pool.execute(
|
@property
|
||||||
_UPSERT_MESSAGE,
|
def columns(self) -> tuple:
|
||||||
account_id,
|
return (
|
||||||
chat_id,
|
self.chat_id,
|
||||||
message_id,
|
self.message_id,
|
||||||
date,
|
self.date,
|
||||||
sender_id,
|
self.sender_id,
|
||||||
text,
|
self.text,
|
||||||
raw,
|
self.raw,
|
||||||
has_media,
|
self.has_media,
|
||||||
is_self_destruct,
|
self.is_self_destruct,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _lock_current(
|
||||||
|
conn: asyncpg.Connection, account_id: int, row: MessageRow
|
||||||
|
) -> asyncpg.Record | None:
|
||||||
|
await conn.execute(_LOCK_MESSAGE, account_id, row.chat_id, row.message_id)
|
||||||
|
return await conn.fetchrow(
|
||||||
|
_CURRENT_CONTENT, account_id, row.chat_id, row.message_id, row.date
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_stale(current: asyncpg.Record, row: MessageRow) -> bool:
|
||||||
|
stored = current["edit_date"]
|
||||||
|
if stored is None:
|
||||||
|
return False
|
||||||
|
if row.edit_date is None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return row.edit_date < datetime.fromisoformat(stored)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_past(
|
||||||
|
conn: asyncpg.Connection, account_id: int, row: MessageRow
|
||||||
|
) -> None:
|
||||||
|
await conn.execute(
|
||||||
|
_INSERT_PAST_VERSION,
|
||||||
|
account_id,
|
||||||
|
row.chat_id,
|
||||||
|
row.message_id,
|
||||||
|
row.edit_date,
|
||||||
|
row.date,
|
||||||
|
row.text,
|
||||||
|
row.raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_edit(
|
||||||
|
conn: asyncpg.Connection, account_id: int, row: MessageRow, *, mark_edited: bool
|
||||||
|
) -> None:
|
||||||
|
await conn.execute(
|
||||||
|
_SNAPSHOT_ORIGINAL, account_id, row.chat_id, row.message_id, row.date
|
||||||
|
)
|
||||||
|
await conn.execute(_TOUCH_EDITED, account_id, *row.columns, mark_edited)
|
||||||
|
await conn.execute(
|
||||||
|
_INSERT_VERSION,
|
||||||
|
account_id,
|
||||||
|
row.chat_id,
|
||||||
|
row.message_id,
|
||||||
|
row.edit_date,
|
||||||
|
row.text,
|
||||||
|
row.raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_message(pool: asyncpg.Pool, account_id: int, row: MessageRow) -> bool:
|
||||||
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
|
current = await _lock_current(conn, account_id, row)
|
||||||
|
if current is None:
|
||||||
|
await conn.execute(_UPSERT_MESSAGE, account_id, *row.columns)
|
||||||
|
return True
|
||||||
|
stored_media = current["media_unique_id"]
|
||||||
|
changed = current["text"] != row.text or (
|
||||||
|
stored_media is not None and stored_media != row.media_unique_id
|
||||||
|
)
|
||||||
|
if _is_stale(current, row):
|
||||||
|
if changed:
|
||||||
|
await _record_past(conn, account_id, row)
|
||||||
|
return False
|
||||||
|
if changed:
|
||||||
|
await _record_edit(
|
||||||
|
conn, account_id, row, mark_edited=row.edit_date is not None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await conn.execute(_UPSERT_MESSAGE, account_id, *row.columns)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def max_message_id(
|
async def max_message_id(
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int
|
pool: asyncpg.Pool, account_id: int, chat_id: int
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
@@ -188,48 +300,29 @@ async def mark_deleted_channel(
|
|||||||
await _mark_deleted(pool, account_id, rows)
|
await _mark_deleted(pool, account_id, rows)
|
||||||
|
|
||||||
|
|
||||||
async def add_version( # noqa: PLR0913
|
async def add_version(pool: asyncpg.Pool, account_id: int, row: MessageRow) -> bool:
|
||||||
pool: asyncpg.Pool,
|
|
||||||
account_id: int,
|
|
||||||
chat_id: int,
|
|
||||||
message_id: int,
|
|
||||||
date: datetime,
|
|
||||||
sender_id: int | None,
|
|
||||||
text: str | None,
|
|
||||||
raw: str,
|
|
||||||
edit_date: datetime | None,
|
|
||||||
media_unique_id: str | None,
|
|
||||||
*,
|
|
||||||
has_media: bool,
|
|
||||||
is_self_destruct: bool,
|
|
||||||
) -> bool:
|
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
current = await conn.fetchrow(_CURRENT_CONTENT, account_id, chat_id, message_id)
|
current = await _lock_current(conn, account_id, row)
|
||||||
text_changed = current is None or current["text"] != text
|
if current is not None:
|
||||||
media_changed = (
|
changed = (
|
||||||
current is not None and current["media_unique_id"] != media_unique_id
|
current["text"] != row.text
|
||||||
)
|
or current["media_unique_id"] != row.media_unique_id
|
||||||
if not (text_changed or media_changed):
|
)
|
||||||
return False
|
if not changed:
|
||||||
await conn.execute(_SNAPSHOT_ORIGINAL, account_id, chat_id, message_id)
|
return False
|
||||||
await conn.execute(
|
if _is_stale(current, row):
|
||||||
_TOUCH_EDITED,
|
await _record_past(conn, account_id, row)
|
||||||
account_id,
|
return False
|
||||||
chat_id,
|
await _record_edit(conn, account_id, row, mark_edited=True)
|
||||||
message_id,
|
|
||||||
date,
|
|
||||||
sender_id,
|
|
||||||
text,
|
|
||||||
raw,
|
|
||||||
has_media,
|
|
||||||
is_self_destruct,
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
_INSERT_VERSION, account_id, chat_id, message_id, edit_date, text, raw
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_deleted_late(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int, date: datetime
|
||||||
|
) -> None:
|
||||||
|
await pool.execute(_MARK_DELETED_LATE, account_id, chat_id, message_id, date)
|
||||||
|
|
||||||
|
|
||||||
async def current_media(
|
async def current_media(
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
||||||
) -> asyncpg.Record | None:
|
) -> asyncpg.Record | None:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from userbot.modules.catchup.service import SavedState, catch_up, read_saved_state
|
||||||
|
|
||||||
|
__all__ = ["SavedState", "catch_up", "read_saved_state"]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
_LOAD = """
|
||||||
|
SELECT catchup_pts, catchup_qts, catchup_date FROM accounts WHERE account_id = $1
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SAVE = """
|
||||||
|
UPDATE accounts SET catchup_pts = $2, catchup_qts = $3, catchup_date = $4
|
||||||
|
WHERE account_id = $1
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CLEAR = """
|
||||||
|
UPDATE accounts SET catchup_pts = NULL, catchup_qts = NULL, catchup_date = NULL
|
||||||
|
WHERE account_id = $1
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UpdateCursor:
|
||||||
|
pts: int
|
||||||
|
qts: int
|
||||||
|
date: int
|
||||||
|
|
||||||
|
|
||||||
|
async def load_cursor(pool: asyncpg.Pool, account_id: int) -> UpdateCursor | None:
|
||||||
|
row = await pool.fetchrow(_LOAD, account_id)
|
||||||
|
if row is None or row["catchup_pts"] is None:
|
||||||
|
return None
|
||||||
|
return UpdateCursor(row["catchup_pts"], row["catchup_qts"], row["catchup_date"])
|
||||||
|
|
||||||
|
|
||||||
|
async def save_cursor(
|
||||||
|
pool: asyncpg.Pool, account_id: int, cursor: UpdateCursor
|
||||||
|
) -> None:
|
||||||
|
await pool.execute(_SAVE, account_id, cursor.pts, cursor.qts, cursor.date)
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_cursor(pool: asyncpg.Pool, account_id: int) -> None:
|
||||||
|
await pool.execute(_CLEAR, account_id)
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import asyncio
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from contextlib import closing
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pyrogram import raw, utils
|
||||||
|
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
|
||||||
|
|
||||||
|
from userbot.modules.capture.chat_meta import meta_from_peer
|
||||||
|
from userbot.modules.capture.context import CaptureContext
|
||||||
|
from userbot.modules.catchup import repository
|
||||||
|
from userbot.modules.catchup.repository import UpdateCursor
|
||||||
|
from userbot.modules.client import PyroClient
|
||||||
|
from utils.logging import logger
|
||||||
|
from utils.policy.models import ChatKind
|
||||||
|
|
||||||
|
BOX_SLICES = 50
|
||||||
|
CHANNEL_SLICES = 10
|
||||||
|
CHANNEL_LIMIT = 100
|
||||||
|
MAX_CHANNELS = 50
|
||||||
|
QUEUE_HIGH_WATER = 200
|
||||||
|
QUEUE_WAIT_SECONDS = 0.5
|
||||||
|
NO_QTS = -1
|
||||||
|
MAX_AGE_SECONDS = 3 * 24 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SavedState:
|
||||||
|
box: UpdateCursor | None = None
|
||||||
|
channels: dict[int, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BoxWalk:
|
||||||
|
cursor: UpdateCursor
|
||||||
|
done: bool = False
|
||||||
|
updates: int = 0
|
||||||
|
channels: dict[int, raw.base.Chat] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def read_saved_state(path: Path) -> SavedState:
|
||||||
|
try:
|
||||||
|
uri = f"{path.resolve().as_uri()}?mode=ro"
|
||||||
|
with closing(sqlite3.connect(uri, uri=True)) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, pts, qts, date FROM update_state"
|
||||||
|
).fetchall()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[yellow]Session {path.stem}: no saved update state ({exc})[/]")
|
||||||
|
return SavedState()
|
||||||
|
box = None
|
||||||
|
channels: dict[int, int] = {}
|
||||||
|
for state_id, pts, qts, date in rows:
|
||||||
|
if pts is None:
|
||||||
|
continue
|
||||||
|
if state_id == 0:
|
||||||
|
box = UpdateCursor(
|
||||||
|
pts, NO_QTS if qts is None else qts, date or int(time.time())
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
channels[state_id] = pts
|
||||||
|
return SavedState(box, channels)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch(
|
||||||
|
client: PyroClient,
|
||||||
|
new_messages: list,
|
||||||
|
other_updates: list,
|
||||||
|
users: list,
|
||||||
|
chats: list,
|
||||||
|
) -> int:
|
||||||
|
await client.fetch_peers(users)
|
||||||
|
await client.fetch_peers(chats)
|
||||||
|
user_map = {user.id: user for user in users}
|
||||||
|
chat_map = {chat.id: chat for chat in chats}
|
||||||
|
queue = client.dispatcher.updates_queue
|
||||||
|
for message in new_messages:
|
||||||
|
update = raw.types.UpdateNewMessage(message=message, pts=0, pts_count=0)
|
||||||
|
queue.put_nowait((update, user_map, chat_map))
|
||||||
|
for update in other_updates:
|
||||||
|
queue.put_nowait((update, user_map, chat_map))
|
||||||
|
while queue.qsize() > QUEUE_HIGH_WATER: # noqa: ASYNC110
|
||||||
|
await asyncio.sleep(QUEUE_WAIT_SECONDS)
|
||||||
|
return len(new_messages) + len(other_updates)
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_hints(other_updates: list, chats: list) -> dict[int, raw.base.Chat]:
|
||||||
|
by_id = {chat.id: chat for chat in chats}
|
||||||
|
return {
|
||||||
|
utils.get_channel_id(update.channel_id): by_id[update.channel_id]
|
||||||
|
for update in other_updates
|
||||||
|
if isinstance(update, raw.types.UpdateChannelTooLong)
|
||||||
|
and update.channel_id in by_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _walk_box(
|
||||||
|
client: PyroClient, ctx: CaptureContext, cursor: UpdateCursor
|
||||||
|
) -> BoxWalk:
|
||||||
|
walk = BoxWalk(cursor)
|
||||||
|
for _ in range(BOX_SLICES):
|
||||||
|
diff = await client.invoke(
|
||||||
|
raw.functions.updates.GetDifference(
|
||||||
|
pts=walk.cursor.pts, date=walk.cursor.date, qts=walk.cursor.qts
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if isinstance(diff, raw.types.updates.DifferenceEmpty):
|
||||||
|
walk.done = True
|
||||||
|
return walk
|
||||||
|
if isinstance(diff, raw.types.updates.DifferenceTooLong):
|
||||||
|
logger.warning(
|
||||||
|
f"[yellow]Account {ctx.account_id}: too much missed to catch up "
|
||||||
|
f"(pts {walk.cursor.pts} to {diff.pts}), that gap stays[/]"
|
||||||
|
)
|
||||||
|
walk.done = True
|
||||||
|
return walk
|
||||||
|
walk.updates += await _dispatch(
|
||||||
|
client, diff.new_messages, diff.other_updates, diff.users, diff.chats
|
||||||
|
)
|
||||||
|
walk.channels.update(_channel_hints(diff.other_updates, diff.chats))
|
||||||
|
final = isinstance(diff, raw.types.updates.Difference)
|
||||||
|
state = diff.state if final else diff.intermediate_state
|
||||||
|
moved = UpdateCursor(state.pts, state.qts, state.date or walk.cursor.date)
|
||||||
|
if moved == walk.cursor and not final:
|
||||||
|
logger.warning(
|
||||||
|
f"[yellow]Account {ctx.account_id}: catch-up stopped advancing "
|
||||||
|
f"at pts={moved.pts}[/]"
|
||||||
|
)
|
||||||
|
return walk
|
||||||
|
walk.cursor = moved
|
||||||
|
await repository.save_cursor(ctx.pool, ctx.account_id, moved)
|
||||||
|
if final:
|
||||||
|
walk.done = True
|
||||||
|
return walk
|
||||||
|
return walk
|
||||||
|
|
||||||
|
|
||||||
|
async def _walk_channel(client: PyroClient, peer_id: int, pts: int) -> int:
|
||||||
|
peer = await client.resolve_peer(peer_id)
|
||||||
|
if not isinstance(peer, raw.types.InputPeerChannel):
|
||||||
|
return 0
|
||||||
|
channel = raw.types.InputChannel(
|
||||||
|
channel_id=peer.channel_id, access_hash=peer.access_hash
|
||||||
|
)
|
||||||
|
updates = 0
|
||||||
|
for _ in range(CHANNEL_SLICES):
|
||||||
|
diff = await client.invoke(
|
||||||
|
raw.functions.updates.GetChannelDifference(
|
||||||
|
channel=channel,
|
||||||
|
filter=raw.types.ChannelMessagesFilterEmpty(),
|
||||||
|
pts=pts,
|
||||||
|
limit=CHANNEL_LIMIT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if isinstance(diff, raw.types.updates.ChannelDifferenceEmpty):
|
||||||
|
return updates
|
||||||
|
if isinstance(diff, raw.types.updates.ChannelDifferenceTooLong):
|
||||||
|
logger.info(f"Channel {peer_id}: too much missed to catch up")
|
||||||
|
return updates
|
||||||
|
updates += await _dispatch(
|
||||||
|
client, diff.new_messages, diff.other_updates, diff.users, diff.chats
|
||||||
|
)
|
||||||
|
if diff.final:
|
||||||
|
return updates
|
||||||
|
pts = diff.pts
|
||||||
|
return updates
|
||||||
|
|
||||||
|
|
||||||
|
def _captured_channels(
|
||||||
|
ctx: CaptureContext, hints: dict[int, raw.base.Chat], saved: dict[int, int]
|
||||||
|
) -> list[int]:
|
||||||
|
wanted: list[tuple[bool, int]] = []
|
||||||
|
for peer_id, chat in hints.items():
|
||||||
|
if peer_id not in saved:
|
||||||
|
continue
|
||||||
|
meta = meta_from_peer(
|
||||||
|
raw.types.PeerChannel(channel_id=chat.id),
|
||||||
|
{chat.id: chat},
|
||||||
|
{},
|
||||||
|
ctx.contacts.ids,
|
||||||
|
)
|
||||||
|
toggles = ctx.resolve(meta)
|
||||||
|
if toggles.messages or toggles.track_edits_deletes:
|
||||||
|
wanted.append((meta.kind is ChatKind.CHANNEL, peer_id))
|
||||||
|
return [peer_id for _, peer_id in sorted(wanted)[:MAX_CHANNELS]]
|
||||||
|
|
||||||
|
|
||||||
|
async def _walk_channels(
|
||||||
|
client: PyroClient, ctx: CaptureContext, walk: BoxWalk, saved: dict[int, int]
|
||||||
|
) -> int:
|
||||||
|
peer_ids = _captured_channels(ctx, walk.channels, saved)
|
||||||
|
updates = 0
|
||||||
|
for peer_id in peer_ids:
|
||||||
|
try:
|
||||||
|
updates += await _walk_channel(client, peer_id, saved[peer_id])
|
||||||
|
except (FloodWait, FloodPremiumWait) as exc:
|
||||||
|
logger.warning(
|
||||||
|
f"[yellow]Account {ctx.account_id}: channel catch-up stopped "
|
||||||
|
f"by a flood wait:[/] {exc}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except RPCError as exc:
|
||||||
|
logger.warning(
|
||||||
|
f"[yellow]Account {ctx.account_id}: channel {peer_id} "
|
||||||
|
f"catch-up failed:[/] {exc}"
|
||||||
|
)
|
||||||
|
skipped = len(walk.channels) - len(peer_ids)
|
||||||
|
if skipped:
|
||||||
|
logger.info(f"Account {ctx.account_id}: {skipped} channel gap(s) left as is")
|
||||||
|
return updates
|
||||||
|
|
||||||
|
|
||||||
|
async def catch_up(client: PyroClient, saved: SavedState) -> None:
|
||||||
|
ctx = client.capture
|
||||||
|
if ctx is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pending = await repository.load_cursor(ctx.pool, ctx.account_id)
|
||||||
|
cursor = pending or saved.box
|
||||||
|
if cursor is None:
|
||||||
|
logger.info(f"Account {ctx.account_id}: no saved update state to catch up")
|
||||||
|
return
|
||||||
|
if time.time() - cursor.date > MAX_AGE_SECONDS:
|
||||||
|
logger.warning(
|
||||||
|
f"[yellow]Account {ctx.account_id}: last update seen too long ago "
|
||||||
|
f"(pts={cursor.pts}), not catching up[/]"
|
||||||
|
)
|
||||||
|
await repository.clear_cursor(ctx.pool, ctx.account_id)
|
||||||
|
return
|
||||||
|
await repository.save_cursor(ctx.pool, ctx.account_id, cursor)
|
||||||
|
logger.info(
|
||||||
|
f"Account {ctx.account_id}: catching up from pts={cursor.pts}"
|
||||||
|
f"{' (resumed)' if pending else ''}"
|
||||||
|
)
|
||||||
|
walk = await _walk_box(client, ctx, cursor)
|
||||||
|
if not walk.done:
|
||||||
|
logger.warning(
|
||||||
|
f"[yellow]Account {ctx.account_id}: catch-up unfinished at "
|
||||||
|
f"pts={walk.cursor.pts},[/] {walk.updates} updates replayed, "
|
||||||
|
f"resumes on the next start"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
from_channels = await _walk_channels(client, ctx, walk, saved.channels)
|
||||||
|
await repository.clear_cursor(ctx.pool, ctx.account_id)
|
||||||
|
logger.info(
|
||||||
|
f"[green]Account {ctx.account_id}: caught up,[/] {walk.updates} updates "
|
||||||
|
f"from the main box, {from_channels} from channels"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"[red]Account {ctx.account_id}: catch-up failed,[/] "
|
||||||
|
f"resumes on the next start"
|
||||||
|
)
|
||||||
@@ -9,11 +9,12 @@ from userbot.modules.jobs import repository
|
|||||||
from userbot.modules.jobs.context import JobContext
|
from userbot.modules.jobs.context import JobContext
|
||||||
from userbot.modules.jobs.registry import JOB_HANDLERS, JobHandler
|
from userbot.modules.jobs.registry import JOB_HANDLERS, JobHandler
|
||||||
from utils.db.models import Job, JobStatus
|
from utils.db.models import Job, JobStatus
|
||||||
from utils.env import env
|
from utils.listen import Listener
|
||||||
from utils.logging import logger
|
from utils.logging import logger
|
||||||
|
|
||||||
JOBS_CHANGED_CHANNEL = "jobs_changed"
|
JOBS_CHANGED_CHANNEL = "jobs_changed"
|
||||||
POLL_INTERVAL_SECONDS = 60.0
|
POLL_INTERVAL_SECONDS = 60.0
|
||||||
|
RESTART_SECONDS = 10.0
|
||||||
|
|
||||||
|
|
||||||
class JobConsumer:
|
class JobConsumer:
|
||||||
@@ -38,25 +39,20 @@ class JobConsumer:
|
|||||||
self.pool, ctx.job_id, JobStatus.FAILED, f"no handler for {job.kind!r}"
|
self.pool, ctx.job_id, JobStatus.FAILED, f"no handler for {job.kind!r}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
while True:
|
try:
|
||||||
try:
|
await handler(ctx)
|
||||||
await handler(ctx)
|
except (FloodWait, FloodPremiumWait) as exc:
|
||||||
except (FloodWait, FloodPremiumWait) as exc:
|
wait = exc.value if isinstance(exc.value, int) else 1
|
||||||
await repository.bump_flood_wait(self.pool, ctx.job_id)
|
await repository.defer_flood_wait(self.pool, ctx.job_id, wait)
|
||||||
wait = exc.value if isinstance(exc.value, int) else 1
|
logger.warning(
|
||||||
logger.warning(
|
f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind}), "
|
||||||
f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind})[/]"
|
f"deferred[/]"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(wait)
|
except Exception as exc:
|
||||||
except Exception as exc:
|
logger.exception(f"Job {ctx.job_id} ({job.kind}) failed")
|
||||||
logger.exception(f"Job {ctx.job_id} ({job.kind}) failed")
|
await repository.finish(self.pool, ctx.job_id, JobStatus.FAILED, str(exc))
|
||||||
await repository.finish(
|
else:
|
||||||
self.pool, ctx.job_id, JobStatus.FAILED, str(exc)
|
await repository.finish(self.pool, ctx.job_id, JobStatus.DONE)
|
||||||
)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
await repository.finish(self.pool, ctx.job_id, JobStatus.DONE)
|
|
||||||
return
|
|
||||||
|
|
||||||
async def drain(self) -> None:
|
async def drain(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
@@ -66,23 +62,41 @@ class JobConsumer:
|
|||||||
await self.execute(job)
|
await self.execute(job)
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self._serve()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"[red]Job consumer for account {self.account_id} failed,[/] "
|
||||||
|
f"restarting in {RESTART_SECONDS:.0f}s"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(RESTART_SECONDS)
|
||||||
|
|
||||||
|
async def _serve(self) -> None:
|
||||||
requeued = await repository.requeue_running(self.pool, self.account_id)
|
requeued = await repository.requeue_running(self.pool, self.account_id)
|
||||||
if requeued:
|
if requeued:
|
||||||
logger.info(f"[yellow]Requeued {requeued} stale running job(s).[/]")
|
logger.info(f"[yellow]Requeued {requeued} stale running job(s).[/]")
|
||||||
pruned = await repository.prune_finished(self.pool, self.account_id)
|
pruned = await repository.prune_finished(self.pool, self.account_id)
|
||||||
if pruned:
|
if pruned:
|
||||||
logger.info(f"[yellow]Pruned {pruned} finished job(s).[/]")
|
logger.info(f"[yellow]Pruned {pruned} finished job(s).[/]")
|
||||||
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
listener = Listener(
|
||||||
await conn.add_listener(JOBS_CHANGED_CHANNEL, lambda *_: self._wake.set())
|
f"jobs {self.account_id}",
|
||||||
|
{JOBS_CHANGED_CHANNEL: lambda _payload: self._wake.set()},
|
||||||
|
self._wake.set,
|
||||||
|
)
|
||||||
|
await listener.start()
|
||||||
logger.info(f"[green]Job consumer running for account {self.account_id}.[/]")
|
logger.info(f"[green]Job consumer running for account {self.account_id}.[/]")
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await self.drain()
|
|
||||||
self._wake.clear()
|
self._wake.clear()
|
||||||
|
await self.drain()
|
||||||
with contextlib.suppress(TimeoutError):
|
with contextlib.suppress(TimeoutError):
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
self._wake.wait(), timeout=POLL_INTERVAL_SECONDS
|
self._wake.wait(), timeout=await self._idle_seconds()
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
await listener.stop()
|
||||||
await conn.close()
|
|
||||||
|
async def _idle_seconds(self) -> float:
|
||||||
|
due = await repository.seconds_to_next_due(self.pool, self.account_id)
|
||||||
|
return POLL_INTERVAL_SECONDS if due is None else min(due, POLL_INTERVAL_SECONDS)
|
||||||
@@ -27,7 +27,14 @@ LIMIT 100
|
|||||||
|
|
||||||
async def _save_user(ctx: CaptureContext, user: User) -> None:
|
async def _save_user(ctx: CaptureContext, user: User) -> None:
|
||||||
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||||
await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
|
await write_profile(
|
||||||
|
ctx.pool,
|
||||||
|
ctx.account_id,
|
||||||
|
user.id,
|
||||||
|
fields,
|
||||||
|
raw_json(user),
|
||||||
|
history=ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).profile_history,
|
||||||
|
)
|
||||||
if photo_file_id and photo_unique_id:
|
if photo_file_id and photo_unique_id:
|
||||||
await note_avatar(
|
await note_avatar(
|
||||||
ctx.pool, ctx.account_id, user.id, "peer", photo_unique_id, photo_file_id
|
ctx.pool, ctx.account_id, user.id, "peer", photo_unique_id, photo_file_id
|
||||||
|
|||||||
@@ -22,7 +22,16 @@ async def sync_contacts(ctx: JobContext) -> None:
|
|||||||
if not isinstance(user, User):
|
if not isinstance(user, User):
|
||||||
continue
|
continue
|
||||||
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||||
await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
|
await write_profile(
|
||||||
|
ctx.pool,
|
||||||
|
ctx.account_id,
|
||||||
|
user.id,
|
||||||
|
fields,
|
||||||
|
raw_json(user),
|
||||||
|
history=capture.resolve_peer(
|
||||||
|
user.id, is_bot=bool(user.is_bot)
|
||||||
|
).profile_history,
|
||||||
|
)
|
||||||
if photo_file_id and photo_unique_id:
|
if photo_file_id and photo_unique_id:
|
||||||
await note_avatar(
|
await note_avatar(
|
||||||
ctx.pool,
|
ctx.pool,
|
||||||
|
|||||||
@@ -34,7 +34,14 @@ async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) ->
|
|||||||
continue
|
continue
|
||||||
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||||
await write_profile(
|
await write_profile(
|
||||||
ctx.pool, ctx.account_id, user.id, fields, raw_json(user)
|
ctx.pool,
|
||||||
|
ctx.account_id,
|
||||||
|
user.id,
|
||||||
|
fields,
|
||||||
|
raw_json(user),
|
||||||
|
history=ctx.resolve_peer(
|
||||||
|
user.id, is_bot=bool(user.is_bot)
|
||||||
|
).profile_history,
|
||||||
)
|
)
|
||||||
if photo_file_id and photo_unique_id:
|
if photo_file_id and photo_unique_id:
|
||||||
await note_avatar(
|
await note_avatar(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async def claim_next(pool: asyncpg.Pool, account_id: int) -> Job | None:
|
|||||||
"updated_at = now(), attempts = attempts + 1 "
|
"updated_at = now(), attempts = attempts + 1 "
|
||||||
"WHERE id = ("
|
"WHERE id = ("
|
||||||
" SELECT id FROM jobs WHERE account_id = $1 AND status = 'pending' "
|
" SELECT id FROM jobs WHERE account_id = $1 AND status = 'pending' "
|
||||||
|
" AND (run_after IS NULL OR run_after <= now()) "
|
||||||
" ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1"
|
" ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1"
|
||||||
") RETURNING *",
|
") RETURNING *",
|
||||||
account_id,
|
account_id,
|
||||||
@@ -76,11 +77,21 @@ async def report_progress(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def bump_flood_wait(pool: asyncpg.Pool, job_id: int) -> None:
|
async def defer_flood_wait(pool: asyncpg.Pool, job_id: int, seconds: int) -> None:
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
"UPDATE jobs SET flood_waits = flood_waits + 1, updated_at = now() "
|
"UPDATE jobs SET status = 'pending', flood_waits = flood_waits + 1, "
|
||||||
"WHERE id = $1",
|
"run_after = now() + make_interval(secs => $2), updated_at = now() "
|
||||||
|
"WHERE id = $1 AND status = 'running'",
|
||||||
job_id,
|
job_id,
|
||||||
|
seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def seconds_to_next_due(pool: asyncpg.Pool, account_id: int) -> float | None:
|
||||||
|
return await pool.fetchval(
|
||||||
|
"SELECT extract(epoch FROM min(run_after) - now())::float FROM jobs "
|
||||||
|
"WHERE account_id = $1 AND status = 'pending' AND run_after > now()",
|
||||||
|
account_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ INSERT INTO links
|
|||||||
(account_id, chat_id, message_id, position, url, kind, web_url, web_title,
|
(account_id, chat_id, message_id, position, url, kind, web_url, web_title,
|
||||||
web_description, web_site_name, raw)
|
web_description, web_site_name, raw)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
|
||||||
|
ON CONFLICT (account_id, chat_id, message_id, position) DO UPDATE SET
|
||||||
|
url = EXCLUDED.url,
|
||||||
|
kind = EXCLUDED.kind,
|
||||||
|
web_url = EXCLUDED.web_url,
|
||||||
|
web_title = EXCLUDED.web_title,
|
||||||
|
web_description = EXCLUDED.web_description,
|
||||||
|
web_site_name = EXCLUDED.web_site_name,
|
||||||
|
raw = EXCLUDED.raw
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,14 @@ async def get_peer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def write_profile(
|
async def write_profile( # noqa: PLR0913
|
||||||
pool: asyncpg.Pool, account_id: int, peer_id: int, fields: ProfileFields, raw: str
|
pool: asyncpg.Pool,
|
||||||
|
account_id: int,
|
||||||
|
peer_id: int,
|
||||||
|
fields: ProfileFields,
|
||||||
|
raw: str,
|
||||||
|
*,
|
||||||
|
history: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
args = (
|
args = (
|
||||||
fields.first_name,
|
fields.first_name,
|
||||||
@@ -63,4 +69,5 @@ async def write_profile(
|
|||||||
)
|
)
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
await conn.execute(_UPSERT_PEER, account_id, peer_id, *args)
|
await conn.execute(_UPSERT_PEER, account_id, peer_id, *args)
|
||||||
await conn.execute(_INSERT_HISTORY, account_id, peer_id, *args)
|
if history:
|
||||||
|
await conn.execute(_INSERT_HISTORY, account_id, peer_id, *args)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from pyrogram.enums import ChatType
|
||||||
from pyrogram.types import Chat
|
from pyrogram.types import Chat
|
||||||
|
|
||||||
from userbot.modules.avatars import note_avatar
|
from userbot.modules.avatars import note_avatar
|
||||||
@@ -13,7 +14,15 @@ from userbot.modules.raw_json import raw_json
|
|||||||
async def save_private(ctx: CaptureContext, chat: Chat) -> bool:
|
async def save_private(ctx: CaptureContext, chat: Chat) -> bool:
|
||||||
chat_id = chat.id or 0
|
chat_id = chat.id or 0
|
||||||
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
|
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
|
||||||
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, raw_json(chat))
|
is_bot = chat.type is ChatType.BOT
|
||||||
|
await write_profile(
|
||||||
|
ctx.pool,
|
||||||
|
ctx.account_id,
|
||||||
|
chat_id,
|
||||||
|
fields,
|
||||||
|
raw_json(chat),
|
||||||
|
history=ctx.resolve_peer(chat_id, is_bot=is_bot).profile_history,
|
||||||
|
)
|
||||||
if photo_file_id and photo_unique_id:
|
if photo_file_id and photo_unique_id:
|
||||||
await note_avatar(
|
await note_avatar(
|
||||||
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
|
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import uvloop
|
|||||||
|
|
||||||
from dependencies.container import container
|
from dependencies.container import container
|
||||||
from userbot.modules.capture import CaptureContext, build_capture_context
|
from userbot.modules.capture import CaptureContext, build_capture_context
|
||||||
|
from userbot.modules.catchup import SavedState, catch_up, read_saved_state
|
||||||
from userbot.modules.client import PyroClient
|
from userbot.modules.client import PyroClient
|
||||||
from userbot.modules.jobs import JobConsumer
|
from userbot.modules.jobs import JobConsumer
|
||||||
from utils.env import env
|
from utils.env import env
|
||||||
from utils.jobs import enqueue
|
from utils.jobs import enqueue
|
||||||
|
from utils.listen import Listener
|
||||||
from utils.logging import logger, setup_logging
|
from utils.logging import logger, setup_logging
|
||||||
from utils.read.accounts import (
|
from utils.read.accounts import (
|
||||||
ACCOUNTS_CHANGED_CHANNEL,
|
ACCOUNTS_CHANGED_CHANNEL,
|
||||||
@@ -28,12 +30,13 @@ from utils.storage import ContentAddressedStorage
|
|||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
SESSION_FLUSH_INTERVAL = 60
|
SESSION_FLUSH_INTERVAL = 60
|
||||||
|
SESSION_RETRY_INTERVAL = 60
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RunningAccount:
|
class RunningAccount:
|
||||||
client: PyroClient
|
client: PyroClient
|
||||||
consumer_task: asyncio.Task
|
tasks: list[asyncio.Task]
|
||||||
device_model: str | None
|
device_model: str | None
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +57,7 @@ class AccountRegistry:
|
|||||||
self._pool = pool
|
self._pool = pool
|
||||||
self._storage = storage
|
self._storage = storage
|
||||||
self._running: dict[str, RunningAccount] = {}
|
self._running: dict[str, RunningAccount] = {}
|
||||||
|
self._saved: dict[str, SavedState] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -86,6 +90,8 @@ class AccountRegistry:
|
|||||||
|
|
||||||
async def _start(self, path: Path, device_model: str | None) -> None:
|
async def _start(self, path: Path, device_model: str | None) -> None:
|
||||||
session_name = path.stem
|
session_name = path.stem
|
||||||
|
if session_name not in self._saved:
|
||||||
|
self._saved[session_name] = await asyncio.to_thread(read_saved_state, path)
|
||||||
client = PyroClient(
|
client = PyroClient(
|
||||||
session_name, workdir=str(path.parent), device_model=device_model
|
session_name, workdir=str(path.parent), device_model=device_model
|
||||||
)
|
)
|
||||||
@@ -100,14 +106,20 @@ class AccountRegistry:
|
|||||||
client, self._pool, self._storage, account_id
|
client, self._pool, self._storage, account_id
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"[red]Failed to start session:[/] {session_name}")
|
logger.exception(
|
||||||
|
f"[red]Failed to start session:[/] {session_name}, "
|
||||||
|
f"retrying in {SESSION_RETRY_INTERVAL}s"
|
||||||
|
)
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await client.stop()
|
await client.stop()
|
||||||
return
|
return
|
||||||
consumer = JobConsumer(client, self._pool, account_id)
|
consumer = JobConsumer(client, self._pool, account_id)
|
||||||
self._running[session_name] = RunningAccount(
|
saved = self._saved.pop(session_name)
|
||||||
client, asyncio.create_task(consumer.run()), device_model
|
tasks = [
|
||||||
)
|
asyncio.create_task(consumer.run()),
|
||||||
|
asyncio.create_task(catch_up(client, saved)),
|
||||||
|
]
|
||||||
|
self._running[session_name] = RunningAccount(client, tasks, device_model)
|
||||||
logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})")
|
logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})")
|
||||||
await enqueue(self._pool, account_id, "sync_dialogs", {})
|
await enqueue(self._pool, account_id, "sync_dialogs", {})
|
||||||
await enqueue(self._pool, account_id, "sync_contacts", {})
|
await enqueue(self._pool, account_id, "sync_contacts", {})
|
||||||
@@ -116,7 +128,8 @@ class AccountRegistry:
|
|||||||
account = self._running.pop(session_name, None)
|
account = self._running.pop(session_name, None)
|
||||||
if account is None:
|
if account is None:
|
||||||
return
|
return
|
||||||
await _cancel(account.consumer_task)
|
for task in account.tasks:
|
||||||
|
await _cancel(task)
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await account.client.stop()
|
await account.client.stop()
|
||||||
logger.info(f"[yellow]Client stopped:[/] {session_name}")
|
logger.info(f"[yellow]Client stopped:[/] {session_name}")
|
||||||
@@ -124,7 +137,8 @@ class AccountRegistry:
|
|||||||
async def _log_out(self, path: Path) -> None:
|
async def _log_out(self, path: Path) -> None:
|
||||||
account = self._running.pop(path.stem, None)
|
account = self._running.pop(path.stem, None)
|
||||||
if account is not None:
|
if account is not None:
|
||||||
await _cancel(account.consumer_task)
|
for task in account.tasks:
|
||||||
|
await _cancel(task)
|
||||||
client = account.client
|
client = account.client
|
||||||
else:
|
else:
|
||||||
client = PyroClient(
|
client = PyroClient(
|
||||||
@@ -148,9 +162,16 @@ async def _flush_sessions(registry: AccountRegistry) -> None:
|
|||||||
await client.storage.save()
|
await client.storage.save()
|
||||||
|
|
||||||
|
|
||||||
async def _listen_changes(
|
async def _retry_sessions(registry: AccountRegistry) -> None:
|
||||||
registry: AccountRegistry, tasks: set[asyncio.Task]
|
while True:
|
||||||
) -> asyncpg.Connection:
|
await asyncio.sleep(SESSION_RETRY_INTERVAL)
|
||||||
|
try:
|
||||||
|
await registry.sync()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[red]Session sync failed[/]")
|
||||||
|
|
||||||
|
|
||||||
|
def _change_listener(registry: AccountRegistry, tasks: set[asyncio.Task]) -> Listener:
|
||||||
def spawn(coro: Coroutine[Any, Any, None]) -> None:
|
def spawn(coro: Coroutine[Any, Any, None]) -> None:
|
||||||
task = asyncio.create_task(coro)
|
task = asyncio.create_task(coro)
|
||||||
tasks.add(task)
|
tasks.add(task)
|
||||||
@@ -163,26 +184,29 @@ async def _listen_changes(
|
|||||||
if client.capture is not None:
|
if client.capture is not None:
|
||||||
spawn(make_coro(client.capture))
|
spawn(make_coro(client.capture))
|
||||||
|
|
||||||
def on_policy(
|
def on_policy(_payload: str) -> None:
|
||||||
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
|
||||||
) -> None:
|
|
||||||
reload(lambda capture: capture.reload_policies())
|
reload(lambda capture: capture.reload_policies())
|
||||||
|
|
||||||
def on_watch(
|
def on_watch(_payload: str) -> None:
|
||||||
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
|
||||||
) -> None:
|
|
||||||
reload(lambda capture: capture.watches.refresh())
|
reload(lambda capture: capture.watches.refresh())
|
||||||
|
|
||||||
def on_accounts(
|
def on_accounts(_payload: str) -> None:
|
||||||
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
|
||||||
) -> None:
|
|
||||||
spawn(registry.sync())
|
spawn(registry.sync())
|
||||||
|
|
||||||
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
def on_reconnect() -> None:
|
||||||
await conn.add_listener("policy_changed", on_policy)
|
on_policy("")
|
||||||
await conn.add_listener(WATCHES_CHANGED_CHANNEL, on_watch)
|
on_watch("")
|
||||||
await conn.add_listener(ACCOUNTS_CHANGED_CHANNEL, on_accounts)
|
on_accounts("")
|
||||||
return conn
|
|
||||||
|
return Listener(
|
||||||
|
"userbot",
|
||||||
|
{
|
||||||
|
"policy_changed": on_policy,
|
||||||
|
WATCHES_CHANGED_CHANNEL: on_watch,
|
||||||
|
ACCOUNTS_CHANGED_CHANNEL: on_accounts,
|
||||||
|
},
|
||||||
|
on_reconnect,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def runner() -> None:
|
async def runner() -> None:
|
||||||
@@ -191,22 +215,22 @@ async def runner() -> None:
|
|||||||
|
|
||||||
registry = AccountRegistry(pool, storage)
|
registry = AccountRegistry(pool, storage)
|
||||||
tasks: set[asyncio.Task] = set()
|
tasks: set[asyncio.Task] = set()
|
||||||
listen_conn: asyncpg.Connection | None = None
|
listener = _change_listener(registry, tasks)
|
||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
||||||
flush = asyncio.create_task(_flush_sessions(registry))
|
flush = asyncio.create_task(_flush_sessions(registry))
|
||||||
|
retry = asyncio.create_task(_retry_sessions(registry))
|
||||||
try:
|
try:
|
||||||
await registry.sync()
|
await registry.sync()
|
||||||
if not registry.clients:
|
if not registry.clients:
|
||||||
logger.warning("[yellow]No sessions yet. Add an account in the web UI.[/]")
|
logger.warning("[yellow]No sessions yet. Add an account in the web UI.[/]")
|
||||||
listen_conn = await _listen_changes(registry, tasks)
|
await listener.start()
|
||||||
logger.info("[green]Userbot running.[/]")
|
logger.info("[green]Userbot running.[/]")
|
||||||
await stop.wait()
|
await stop.wait()
|
||||||
finally:
|
finally:
|
||||||
await _cancel(flush)
|
await _cancel(flush)
|
||||||
if listen_conn is not None:
|
await _cancel(retry)
|
||||||
with contextlib.suppress(Exception):
|
await listener.stop()
|
||||||
await listen_conn.close()
|
|
||||||
await registry.close()
|
await registry.close()
|
||||||
await container.close()
|
await container.close()
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ class Job(SQLModel, table=True):
|
|||||||
finished_at: datetime | None = Field(
|
finished_at: datetime | None = Field(
|
||||||
default=None, sa_column=Column(DateTime(timezone=True))
|
default=None, sa_column=Column(DateTime(timezone=True))
|
||||||
)
|
)
|
||||||
|
run_after: datetime | None = Field(
|
||||||
|
default=None, sa_column=Column(DateTime(timezone=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CapturePolicy(SQLModel, table=True):
|
class CapturePolicy(SQLModel, table=True):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Literal
|
|||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
BG_EVENTS_CHANNEL = "bg_events"
|
BG_EVENTS_CHANNEL = "bg_events"
|
||||||
|
MESSAGE_IDS_PER_EVENT = 500
|
||||||
|
|
||||||
EventKind = Literal["message", "edit", "delete", "reaction", "presence", "receipt"]
|
EventKind = Literal["message", "edit", "delete", "reaction", "presence", "receipt"]
|
||||||
|
|
||||||
@@ -22,8 +23,15 @@ async def notify_bg_event( # noqa: PLR0913
|
|||||||
payload["chat_id"] = chat_id
|
payload["chat_id"] = chat_id
|
||||||
if message_id is not None:
|
if message_id is not None:
|
||||||
payload["message_id"] = message_id
|
payload["message_id"] = message_id
|
||||||
if message_ids is not None:
|
if message_ids is None:
|
||||||
payload["message_ids"] = message_ids
|
await _notify(pool, payload)
|
||||||
|
return
|
||||||
|
for start in range(0, len(message_ids), MESSAGE_IDS_PER_EVENT):
|
||||||
|
chunk = message_ids[start : start + MESSAGE_IDS_PER_EVENT]
|
||||||
|
await _notify(pool, {**payload, "message_ids": chunk})
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify(pool: asyncpg.Pool, payload: dict[str, object]) -> None:
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
"SELECT pg_notify($1, $2)", BG_EVENTS_CHANNEL, json.dumps(payload)
|
"SELECT pg_notify($1, $2)", BG_EVENTS_CHANNEL, json.dumps(payload)
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.env import env
|
||||||
|
from utils.logging import logger
|
||||||
|
|
||||||
|
RETRY_MIN_SECONDS = 1.0
|
||||||
|
RETRY_MAX_SECONDS = 60.0
|
||||||
|
PING_INTERVAL_SECONDS = 60.0
|
||||||
|
PING_TIMEOUT_SECONDS = 10.0
|
||||||
|
CLOSE_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
OnNotify = Callable[[str], None]
|
||||||
|
|
||||||
|
|
||||||
|
class Listener:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
channels: Mapping[str, OnNotify],
|
||||||
|
on_reconnect: Callable[[], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._name = name
|
||||||
|
self._channels = channels
|
||||||
|
self._on_reconnect = on_reconnect
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._ready = asyncio.Event()
|
||||||
|
self._listening = False
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._task = asyncio.create_task(self._run())
|
||||||
|
await self._ready.wait()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._task is None:
|
||||||
|
return
|
||||||
|
self._task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._task
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
async def _run(self) -> None:
|
||||||
|
delay = RETRY_MIN_SECONDS
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self._listen()
|
||||||
|
except Exception as exc:
|
||||||
|
if self._listening:
|
||||||
|
delay = RETRY_MIN_SECONDS
|
||||||
|
logger.warning(
|
||||||
|
f"{self._name}: no listen connection ({exc!r}), "
|
||||||
|
f"reconnecting in {delay:.0f}s"
|
||||||
|
)
|
||||||
|
self._listening = False
|
||||||
|
self._ready.set()
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay = min(delay * 2, RETRY_MAX_SECONDS)
|
||||||
|
|
||||||
|
async def _listen(self) -> None:
|
||||||
|
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||||
|
try:
|
||||||
|
lost = asyncio.Event()
|
||||||
|
conn.add_termination_listener(lambda _conn: lost.set())
|
||||||
|
for channel, on_notify in self._channels.items():
|
||||||
|
await conn.add_listener(
|
||||||
|
channel,
|
||||||
|
lambda _conn, _pid, _channel, payload, f=on_notify: f(payload),
|
||||||
|
)
|
||||||
|
logger.info(f"{self._name}: listening on {', '.join(self._channels)}")
|
||||||
|
self._listening = True
|
||||||
|
if self._ready.is_set() and self._on_reconnect is not None:
|
||||||
|
self._on_reconnect()
|
||||||
|
self._ready.set()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(lost.wait(), PING_INTERVAL_SECONDS)
|
||||||
|
except TimeoutError:
|
||||||
|
await conn.fetchval("SELECT 1", timeout=PING_TIMEOUT_SECONDS)
|
||||||
|
else:
|
||||||
|
msg = "closed by the server"
|
||||||
|
raise ConnectionError(msg)
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await conn.close(timeout=CLOSE_TIMEOUT_SECONDS)
|
||||||
@@ -167,17 +167,33 @@ class BotRegistry:
|
|||||||
await client.stop()
|
await client.stop()
|
||||||
return
|
return
|
||||||
|
|
||||||
account_id = await accounts.sync_account(
|
try:
|
||||||
self._pool, me, str(bot_id), is_bot=True
|
account_id = await accounts.sync_account(
|
||||||
)
|
self._pool, me, str(bot_id), is_bot=True
|
||||||
client.capture = await build_bot_capture_context(
|
)
|
||||||
client, self._pool, self._storage, account_id
|
client.capture = await build_bot_capture_context(
|
||||||
)
|
client, self._pool, self._storage, account_id
|
||||||
await bots.link_account(self._pool, bot_id, account_id)
|
)
|
||||||
await bots.set_status(
|
await bots.link_account(self._pool, bot_id, account_id)
|
||||||
self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name
|
await bots.set_status(
|
||||||
)
|
self._pool,
|
||||||
await accounts.notify_accounts_changed(self._pool)
|
bot_id,
|
||||||
|
bots.STATUS_OK,
|
||||||
|
username=me.username,
|
||||||
|
title=me.full_name,
|
||||||
|
)
|
||||||
|
await accounts.notify_accounts_changed(self._pool)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
f"[red]Bot {bot_id} failed to start,[/] retrying on the next sync"
|
||||||
|
)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await bots.set_status(
|
||||||
|
self._pool, bot_id, bots.STATUS_ERROR, detail=str(exc)
|
||||||
|
)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await client.stop()
|
||||||
|
return
|
||||||
|
|
||||||
consumer = JobConsumer(client, self._pool, account_id)
|
consumer = JobConsumer(client, self._pool, account_id)
|
||||||
self._running[bot_id] = RunningBot(
|
self._running[bot_id] = RunningBot(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import asyncpg
|
|||||||
import uvloop
|
import uvloop
|
||||||
|
|
||||||
from dependencies.container import container
|
from dependencies.container import container
|
||||||
from utils.env import env
|
from utils.listen import Listener
|
||||||
from utils.logging import logger, setup_logging
|
from utils.logging import logger, setup_logging
|
||||||
from utils.read.bots import BOTS_CHANGED_CHANNEL
|
from utils.read.bots import BOTS_CHANGED_CHANNEL
|
||||||
from utils.storage import ContentAddressedStorage
|
from utils.storage import ContentAddressedStorage
|
||||||
@@ -20,23 +20,19 @@ RETRY_INTERVAL = 300
|
|||||||
async def _retry_loop(registry: BotRegistry) -> None:
|
async def _retry_loop(registry: BotRegistry) -> None:
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(RETRY_INTERVAL)
|
await asyncio.sleep(RETRY_INTERVAL)
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
await registry.sync()
|
await registry.sync()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[red]Bot sync failed[/]")
|
||||||
|
|
||||||
|
|
||||||
async def _listen_changes(
|
def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener:
|
||||||
registry: BotRegistry, tasks: set[asyncio.Task]
|
def on_change(_payload: str) -> None:
|
||||||
) -> asyncpg.Connection:
|
|
||||||
def on_change(
|
|
||||||
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
|
||||||
) -> None:
|
|
||||||
task = asyncio.create_task(registry.sync())
|
task = asyncio.create_task(registry.sync())
|
||||||
tasks.add(task)
|
tasks.add(task)
|
||||||
task.add_done_callback(tasks.discard)
|
task.add_done_callback(tasks.discard)
|
||||||
|
|
||||||
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
return Listener("watcher", {BOTS_CHANGED_CHANNEL: on_change}, lambda: on_change(""))
|
||||||
await conn.add_listener(BOTS_CHANGED_CHANNEL, on_change)
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
async def runner() -> None:
|
async def runner() -> None:
|
||||||
@@ -44,7 +40,7 @@ async def runner() -> None:
|
|||||||
storage = await container.get(ContentAddressedStorage)
|
storage = await container.get(ContentAddressedStorage)
|
||||||
registry = BotRegistry(pool, storage)
|
registry = BotRegistry(pool, storage)
|
||||||
tasks: set[asyncio.Task] = set()
|
tasks: set[asyncio.Task] = set()
|
||||||
listen_conn: asyncpg.Connection | None = None
|
listener = _change_listener(registry, tasks)
|
||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
||||||
retry = asyncio.create_task(_retry_loop(registry))
|
retry = asyncio.create_task(_retry_loop(registry))
|
||||||
@@ -52,16 +48,14 @@ async def runner() -> None:
|
|||||||
await registry.sync()
|
await registry.sync()
|
||||||
if not registry.running:
|
if not registry.running:
|
||||||
logger.warning("[yellow]No bots yet. Add a bot token in the web UI.[/]")
|
logger.warning("[yellow]No bots yet. Add a bot token in the web UI.[/]")
|
||||||
listen_conn = await _listen_changes(registry, tasks)
|
await listener.start()
|
||||||
logger.info("[green]Bot watcher running.[/]")
|
logger.info("[green]Bot watcher running.[/]")
|
||||||
await stop.wait()
|
await stop.wait()
|
||||||
finally:
|
finally:
|
||||||
retry.cancel()
|
retry.cancel()
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await retry
|
await retry
|
||||||
if listen_conn is not None:
|
await listener.stop()
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await listen_conn.close()
|
|
||||||
await registry.close()
|
await registry.close()
|
||||||
await container.close()
|
await container.close()
|
||||||
|
|
||||||
|
|||||||
Generated
+6
-7
@@ -411,7 +411,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.143.3"
|
version = "0.143.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -421,9 +421,9 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/38/d5/b0aeb0b303650ac87b0411de4aaf3f901b06042dcc3fb00f30b97b7ff585/fastapi-0.143.3.tar.gz", hash = "sha256:b9ab567ee57afb4454004ce04c889f8f0160ac47946b99795887cd6e88484848", size = 469834, upload-time = "2026-10-16T14:14:13.31Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/1a/03d246befcf307219bb2161c97d8c5d314f84125bf7dd245730a28589828/fastapi-0.143.3-py3-none-any.whl", hash = "sha256:75e8bd68e2f52f77d29b2879749c257d64de2d85282d25270caf2bfccce5efe5", size = 144849, upload-time = "2026-10-16T14:14:11.763Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1080,17 +1080,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-settings"
|
name = "pydantic-settings"
|
||||||
version = "2.16.0"
|
version = "2.15.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "typing-extensions" },
|
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/3b/a5d2294799b53b448319978cfb5bd139d5a9d45e862af91661614f14c922/pydantic_settings-2.16.0.tar.gz", hash = "sha256:5b6c578049ede4db0e2ef3b4eaa4ad4069cfa9211f83fb38df899dfade50a614", size = 301031, upload-time = "2026-10-14T12:44:09.998Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl", hash = "sha256:7e73acf7f61936a15e5a3b6eedaea29f133357faf7272f2607ba479b049dd7f2", size = 80364, upload-time = "2026-10-14T12:44:08.233Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+10
-1
@@ -1,9 +1,18 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||||
<meta content="scale" name="text-scale" />
|
<meta content="scale" name="text-scale" />
|
||||||
|
<meta content="#ffffff" media="(prefers-color-scheme: light)" name="theme-color" />
|
||||||
|
<meta content="#212121" media="(prefers-color-scheme: dark)" name="theme-color" />
|
||||||
|
<meta content="yes" name="mobile-web-app-capable" />
|
||||||
|
<meta content="black-translucent" name="apple-mobile-web-app-status-bar-style" />
|
||||||
|
<meta content="Beavergram" name="apple-mobile-web-app-title" />
|
||||||
|
<link href="/icon.svg" rel="icon" type="image/svg+xml" />
|
||||||
|
<link href="/apple-touch-icon.png" rel="apple-touch-icon" />
|
||||||
|
<link href="/manifest.webmanifest" rel="manifest" />
|
||||||
|
<title>Beavergram</title>
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const stored = localStorage.getItem("bg.theme");
|
const stored = localStorage.getItem("bg.theme");
|
||||||
|
|||||||
@@ -4,13 +4,27 @@ import { PUBLIC_API_BASE_URL } from "$app/env/public";
|
|||||||
|
|
||||||
const BASE = PUBLIC_API_BASE_URL;
|
const BASE = PUBLIC_API_BASE_URL;
|
||||||
const RETRY_DELAY = 2500;
|
const RETRY_DELAY = 2500;
|
||||||
|
const MISSING_TTL = 120_000;
|
||||||
|
|
||||||
export type AvatarKind = "peer" | "chat";
|
export type AvatarKind = "peer" | "chat";
|
||||||
|
|
||||||
const MAX_CACHED = 240;
|
const MAX_CACHED = 240;
|
||||||
|
|
||||||
const ready = new Map<string, string>();
|
const ready = new Map<string, string>();
|
||||||
const missing = new Set<string>();
|
const missing = new Map<string, number>();
|
||||||
|
|
||||||
|
function isMissing(key: string): boolean {
|
||||||
|
const since = missing.get(key);
|
||||||
|
if (since === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Date.now() - since < MISSING_TTL) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
missing.delete(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const inflight = new Map<string, Promise<string | null>>();
|
const inflight = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
function cacheKey(account: number, kind: AvatarKind, id: number): string {
|
function cacheKey(account: number, kind: AvatarKind, id: number): string {
|
||||||
@@ -60,15 +74,15 @@ async function fetchAvatar(
|
|||||||
await delay(RETRY_DELAY);
|
await delay(RETRY_DELAY);
|
||||||
return fetchAvatar(account, kind, id, key, false);
|
return fetchAvatar(account, kind, id, key, false);
|
||||||
}
|
}
|
||||||
missing.add(key);
|
missing.set(key, Date.now());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAvatar(
|
export function loadAvatar(
|
||||||
kind: AvatarKind,
|
kind: AvatarKind,
|
||||||
id: number
|
id: number,
|
||||||
|
account: number | null = accounts.selectedId
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const account = accounts.selectedId;
|
|
||||||
if (account === null) {
|
if (account === null) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
@@ -77,7 +91,7 @@ export function loadAvatar(
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
return Promise.resolve(cached);
|
return Promise.resolve(cached);
|
||||||
}
|
}
|
||||||
if (missing.has(key)) {
|
if (isMissing(key)) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
const existing = inflight.get(key);
|
const existing = inflight.get(key);
|
||||||
@@ -110,7 +124,7 @@ async function fetchVariant(
|
|||||||
await delay(RETRY_DELAY);
|
await delay(RETRY_DELAY);
|
||||||
return fetchVariant(account, kind, id, uniqueId, key, false);
|
return fetchVariant(account, kind, id, uniqueId, key, false);
|
||||||
}
|
}
|
||||||
missing.add(key);
|
missing.set(key, Date.now());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +142,7 @@ export function loadAvatarVariant(
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
return Promise.resolve(cached);
|
return Promise.resolve(cached);
|
||||||
}
|
}
|
||||||
if (missing.has(key)) {
|
if (isMissing(key)) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
const existing = inflight.get(key);
|
const existing = inflight.get(key);
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<svg
|
|
||||||
height="128"
|
|
||||||
viewBox="0 0 107 128"
|
|
||||||
width="107"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<title>svelte-logo</title>
|
|
||||||
<path
|
|
||||||
d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116"
|
|
||||||
style="fill:#ff3e00"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328"
|
|
||||||
style="fill:#fff"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -139,6 +139,10 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{title} · Beavergram</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<div class="back">
|
<div class="back">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu } from "bits-ui";
|
||||||
|
import AccountAvatar from "#lib/components/settings/AccountAvatar.svelte";
|
||||||
|
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
|
||||||
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { accountName } from "#lib/format/peer.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { theme } from "#lib/stores/theme.svelte.js";
|
||||||
|
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
icon: string;
|
||||||
|
label: MessageKey;
|
||||||
|
panel: RightPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIGIT = /^Digit([1-9])$/;
|
||||||
|
|
||||||
|
const entries: Entry[] = [
|
||||||
|
{ icon: "eye", label: "settings.watches", panel: "watches" },
|
||||||
|
{ icon: "unmute", label: "settings.alerts", panel: "alerts" },
|
||||||
|
{ icon: "data", label: "settings.storage", panel: "storage" },
|
||||||
|
{ icon: "play-story", label: "settings.stories", panel: "stories-all" },
|
||||||
|
{ icon: "cloud-download", label: "settings.jobs", panel: "jobs" },
|
||||||
|
{ icon: "permissions", label: "settings.policy", panel: "policy" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let adding = $state(false);
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
const match = DIGIT.exec(event.code);
|
||||||
|
if (!(event.altKey && match)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const account = accounts.list[Number(match[1]) - 1];
|
||||||
|
if (account) {
|
||||||
|
event.preventDefault();
|
||||||
|
accounts.switchTo(account.account_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window {onkeydown} />
|
||||||
|
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({
|
||||||
|
props,
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
aria-label={t("menu.open")}
|
||||||
|
round
|
||||||
|
smaller
|
||||||
|
variant="translucent"
|
||||||
|
>
|
||||||
|
<Icon name="menu" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
align="start"
|
||||||
|
class="bg-menu-content main-menu"
|
||||||
|
sideOffset={6}
|
||||||
|
>
|
||||||
|
{#each accounts.list as account (account.account_id)}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="bg-menu-item account-item"
|
||||||
|
onSelect={() => accounts.switchTo(account.account_id)}
|
||||||
|
>
|
||||||
|
<AccountAvatar {account} size={1.75} />
|
||||||
|
<span class="account-name">{accountName(account)}</span>
|
||||||
|
{#if !account.is_active}
|
||||||
|
<span class="account-tag">{t("menu.archived")}</span>
|
||||||
|
{/if}
|
||||||
|
{#if account.account_id === accounts.selectedId}
|
||||||
|
<Icon class="account-check" name="check" size="1.125rem" />
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="bg-menu-item"
|
||||||
|
onSelect={() => {
|
||||||
|
adding = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="1.125rem" />
|
||||||
|
{t("menu.addAccount")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator class="bg-menu-separator" />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="bg-menu-item"
|
||||||
|
onSelect={() => ui.openSettings()}
|
||||||
|
>
|
||||||
|
<Icon name="settings" size="1.125rem" />
|
||||||
|
{t("settings.title")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{#each entries as entry (entry.panel)}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="bg-menu-item"
|
||||||
|
onSelect={() => ui.openPanel(entry.panel)}
|
||||||
|
>
|
||||||
|
<Icon name={entry.icon} size="1.125rem" />
|
||||||
|
{t(entry.label)}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
<DropdownMenu.Separator class="bg-menu-separator" />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="bg-menu-item"
|
||||||
|
closeOnSelect={false}
|
||||||
|
onSelect={() => theme.toggle()}
|
||||||
|
>
|
||||||
|
<Icon name="darkmode" size="1.125rem" />
|
||||||
|
<span class="account-name">{t("menu.nightMode")}</span>
|
||||||
|
{#if theme.resolved === "dark"}
|
||||||
|
<Icon class="account-check" name="check" size="1.125rem" />
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
|
<AddAccountDialog bind:open={adding} />
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global(.main-menu) {
|
||||||
|
width: 17rem;
|
||||||
|
max-height: calc(100dvh - 5rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.main-menu .account-item) {
|
||||||
|
padding-block: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-name {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.main-menu .account-check) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
const appVersion = "0.0.1";
|
const appVersion = "0.0.1";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="about">
|
<div class="about">
|
||||||
<div class="title">Beavergram</div>
|
<div class="title">Beavergram</div>
|
||||||
<div class="line">Версия {appVersion}</div>
|
<div class="line">{t("settings.about.version", { version: appVersion })}</div>
|
||||||
<div class="line">Лицензия GPL-3.0</div>
|
<div class="line">{t("settings.about.license")}</div>
|
||||||
<a
|
<a
|
||||||
class="line link"
|
class="line link"
|
||||||
href="https://github.com/Ajaxy/telegram-tt"
|
href="https://github.com/Ajaxy/telegram-tt"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
UI портирован из Telegram A
|
{t("settings.about.port")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Account } from "#lib/api/types.js";
|
||||||
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
|
import { accountName } from "#lib/format/peer.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
account: Account;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { account, size }: Props = $props();
|
||||||
|
|
||||||
|
const avatar = $derived(
|
||||||
|
account.tg_user_id === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
account: account.account_id,
|
||||||
|
id: account.tg_user_id,
|
||||||
|
kind: "peer" as const,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Avatar
|
||||||
|
{avatar}
|
||||||
|
colorKey={account.account_id}
|
||||||
|
hasAvatar={avatar !== null}
|
||||||
|
name={accountName(account)}
|
||||||
|
{size}
|
||||||
|
/>
|
||||||
@@ -1,70 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ripple } from "#lib/actions/ripple.js";
|
import RadioList from "#lib/components/ui/RadioList.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { theme } from "#lib/stores/theme.svelte.js";
|
import { theme } from "#lib/stores/theme.svelte.js";
|
||||||
|
|
||||||
const options = [
|
const options = $derived([
|
||||||
{ label: "Светлая", value: "light" },
|
{ label: t("settings.theme.light"), value: "light" as const },
|
||||||
{ label: "Тёмная", value: "dark" },
|
{ label: t("settings.theme.dark"), value: "dark" as const },
|
||||||
{ label: "Системная", value: "system" },
|
{ label: t("settings.theme.system"), value: "system" as const },
|
||||||
] as const;
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="appearance">
|
<RadioList
|
||||||
{#each options as option (option.value)}
|
onselect={(value) => theme.set(value)}
|
||||||
<button
|
{options}
|
||||||
class="theme-row"
|
selected={theme.preference}
|
||||||
onclick={() => theme.set(option.value)}
|
/>
|
||||||
type="button"
|
|
||||||
use:ripple
|
|
||||||
>
|
|
||||||
<span class="dot" class:on={theme.preference === option.value}></span>
|
|
||||||
<span class="label">{option.label}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.theme-row {
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1.5rem;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
min-height: 3.25rem;
|
|
||||||
padding: 0 1.25rem;
|
|
||||||
border: 0;
|
|
||||||
|
|
||||||
font-size: 1rem;
|
|
||||||
text-align: start;
|
|
||||||
color: var(--color-text);
|
|
||||||
background-color: transparent;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
border: 2px solid var(--color-borders);
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
transition: border-color 0.15s ease;
|
|
||||||
|
|
||||||
&.on {
|
|
||||||
border-width: 0.4375rem;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import RadioList from "#lib/components/ui/RadioList.svelte";
|
||||||
|
import { locale, t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
|
const options = $derived([
|
||||||
|
{ label: t("settings.language.system"), value: "system" as const },
|
||||||
|
{ label: "Русский", value: "ru" as const },
|
||||||
|
{ label: "English", value: "en" as const },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<RadioList
|
||||||
|
onselect={(value) => locale.set(value)}
|
||||||
|
{options}
|
||||||
|
selected={locale.preference}
|
||||||
|
/>
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
import { Dialog } from "bits-ui";
|
import { Dialog } from "bits-ui";
|
||||||
import { ripple } from "#lib/actions/ripple.js";
|
import { ripple } from "#lib/actions/ripple.js";
|
||||||
import { ApiError } from "#lib/api/client.js";
|
import { ApiError } from "#lib/api/client.js";
|
||||||
|
import AccountAvatar from "#lib/components/settings/AccountAvatar.svelte";
|
||||||
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
|
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
|
||||||
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { accountName } from "#lib/format/peer.js";
|
import { accountName } from "#lib/format/peer.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
|
||||||
|
|
||||||
const current = $derived(accounts.selected);
|
const current = $derived(accounts.selected);
|
||||||
const others = $derived(
|
const others = $derived(
|
||||||
@@ -26,11 +26,13 @@
|
|||||||
let renaming = $state(false);
|
let renaming = $state(false);
|
||||||
let deviceName = $state("");
|
let deviceName = $state("");
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
let failure = $state<string | null>(null);
|
||||||
|
|
||||||
const deviceLabel = $derived(current?.device_model ?? DEFAULT_DEVICE);
|
const deviceLabel = $derived(current?.device_model ?? DEFAULT_DEVICE);
|
||||||
|
|
||||||
function openRename() {
|
function openRename() {
|
||||||
deviceName = deviceLabel;
|
deviceName = deviceLabel;
|
||||||
|
failure = null;
|
||||||
renaming = true;
|
renaming = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,14 +43,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
|
failure = null;
|
||||||
try {
|
try {
|
||||||
await accounts.renameDevice(current.account_id, value);
|
await accounts.renameDevice(current.account_id, value);
|
||||||
renaming = false;
|
renaming = false;
|
||||||
toasts.success("Имя устройства обновлено");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toasts.error(
|
failure =
|
||||||
error instanceof ApiError ? error.detail : "Не удалось переименовать"
|
error instanceof ApiError ? error.detail : t("account.renameFailed");
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -59,12 +60,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
|
failure = null;
|
||||||
try {
|
try {
|
||||||
await accounts.logout(current.account_id);
|
await accounts.logout(current.account_id);
|
||||||
confirming = false;
|
confirming = false;
|
||||||
toasts.success("Сессия завершена");
|
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось выйти из аккаунта");
|
failure = t("account.logoutFailed");
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -74,17 +75,13 @@
|
|||||||
<div class="my-account">
|
<div class="my-account">
|
||||||
{#if current}
|
{#if current}
|
||||||
<div class="profile">
|
<div class="profile">
|
||||||
<Avatar
|
<AccountAvatar account={current} size={5} />
|
||||||
colorKey={current.account_id}
|
|
||||||
name={accountName(current)}
|
|
||||||
size={5}
|
|
||||||
/>
|
|
||||||
<div class="name">{accountName(current)}</div>
|
<div class="name">{accountName(current)}</div>
|
||||||
{#if current.phone}
|
{#if current.phone}
|
||||||
<div class="phone">+{current.phone}</div>
|
<div class="phone">+{current.phone}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !current.is_active}
|
{#if !current.is_active}
|
||||||
<div class="inactive">Сессия завершена, доступен только архив</div>
|
<div class="inactive">{t("account.inactive")}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -94,18 +91,14 @@
|
|||||||
{#each others as account (account.account_id)}
|
{#each others as account (account.account_id)}
|
||||||
<button
|
<button
|
||||||
class="switch-row"
|
class="switch-row"
|
||||||
onclick={() => accounts.select(account.account_id)}
|
onclick={() => accounts.switchTo(account.account_id)}
|
||||||
type="button"
|
type="button"
|
||||||
use:ripple
|
use:ripple
|
||||||
>
|
>
|
||||||
<Avatar
|
<AccountAvatar {account} size={2.25} />
|
||||||
colorKey={account.account_id}
|
|
||||||
name={accountName(account)}
|
|
||||||
size={2.25}
|
|
||||||
/>
|
|
||||||
<span>{accountName(account)}</span>
|
<span>{accountName(account)}</span>
|
||||||
{#if !account.is_active}
|
{#if !account.is_active}
|
||||||
<span class="tag">архив</span>
|
<span class="tag">{t("menu.archived")}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<Icon class="chevron" name="arrow-right" size="1rem" />
|
<Icon class="chevron" name="arrow-right" size="1rem" />
|
||||||
</button>
|
</button>
|
||||||
@@ -117,14 +110,14 @@
|
|||||||
{#if current?.is_active}
|
{#if current?.is_active}
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="active-sessions"
|
icon="active-sessions"
|
||||||
label="Имя устройства"
|
label={t("account.device")}
|
||||||
onclick={openRename}
|
onclick={openRename}
|
||||||
value={deviceLabel}
|
value={deviceLabel}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="add-user"
|
icon="add-user"
|
||||||
label="Добавить аккаунт"
|
label={t("menu.addAccount")}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
adding = true;
|
adding = true;
|
||||||
}}
|
}}
|
||||||
@@ -132,8 +125,9 @@
|
|||||||
{#if current?.is_active}
|
{#if current?.is_active}
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="logout"
|
icon="logout"
|
||||||
label="Выйти из аккаунта"
|
label={t("account.logout")}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
failure = null;
|
||||||
confirming = true;
|
confirming = true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -148,18 +142,14 @@
|
|||||||
<Dialog.Overlay class="dialog-overlay" />
|
<Dialog.Overlay class="dialog-overlay" />
|
||||||
<Dialog.Content class="dialog-content">
|
<Dialog.Content class="dialog-content">
|
||||||
<header class="dialog-head">
|
<header class="dialog-head">
|
||||||
<Dialog.Title class="dialog-title">Имя устройства</Dialog.Title>
|
<Dialog.Title class="dialog-title">{t("account.device")}</Dialog.Title>
|
||||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
||||||
<Icon name="close" size="1.25rem" />
|
<Icon name="close" size="1.25rem" />
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</header>
|
</header>
|
||||||
<form onsubmit={rename}>
|
<form onsubmit={rename}>
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<p class="confirm-text">
|
<p class="confirm-text">{t("account.deviceHint")}</p>
|
||||||
Так сессия называется в списке устройств Telegram. Чтобы имя
|
|
||||||
применилось, соединение переподключится — сбор сообщений прервётся
|
|
||||||
на пару секунд.
|
|
||||||
</p>
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input
|
<input
|
||||||
class="form-control"
|
class="form-control"
|
||||||
@@ -169,8 +159,11 @@
|
|||||||
type="text"
|
type="text"
|
||||||
bind:value={deviceName}
|
bind:value={deviceName}
|
||||||
>
|
>
|
||||||
<label for="device-name">Название</label>
|
<label for="device-name">{t("account.deviceName")}</label>
|
||||||
</div>
|
</div>
|
||||||
|
{#if failure}
|
||||||
|
<p class="failure">{failure}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<Button
|
<Button
|
||||||
@@ -179,7 +172,7 @@
|
|||||||
pill
|
pill
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Сохранить
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -192,17 +185,22 @@
|
|||||||
<Dialog.Overlay class="dialog-overlay" />
|
<Dialog.Overlay class="dialog-overlay" />
|
||||||
<Dialog.Content class="dialog-content">
|
<Dialog.Content class="dialog-content">
|
||||||
<header class="dialog-head">
|
<header class="dialog-head">
|
||||||
<Dialog.Title class="dialog-title">Выйти из аккаунта?</Dialog.Title>
|
<Dialog.Title class="dialog-title">
|
||||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
{t("account.logoutTitle")}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
||||||
<Icon name="close" size="1.25rem" />
|
<Icon name="close" size="1.25rem" />
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</header>
|
</header>
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<p class="confirm-text">
|
<p class="confirm-text">
|
||||||
Сессия {current ? accountName(current) : ""} будет завершена в
|
{t("account.logoutText", {
|
||||||
Telegram, новые сообщения перестанут собираться. Уже собранный архив
|
name: current ? accountName(current) : "",
|
||||||
останется доступным.
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
{#if failure}
|
||||||
|
<p class="failure">{failure}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<Button
|
<Button
|
||||||
@@ -212,10 +210,10 @@
|
|||||||
pill
|
pill
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
Отмена
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button loading={busy} onclick={logout} pill variant="danger">
|
<Button loading={busy} onclick={logout} pill variant="danger">
|
||||||
Выйти
|
{t("account.logoutAction")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
@@ -292,6 +290,12 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.failure {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.switch-row .chevron) {
|
:global(.switch-row .chevron) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--color-icon-secondary);
|
color: var(--color-icon-secondary);
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import About from "#lib/components/settings/About.svelte";
|
import About from "#lib/components/settings/About.svelte";
|
||||||
import Appearance from "#lib/components/settings/Appearance.svelte";
|
import Appearance from "#lib/components/settings/Appearance.svelte";
|
||||||
|
import Language from "#lib/components/settings/Language.svelte";
|
||||||
import MyAccount from "#lib/components/settings/MyAccount.svelte";
|
import MyAccount from "#lib/components/settings/MyAccount.svelte";
|
||||||
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
const features = [
|
|
||||||
{ icon: "search", label: "Поиск" },
|
|
||||||
{ icon: "folder", label: "Папки" },
|
|
||||||
{ icon: "stats", label: "Presence и аналитика" },
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
<header class="settings-header">
|
<header class="settings-header">
|
||||||
<Button
|
<Button
|
||||||
aria-label="Назад"
|
aria-label={t("common.back")}
|
||||||
onclick={() => ui.closeSettings()}
|
onclick={() => ui.closeSettings()}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
@@ -25,61 +21,63 @@
|
|||||||
>
|
>
|
||||||
<Icon name="arrow-left" />
|
<Icon name="arrow-left" />
|
||||||
</Button>
|
</Button>
|
||||||
<h2>Настройки</h2>
|
<h2>{t("settings.title")}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="settings-body custom-scroll">
|
<div class="settings-body custom-scroll">
|
||||||
<MyAccount />
|
<MyAccount />
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">Внешний вид</div>
|
<div class="section-title">{t("settings.appearance")}</div>
|
||||||
<Appearance />
|
<Appearance />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">{t("settings.language")}</div>
|
||||||
|
<Language />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="settings"
|
icon="settings"
|
||||||
label="Политика захвата"
|
label={t("settings.policy")}
|
||||||
onclick={() => ui.openPanel("policy")}
|
onclick={() => ui.openPanel("policy")}
|
||||||
/>
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="cloud-download"
|
icon="cloud-download"
|
||||||
label="Бэкфилл и задачи"
|
label={t("settings.jobs")}
|
||||||
onclick={() => ui.openPanel("jobs")}
|
onclick={() => ui.openPanel("jobs")}
|
||||||
/>
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="data"
|
icon="data"
|
||||||
label="Хранилище"
|
label={t("settings.storage")}
|
||||||
onclick={() => ui.openPanel("storage")}
|
onclick={() => ui.openPanel("storage")}
|
||||||
/>
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="play-story"
|
icon="play-story"
|
||||||
label="Сторис"
|
label={t("settings.stories")}
|
||||||
onclick={() => ui.openPanel("stories-all")}
|
onclick={() => ui.openPanel("stories-all")}
|
||||||
/>
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="allow-share"
|
icon="allow-share"
|
||||||
label="Файлы по ссылке"
|
label={t("settings.shares")}
|
||||||
onclick={() => ui.openPanel("shares")}
|
onclick={() => ui.openPanel("shares")}
|
||||||
/>
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="bots"
|
icon="bots"
|
||||||
label="Боты"
|
label={t("settings.bots")}
|
||||||
onclick={() => ui.openPanel("bots")}
|
onclick={() => ui.openPanel("bots")}
|
||||||
/>
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="eye"
|
icon="eye"
|
||||||
label="Отслеживания"
|
label={t("settings.watches")}
|
||||||
onclick={() => ui.openPanel("watches")}
|
onclick={() => ui.openPanel("watches")}
|
||||||
/>
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="unmute"
|
icon="unmute"
|
||||||
label="Алерты"
|
label={t("settings.alerts")}
|
||||||
onclick={() => ui.openPanel("alerts")}
|
onclick={() => ui.openPanel("alerts")}
|
||||||
/>
|
/>
|
||||||
{#each features as feature (feature.label)}
|
|
||||||
<SettingsItem icon={feature.icon} label={feature.label} soon />
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<About />
|
<About />
|
||||||
|
|||||||
@@ -6,29 +6,19 @@
|
|||||||
icon: string;
|
icon: string;
|
||||||
label: string;
|
label: string;
|
||||||
onclick?: () => void;
|
onclick?: () => void;
|
||||||
soon?: boolean;
|
|
||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { icon, label, value, soon = false, onclick }: Props = $props();
|
let { icon, label, value, onclick }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button class="settings-item" {onclick} type="button" use:ripple>
|
||||||
class="settings-item"
|
|
||||||
disabled={soon}
|
|
||||||
{onclick}
|
|
||||||
type="button"
|
|
||||||
class:soon
|
|
||||||
use:ripple
|
|
||||||
>
|
|
||||||
<Icon class="leading" name={icon} size="1.5rem" />
|
<Icon class="leading" name={icon} size="1.5rem" />
|
||||||
<span class="label">{label}</span>
|
<span class="label">{label}</span>
|
||||||
{#if soon}
|
{#if value}
|
||||||
<span class="soon-tag">скоро</span>
|
|
||||||
{:else if value}
|
|
||||||
<span class="value">{value}</span>
|
<span class="value">{value}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if onclick && !soon}
|
{#if onclick}
|
||||||
<Icon class="chevron" name="arrow-right" size="1rem" />
|
<Icon class="chevron" name="arrow-right" size="1rem" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -53,13 +43,9 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover {
|
||||||
background-color: var(--color-chat-hover);
|
background-color: var(--color-chat-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.settings-item .leading) {
|
:global(.settings-item .leading) {
|
||||||
@@ -78,11 +64,6 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.soon-tag {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.settings-item .chevron) {
|
:global(.settings-item .chevron) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--color-icon-secondary);
|
color: var(--color-icon-secondary);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { initials as toInitials } from "#lib/format/peer.js";
|
import { initials as toInitials } from "#lib/format/peer.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
avatar?: { kind: AvatarKind; id: number } | null;
|
avatar?: { account?: number; kind: AvatarKind; id: number } | null;
|
||||||
colorKey?: number;
|
colorKey?: number;
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
hasAvatar?: boolean;
|
hasAvatar?: boolean;
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
loadAvatar(avatar.kind, avatar.id).then((resolved) => {
|
loadAvatar(avatar.kind, avatar.id, avatar.account).then((resolved) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
url = resolved;
|
url = resolved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script generics="T extends string" lang="ts">
|
||||||
|
import { ripple } from "#lib/actions/ripple.js";
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onselect: (value: T) => void;
|
||||||
|
options: Option[];
|
||||||
|
selected: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { options, selected, onselect }: Props = $props();
|
||||||
|
const name = $props.id();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="radio-list">
|
||||||
|
{#each options as option (option.value)}
|
||||||
|
<label class="radio-row" use:ripple>
|
||||||
|
<input
|
||||||
|
checked={selected === option.value}
|
||||||
|
class="input"
|
||||||
|
{name}
|
||||||
|
onchange={() => onselect(option.value)}
|
||||||
|
type="radio"
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
<span class="dot" class:on={selected === option.value}></span>
|
||||||
|
<span class="label">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.radio-row {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3.25rem;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: start;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(.input:focus-visible) {
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 2px solid var(--color-borders);
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&.on {
|
||||||
|
border-width: 0.4375rem;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,29 +1,39 @@
|
|||||||
const time = new Intl.DateTimeFormat(undefined, {
|
import { locale } from "#lib/i18n/locale.svelte.js";
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
const OPTIONS = {
|
||||||
});
|
day: { day: "numeric", month: "short" },
|
||||||
const day = new Intl.DateTimeFormat(undefined, {
|
full: {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "short",
|
hour: "2-digit",
|
||||||
});
|
minute: "2-digit",
|
||||||
const full = new Intl.DateTimeFormat(undefined, {
|
month: "short",
|
||||||
day: "numeric",
|
year: "numeric",
|
||||||
hour: "2-digit",
|
},
|
||||||
minute: "2-digit",
|
time: { hour: "2-digit", minute: "2-digit" },
|
||||||
month: "short",
|
} satisfies Record<string, Intl.DateTimeFormatOptions>;
|
||||||
year: "numeric",
|
|
||||||
});
|
const formatters = new Map<string, Intl.DateTimeFormat>();
|
||||||
|
|
||||||
|
function format(kind: keyof typeof OPTIONS, date: Date): string {
|
||||||
|
const key = `${locale.tag}:${kind}`;
|
||||||
|
let formatter = formatters.get(key);
|
||||||
|
if (!formatter) {
|
||||||
|
formatter = new Intl.DateTimeFormat(locale.tag, OPTIONS[kind]);
|
||||||
|
formatters.set(key, formatter);
|
||||||
|
}
|
||||||
|
return formatter.format(date);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatTime(iso: string): string {
|
export function formatTime(iso: string): string {
|
||||||
return time.format(new Date(iso));
|
return format("time", new Date(iso));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDay(iso: string): string {
|
export function formatDay(iso: string): string {
|
||||||
return day.format(new Date(iso));
|
return format("day", new Date(iso));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFull(iso: string): string {
|
export function formatFull(iso: string): string {
|
||||||
return full.format(new Date(iso));
|
return format("full", new Date(iso));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatListDate(iso: string | null): string {
|
export function formatListDate(iso: string | null): string {
|
||||||
@@ -33,7 +43,7 @@ export function formatListDate(iso: string | null): string {
|
|||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (date.toDateString() === now.toDateString()) {
|
if (date.toDateString() === now.toDateString()) {
|
||||||
return time.format(date);
|
return format("time", date);
|
||||||
}
|
}
|
||||||
return day.format(date);
|
return format("day", date);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { type MessageKey, messages } from "#lib/i18n/messages.js";
|
||||||
|
import type { Locale, Text } from "#lib/i18n/types.js";
|
||||||
|
import { browser } from "$app/env";
|
||||||
|
|
||||||
|
type Preference = Locale | "system";
|
||||||
|
type Params = Record<string, number | string>;
|
||||||
|
|
||||||
|
const STORAGE_KEY = "bg.locale";
|
||||||
|
const PARAM = /\{(\w+)\}/g;
|
||||||
|
const TAGS: Record<Locale, string> = { en: "en-GB", ru: "ru-RU" };
|
||||||
|
const plurals: Record<Locale, Intl.PluralRules> = {
|
||||||
|
en: new Intl.PluralRules(TAGS.en),
|
||||||
|
ru: new Intl.PluralRules(TAGS.ru),
|
||||||
|
};
|
||||||
|
|
||||||
|
function readPreference(): Preference {
|
||||||
|
if (!browser) {
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored === "en" || stored === "ru" ? stored : "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemLocale(): Locale {
|
||||||
|
if (!browser) {
|
||||||
|
return "ru";
|
||||||
|
}
|
||||||
|
for (const language of navigator.languages) {
|
||||||
|
const base = language.toLowerCase().slice(0, 2);
|
||||||
|
if (base === "ru" || base === "en") {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocale() {
|
||||||
|
let preference = $state<Preference>(readPreference());
|
||||||
|
const current = $derived<Locale>(
|
||||||
|
preference === "system" ? systemLocale() : preference
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get current() {
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
get preference() {
|
||||||
|
return preference;
|
||||||
|
},
|
||||||
|
set(value: Preference) {
|
||||||
|
preference = value;
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get tag() {
|
||||||
|
return TAGS[current];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const locale = createLocale();
|
||||||
|
|
||||||
|
function pick(text: Text, count: number | undefined): string {
|
||||||
|
if (typeof text === "string") {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return text[plurals[locale.current].select(count ?? 0)] ?? text.other;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key: MessageKey, params?: Params): string {
|
||||||
|
const count = typeof params?.count === "number" ? params.count : undefined;
|
||||||
|
const text = pick(messages[key][locale.current], count);
|
||||||
|
if (!params) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return text.replace(PARAM, (match, name: string) =>
|
||||||
|
name in params ? String(params[name]) : match
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { account } from "#lib/i18n/messages/account.js";
|
||||||
|
import { common } from "#lib/i18n/messages/common.js";
|
||||||
|
import { menu } from "#lib/i18n/messages/menu.js";
|
||||||
|
import { settings } from "#lib/i18n/messages/settings.js";
|
||||||
|
|
||||||
|
export const messages = {
|
||||||
|
...account,
|
||||||
|
...common,
|
||||||
|
...menu,
|
||||||
|
...settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageKey = keyof typeof messages;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Messages } from "#lib/i18n/types.js";
|
||||||
|
|
||||||
|
export const account = {
|
||||||
|
"account.device": { en: "Device name", ru: "Имя устройства" },
|
||||||
|
"account.deviceHint": {
|
||||||
|
en: "This is how the session is called in the Telegram device list. To apply the name the connection restarts, capture pauses for a couple of seconds.",
|
||||||
|
ru: "Так сессия называется в списке устройств Telegram. Чтобы имя применилось, соединение переподключится, сбор сообщений прервётся на пару секунд.",
|
||||||
|
},
|
||||||
|
"account.deviceName": { en: "Name", ru: "Название" },
|
||||||
|
"account.inactive": {
|
||||||
|
en: "Session ended, only the archive is available",
|
||||||
|
ru: "Сессия завершена, доступен только архив",
|
||||||
|
},
|
||||||
|
"account.logout": { en: "Log out", ru: "Выйти из аккаунта" },
|
||||||
|
"account.logoutAction": { en: "Log out", ru: "Выйти" },
|
||||||
|
"account.logoutFailed": {
|
||||||
|
en: "Could not log out",
|
||||||
|
ru: "Не удалось выйти из аккаунта",
|
||||||
|
},
|
||||||
|
"account.logoutText": {
|
||||||
|
en: "The {name} session will be ended in Telegram and new messages will stop being captured. The archive collected so far stays available.",
|
||||||
|
ru: "Сессия {name} будет завершена в Telegram, новые сообщения перестанут собираться. Уже собранный архив останется доступным.",
|
||||||
|
},
|
||||||
|
"account.logoutTitle": { en: "Log out?", ru: "Выйти из аккаунта?" },
|
||||||
|
"account.renameFailed": {
|
||||||
|
en: "Could not rename",
|
||||||
|
ru: "Не удалось переименовать",
|
||||||
|
},
|
||||||
|
} satisfies Messages;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Messages } from "#lib/i18n/types.js";
|
||||||
|
|
||||||
|
export const common = {
|
||||||
|
"common.back": { en: "Back", ru: "Назад" },
|
||||||
|
"common.cancel": { en: "Cancel", ru: "Отмена" },
|
||||||
|
"common.close": { en: "Close", ru: "Закрыть" },
|
||||||
|
"common.delete": { en: "Delete", ru: "Удалить" },
|
||||||
|
"common.save": { en: "Save", ru: "Сохранить" },
|
||||||
|
"common.selectChat": {
|
||||||
|
en: "Select a chat to open its archive",
|
||||||
|
ru: "Выберите чат, чтобы открыть архив",
|
||||||
|
},
|
||||||
|
} satisfies Messages;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Messages } from "#lib/i18n/types.js";
|
||||||
|
|
||||||
|
export const menu = {
|
||||||
|
"menu.accountsFailed": {
|
||||||
|
en: "Could not load accounts",
|
||||||
|
ru: "Не удалось загрузить аккаунты",
|
||||||
|
},
|
||||||
|
"menu.addAccount": { en: "Add account", ru: "Добавить аккаунт" },
|
||||||
|
"menu.archived": { en: "archive", ru: "архив" },
|
||||||
|
"menu.nightMode": { en: "Night mode", ru: "Ночной режим" },
|
||||||
|
"menu.open": { en: "Menu", ru: "Меню" },
|
||||||
|
} satisfies Messages;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Messages } from "#lib/i18n/types.js";
|
||||||
|
|
||||||
|
export const settings = {
|
||||||
|
"settings.about.license": { en: "GPL-3.0 license", ru: "Лицензия GPL-3.0" },
|
||||||
|
"settings.about.port": {
|
||||||
|
en: "UI ported from Telegram A",
|
||||||
|
ru: "UI портирован из Telegram A",
|
||||||
|
},
|
||||||
|
"settings.about.version": { en: "Version {version}", ru: "Версия {version}" },
|
||||||
|
"settings.alerts": { en: "Alerts", ru: "Оповещения" },
|
||||||
|
"settings.appearance": { en: "Appearance", ru: "Внешний вид" },
|
||||||
|
"settings.bots": { en: "Bots", ru: "Боты" },
|
||||||
|
"settings.jobs": { en: "Backfill and jobs", ru: "Бэкфилл и задачи" },
|
||||||
|
"settings.language": { en: "Language", ru: "Язык" },
|
||||||
|
"settings.language.system": { en: "System", ru: "Как в системе" },
|
||||||
|
"settings.policy": { en: "Capture policy", ru: "Политика захвата" },
|
||||||
|
"settings.shares": { en: "Files by link", ru: "Файлы по ссылке" },
|
||||||
|
"settings.storage": { en: "Storage", ru: "Хранилище" },
|
||||||
|
"settings.stories": { en: "Stories", ru: "Сторис" },
|
||||||
|
"settings.theme.dark": { en: "Dark", ru: "Тёмная" },
|
||||||
|
"settings.theme.light": { en: "Light", ru: "Светлая" },
|
||||||
|
"settings.theme.system": { en: "System", ru: "Системная" },
|
||||||
|
"settings.title": { en: "Settings", ru: "Настройки" },
|
||||||
|
"settings.watches": { en: "Watches", ru: "Наблюдения" },
|
||||||
|
} satisfies Messages;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type Locale = "en" | "ru";
|
||||||
|
|
||||||
|
export type Plural = Partial<Record<Intl.LDMLPluralRule, string>> & {
|
||||||
|
other: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Text = string | Plural;
|
||||||
|
|
||||||
|
export type Messages = Record<string, Record<Locale, Text>>;
|
||||||
@@ -5,8 +5,21 @@ import {
|
|||||||
} from "#lib/api/endpoints.js";
|
} from "#lib/api/endpoints.js";
|
||||||
import type { Account } from "#lib/api/types.js";
|
import type { Account } from "#lib/api/types.js";
|
||||||
import { browser } from "$app/env";
|
import { browser } from "$app/env";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
const STORAGE_KEY = "bg.account";
|
const STORAGE_KEY = "bg.account";
|
||||||
|
const LAST_CHAT_KEY = "bg.lastChat";
|
||||||
|
|
||||||
|
function readLastChats(): Record<string, number> {
|
||||||
|
if (!browser) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(LAST_CHAT_KEY) ?? "{}");
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readSelected(): number | null {
|
function readSelected(): number | null {
|
||||||
if (!browser) {
|
if (!browser) {
|
||||||
@@ -20,6 +33,7 @@ function createAccounts() {
|
|||||||
let list = $state<Account[]>([]);
|
let list = $state<Account[]>([]);
|
||||||
let selectedId = $state<number | null>(readSelected());
|
let selectedId = $state<number | null>(readSelected());
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
const lastChats = readLastChats();
|
||||||
|
|
||||||
const selected = $derived(
|
const selected = $derived(
|
||||||
list.find((account) => account.account_id === selectedId) ?? null
|
list.find((account) => account.account_id === selectedId) ?? null
|
||||||
@@ -67,6 +81,17 @@ function createAccounts() {
|
|||||||
}
|
}
|
||||||
await load();
|
await load();
|
||||||
},
|
},
|
||||||
|
rememberChat(chatId: number | null) {
|
||||||
|
if (selectedId === null || !browser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chatId === null) {
|
||||||
|
delete lastChats[selectedId];
|
||||||
|
} else {
|
||||||
|
lastChats[selectedId] = chatId;
|
||||||
|
}
|
||||||
|
localStorage.setItem(LAST_CHAT_KEY, JSON.stringify(lastChats));
|
||||||
|
},
|
||||||
async renameDevice(id: number, deviceModel: string) {
|
async renameDevice(id: number, deviceModel: string) {
|
||||||
const updated = await renameAccountDevice(id, deviceModel);
|
const updated = await renameAccountDevice(id, deviceModel);
|
||||||
list = list.map((account) =>
|
list = list.map((account) =>
|
||||||
@@ -80,6 +105,14 @@ function createAccounts() {
|
|||||||
get selectedId() {
|
get selectedId() {
|
||||||
return selectedId;
|
return selectedId;
|
||||||
},
|
},
|
||||||
|
switchTo(id: number) {
|
||||||
|
if (id === selectedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select(id);
|
||||||
|
const chatId = lastChats[id];
|
||||||
|
goto(chatId === undefined ? "/app" : `/app/${chatId}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ function createChats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function active(): Bucket | undefined {
|
function active(): Bucket | undefined {
|
||||||
|
if (account !== accounts.selectedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return buckets[activeKey()];
|
return buckets[activeKey()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import "#lib/styles/icons.css";
|
import "#lib/styles/icons.css";
|
||||||
import "#lib/styles/global.scss";
|
import "#lib/styles/global.scss";
|
||||||
import ToastHost from "#lib/components/ui/ToastHost.svelte";
|
import ToastHost from "#lib/components/ui/ToastHost.svelte";
|
||||||
|
import { locale } from "#lib/i18n/locale.svelte.js";
|
||||||
import { auth } from "#lib/stores/auth.svelte.js";
|
import { auth } from "#lib/stores/auth.svelte.js";
|
||||||
import { theme } from "#lib/stores/theme.svelte.js";
|
import { theme } from "#lib/stores/theme.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -16,6 +17,10 @@
|
|||||||
|
|
||||||
$effect(() => theme.watchSystem());
|
$effect(() => theme.watchSystem());
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
document.documentElement.lang = locale.current;
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const next = theme.resolved;
|
const next = theme.resolved;
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
import { cubicOut } from "svelte/easing";
|
import { cubicOut } from "svelte/easing";
|
||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
import ChatList from "#lib/components/ChatList.svelte";
|
import ChatList from "#lib/components/ChatList.svelte";
|
||||||
import FolderTabs from "#lib/components/FolderTabs.svelte";
|
import FolderTabs from "#lib/components/FolderTabs.svelte";
|
||||||
|
import MainMenu from "#lib/components/MainMenu.svelte";
|
||||||
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
|
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
|
||||||
import RightColumn from "#lib/components/RightColumn.svelte";
|
import RightColumn from "#lib/components/RightColumn.svelte";
|
||||||
import SearchInput from "#lib/components/search/SearchInput.svelte";
|
import SearchInput from "#lib/components/search/SearchInput.svelte";
|
||||||
@@ -11,6 +13,7 @@
|
|||||||
import ShareDialog from "#lib/components/shares/ShareDialog.svelte";
|
import ShareDialog from "#lib/components/shares/ShareDialog.svelte";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { auth } from "#lib/stores/auth.svelte.js";
|
import { auth } from "#lib/stores/auth.svelte.js";
|
||||||
import { events } from "#lib/stores/events.svelte.js";
|
import { events } from "#lib/stores/events.svelte.js";
|
||||||
@@ -27,13 +30,32 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!accounts.loaded) {
|
if (!accounts.loaded) {
|
||||||
accounts.load().catch(() => toasts.error("Failed to load accounts"));
|
accounts.load().catch(() => toasts.error(t("menu.accountsFailed")));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
events.open(auth.token === null ? null : accounts.selectedId);
|
events.open(auth.token === null ? null : accounts.selectedId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const chatId = page.params.chatId ? Number(page.params.chatId) : null;
|
||||||
|
untrack(() => accounts.rememberChat(chatId));
|
||||||
|
});
|
||||||
|
|
||||||
|
let shownAccount = accounts.selectedId;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = accounts.selectedId;
|
||||||
|
if (id === shownAccount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shownAccount = id;
|
||||||
|
untrack(() => {
|
||||||
|
ui.closePanel();
|
||||||
|
search.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -57,7 +79,7 @@
|
|||||||
<header class="left-header">
|
<header class="left-header">
|
||||||
{#if search.active}
|
{#if search.active}
|
||||||
<Button
|
<Button
|
||||||
aria-label="Назад"
|
aria-label={t("common.back")}
|
||||||
onclick={() => search.close()}
|
onclick={() => search.close()}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
@@ -66,15 +88,7 @@
|
|||||||
<Icon name="arrow-left" />
|
<Icon name="arrow-left" />
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button
|
<MainMenu />
|
||||||
aria-label="Меню"
|
|
||||||
onclick={() => ui.openSettings()}
|
|
||||||
round
|
|
||||||
smaller
|
|
||||||
variant="translucent"
|
|
||||||
>
|
|
||||||
<Icon name="menu" />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
{/if}
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
</script>
|
||||||
|
|
||||||
<div class="placeholder">
|
<div class="placeholder">
|
||||||
<span class="pill">Select a chat to view its archive</span>
|
<span class="pill">{t("common.selectChat")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Beavergram</title>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#4fa3f3" />
|
||||||
|
<stop offset="1" stop-color="#2b7fdc" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect fill="url(#bg)" height="512" rx="112" width="512" />
|
||||||
|
<path d="M112 222 396 106 224 252Z" fill="#fff" />
|
||||||
|
<path d="M396 106 224 252 330 300Z" fill="#fff" fill-opacity=".86" />
|
||||||
|
<path d="M224 252 230 314 266 270Z" fill="#fff" fill-opacity=".7" />
|
||||||
|
<path
|
||||||
|
d="M128 344h256v28a36 36 0 0 1-36 36H164a36 36 0 0 1-36-36Z"
|
||||||
|
fill="#fff"
|
||||||
|
/>
|
||||||
|
<rect fill="#3a8be6" height="14" rx="7" width="80" x="216" y="362" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 713 B |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "Beavergram",
|
||||||
|
"short_name": "Beavergram",
|
||||||
|
"start_url": "/app",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#212121",
|
||||||
|
"theme_color": "#212121",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user