Compare commits

...
Author SHA1 Message Date
hh d8ad62fb59 fix(frontend): switching accounts never shows the previous account's chats for a moment 2026-10-19 02:33:44 +00:00
hh f5a8649855 perf(backend): message writes look up the stored copy by its date and touch one chunk 2026-10-19 02:29:19 +00:00
hh 23c1504001 feat(userbot): after a restart the userbot catches up on what it missed while down, from a saved cursor and within bounds 2026-10-19 02:29:19 +00:00
hh 92e5e737d2 fix(userbot): presence, story and profile history toggles of the capture policy take effect for each user peer 2026-10-19 02:29:19 +00:00
hh f32b772ddc fix(userbot): a deletion that arrives before its message is applied once the message is stored 2026-10-19 02:29:19 +00:00
hh 516b8e307f fix(backend): a re-captured or bot-edited message keeps its earlier text as a version, an older copy never overwrites a newer edit 2026-10-19 02:29:19 +00:00
hh 33ceea3dfc fix(backend): a bulk deletion is announced in chunks that fit the notify payload limit 2026-10-19 02:29:19 +00:00
hh 4ce9c5d340 fix(backend): a session or bot that fails to start is retried and the failure is logged 2026-10-19 02:29:19 +00:00
hh 42469eb9f1 fix(userbot): job queue survives a consumer failure, a flood wait defers its job instead of holding the queue 2026-10-19 02:29:19 +00:00
hh 3373c31a02 fix(backend): listen connections reconnect after a dropped connection or a postgres restart and catch up on what they missed 2026-10-19 02:29:19 +00:00
hh 17e27beb59 fix(userbot): repeated link rows no longer abort the message insert 2026-10-19 02:29:19 +00:00
hh 54424d0aa0 feat(frontend): browser tab shows the open chat, empty state speaks the chosen language 2026-10-19 02:06:13 +00:00
hh 50922ef69a feat(frontend): installable on the phone with its own icon, title and manifest, missing avatars are asked again later 2026-10-19 02:05:41 +00:00
hh a1d3552519 feat(frontend): main menu lists accounts with their avatars, switching opens the last chat of that account 2026-10-19 02:02:31 +00:00
hh 3460246749 feat(frontend): russian and english interface with a language choice in settings, soon stubs removed 2026-10-19 01:54:34 +00:00
renovate Bot 3d2f670e87 chore(deps): update ghcr.io/astral-sh/uv:python3.13-trixie-slim docker digest to 7dd72af (main) (#20)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-18 04:09:11 +00:00
renovate Bot 305de9f871 chore(deps): pin ghcr.io/astral-sh/uv docker tag to 40215b6 (main) (#15)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-14 04:07:55 +00:00
62 changed files with 1661 additions and 429 deletions

No files matched your search

+1 -1
View File
@@ -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/*
@@ -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 -12
View File
@@ -5,8 +5,8 @@ from typing import Any
import asyncpg
from utils.env import env
from utils.events import BG_EVENTS_CHANNEL
from utils.listen import Listener
from utils.read import chats as chats_read
from utils.read import presence as presence_read
@@ -26,7 +26,7 @@ class EventHub:
def __init__(self) -> None:
self._subscribers: set[Subscriber] = set()
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()
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:
self._pool = pool
conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener(BG_EVENTS_CHANNEL, self._on_notify)
self._conn = conn
logger.info("Realtime hub listening on %s", BG_EVENTS_CHANNEL)
await self._listener.start()
async def stop(self) -> None:
for task in self._tasks:
task.cancel()
if self._conn is not None:
await self._conn.close()
self._conn = None
await self._listener.stop()
def _on_notify(
self, _conn: asyncpg.Connection, _pid: int, _channel: str, payload: str
) -> None:
def _on_notify(self, payload: str) -> None:
task = asyncio.create_task(self._dispatch(payload))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
+3
View File
@@ -20,6 +20,9 @@ async def on_deleted_messages(client: PyroClient, messages: list[Message]) -> No
box.append(message.id)
else:
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:
await repository.mark_deleted_box(ctx.pool, ctx.account_id, box)
await notify_bg_event(ctx.pool, "delete", ctx.account_id, message_ids=box)
+3 -15
View File
@@ -2,10 +2,9 @@ from pyrogram.types import Message
from userbot.modules.capture import repository
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.media import capture_media, media_unique_id, self_destruct_ttl
from userbot.modules.raw_json import raw_json
from userbot.modules.media import capture_media
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:
return
changed = await repository.add_version(
ctx.pool,
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,
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
)
if not changed:
return
+12 -11
View File
@@ -13,18 +13,19 @@ async def on_user_status(client: PyroClient, user: User) -> None:
ctx = client.capture
if ctx is None or user.status is None:
return
await repository.insert_status(
ctx.pool,
ctx.account_id,
user.id,
datetime.now(UTC),
user.status.name.lower(),
user.last_online_date,
user.next_offline_date,
raw_json(user.raw),
)
if ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).presence:
await repository.insert_status(
ctx.pool,
ctx.account_id,
user.id,
datetime.now(UTC),
user.status.name.lower(),
user.last_online_date,
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 notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id)
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
+15 -2
View File
@@ -25,8 +25,14 @@ async def _handle_user(
current = await get_peer(ctx.pool, ctx.account_id, update.user_id)
if current == fields:
return
is_bot = bool(getattr(raw_user, "bot", False))
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
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)
if merged == current:
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(
+8 -3
View File
@@ -1,14 +1,19 @@
from pyrogram.types import Story
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()
async def on_story(client: PyroClient, story: Story) -> None:
if client.capture is None:
ctx = client.capture
if ctx is None:
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]
+13 -1
View File
@@ -2,10 +2,11 @@ import asyncpg
from pyrogram import Client
from userbot.modules.capture.identity import ChatMetaCache, PeerIdentityCache
from userbot.modules.capture.pending import PendingDeletes
from userbot.modules.contacts import ContactCache
from userbot.modules.folders import FolderCache
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.resolver import resolve
from utils.storage import ContentAddressedStorage
@@ -28,6 +29,7 @@ class CaptureContext:
self.watches = WatchCache(pool, account_id)
self.peer_identity = PeerIdentityCache()
self.chat_meta = ChatMetaCache()
self.pending_deletes = PendingDeletes()
self.policies = PolicySet()
async def reload_policies(self) -> None:
@@ -36,6 +38,16 @@ class CaptureContext:
def resolve(self, chat: ChatMeta) -> CaptureToggles:
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(
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)
if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields):
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:
from userbot.modules.avatars import note_avatar # noqa: PLC0415
+27 -12
View File
@@ -1,11 +1,14 @@
from datetime import datetime
from pyrogram import Client
from pyrogram.types import Message
from userbot.modules.capture import repository
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 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 utils.policy.models import CaptureToggles
@@ -34,27 +37,39 @@ def callbacks(message: Message) -> list[tuple[int, str | None, bytes | None]]:
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(
client: Client, message: Message, ctx: CaptureContext, toggles: CaptureToggles
) -> None:
if message.empty or message.chat is None or message.date is None:
return
chat_id = message.chat.id or 0
await repository.upsert_message(
ctx.pool,
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,
current = await repository.upsert_message(
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
)
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
await capture_identity(message, ctx)
if not current:
return
await capture_media(client, message, ctx, chat_id, message.id, toggles)
buttons = callbacks(message)
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()
+160 -67
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import datetime
import asyncpg
@@ -21,13 +22,19 @@ _TOUCH_EDITED = """
INSERT INTO messages
(account_id, chat_id, message_id, date, sender_id, text, raw,
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
text = EXCLUDED.text,
raw = EXCLUDED.raw,
has_media = EXCLUDED.has_media,
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 = """
@@ -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
"""
_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 INTO message_versions
(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)
SELECT account_id, chat_id, message_id, clock_timestamp(), NULL, text, raw
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 (
SELECT 1 FROM message_versions v
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
"""
_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 = """
SELECT
m.text AS text,
m.raw->>'edit_date' AS edit_date,
(SELECT d.unique_id FROM media d
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
FROM messages m
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
ORDER BY m.date DESC LIMIT 1
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
"""
_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
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
message_id: int,
date: datetime,
sender_id: int | None,
text: str | None,
raw: str,
*,
has_media: bool,
is_self_destruct: bool,
) -> None:
await pool.execute(
_UPSERT_MESSAGE,
account_id,
chat_id,
message_id,
date,
sender_id,
text,
raw,
has_media,
is_self_destruct,
@dataclass(frozen=True, slots=True)
class MessageRow:
chat_id: int
message_id: int
date: datetime
sender_id: int | None
text: str | None
raw: str
has_media: bool
is_self_destruct: bool
edit_date: datetime | None
media_unique_id: str | None
@property
def columns(self) -> tuple:
return (
self.chat_id,
self.message_id,
self.date,
self.sender_id,
self.text,
self.raw,
self.has_media,
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(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> int | None:
@@ -188,48 +300,29 @@ async def mark_deleted_channel(
await _mark_deleted(pool, account_id, rows)
async def add_version( # noqa: PLR0913
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 def add_version(pool: asyncpg.Pool, account_id: int, row: MessageRow) -> bool:
async with pool.acquire() as conn, conn.transaction():
current = await conn.fetchrow(_CURRENT_CONTENT, account_id, chat_id, message_id)
text_changed = current is None or current["text"] != text
media_changed = (
current is not None and current["media_unique_id"] != media_unique_id
)
if not (text_changed or media_changed):
return False
await conn.execute(_SNAPSHOT_ORIGINAL, account_id, chat_id, message_id)
await conn.execute(
_TOUCH_EDITED,
account_id,
chat_id,
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
)
current = await _lock_current(conn, account_id, row)
if current is not None:
changed = (
current["text"] != row.text
or current["media_unique_id"] != row.media_unique_id
)
if not changed:
return False
if _is_stale(current, row):
await _record_past(conn, account_id, row)
return False
await _record_edit(conn, account_id, row, mark_edited=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(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> 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"
)
+40 -26
View File
@@ -9,11 +9,12 @@ from userbot.modules.jobs import repository
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import JOB_HANDLERS, JobHandler
from utils.db.models import Job, JobStatus
from utils.env import env
from utils.listen import Listener
from utils.logging import logger
JOBS_CHANGED_CHANNEL = "jobs_changed"
POLL_INTERVAL_SECONDS = 60.0
RESTART_SECONDS = 10.0
class JobConsumer:
@@ -38,25 +39,20 @@ class JobConsumer:
self.pool, ctx.job_id, JobStatus.FAILED, f"no handler for {job.kind!r}"
)
return
while True:
try:
await handler(ctx)
except (FloodWait, FloodPremiumWait) as exc:
await repository.bump_flood_wait(self.pool, ctx.job_id)
wait = exc.value if isinstance(exc.value, int) else 1
logger.warning(
f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind})[/]"
)
await asyncio.sleep(wait)
except Exception as exc:
logger.exception(f"Job {ctx.job_id} ({job.kind}) failed")
await repository.finish(
self.pool, ctx.job_id, JobStatus.FAILED, str(exc)
)
return
else:
await repository.finish(self.pool, ctx.job_id, JobStatus.DONE)
return
try:
await handler(ctx)
except (FloodWait, FloodPremiumWait) as exc:
wait = exc.value if isinstance(exc.value, int) else 1
await repository.defer_flood_wait(self.pool, ctx.job_id, wait)
logger.warning(
f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind}), "
f"deferred[/]"
)
except Exception as exc:
logger.exception(f"Job {ctx.job_id} ({job.kind}) failed")
await repository.finish(self.pool, ctx.job_id, JobStatus.FAILED, str(exc))
else:
await repository.finish(self.pool, ctx.job_id, JobStatus.DONE)
async def drain(self) -> None:
while True:
@@ -66,23 +62,41 @@ class JobConsumer:
await self.execute(job)
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)
if requeued:
logger.info(f"[yellow]Requeued {requeued} stale running job(s).[/]")
pruned = await repository.prune_finished(self.pool, self.account_id)
if pruned:
logger.info(f"[yellow]Pruned {pruned} finished job(s).[/]")
conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener(JOBS_CHANGED_CHANNEL, lambda *_: self._wake.set())
listener = Listener(
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}.[/]")
try:
while True:
await self.drain()
self._wake.clear()
await self.drain()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(
self._wake.wait(), timeout=POLL_INTERVAL_SECONDS
self._wake.wait(), timeout=await self._idle_seconds()
)
finally:
with contextlib.suppress(Exception):
await conn.close()
await listener.stop()
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:
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:
await note_avatar(
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):
continue
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:
await note_avatar(
ctx.pool,
@@ -34,7 +34,14 @@ async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) ->
continue
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)
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:
await note_avatar(
+14 -3
View File
@@ -32,6 +32,7 @@ async def claim_next(pool: asyncpg.Pool, account_id: int) -> Job | None:
"updated_at = now(), attempts = attempts + 1 "
"WHERE id = ("
" 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"
") RETURNING *",
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(
"UPDATE jobs SET flood_waits = flood_waits + 1, updated_at = now() "
"WHERE id = $1",
"UPDATE jobs SET status = 'pending', flood_waits = flood_waits + 1, "
"run_after = now() + make_interval(secs => $2), updated_at = now() "
"WHERE id = $1 AND status = 'running'",
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,
web_description, web_site_name, raw)
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(
pool: asyncpg.Pool, account_id: int, peer_id: int, fields: ProfileFields, raw: str
async def write_profile( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
peer_id: int,
fields: ProfileFields,
raw: str,
*,
history: bool,
) -> None:
args = (
fields.first_name,
@@ -63,4 +69,5 @@ async def write_profile(
)
async with pool.acquire() as conn, conn.transaction():
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 pyrogram.enums import ChatType
from pyrogram.types import Chat
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:
chat_id = chat.id or 0
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, 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:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
+53 -29
View File
@@ -11,10 +11,12 @@ import uvloop
from dependencies.container import container
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.jobs import JobConsumer
from utils.env import env
from utils.jobs import enqueue
from utils.listen import Listener
from utils.logging import logger, setup_logging
from utils.read.accounts import (
ACCOUNTS_CHANGED_CHANNEL,
@@ -28,12 +30,13 @@ from utils.storage import ContentAddressedStorage
setup_logging()
SESSION_FLUSH_INTERVAL = 60
SESSION_RETRY_INTERVAL = 60
@dataclass
class RunningAccount:
client: PyroClient
consumer_task: asyncio.Task
tasks: list[asyncio.Task]
device_model: str | None
@@ -54,6 +57,7 @@ class AccountRegistry:
self._pool = pool
self._storage = storage
self._running: dict[str, RunningAccount] = {}
self._saved: dict[str, SavedState] = {}
self._lock = asyncio.Lock()
@property
@@ -86,6 +90,8 @@ class AccountRegistry:
async def _start(self, path: Path, device_model: str | None) -> None:
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(
session_name, workdir=str(path.parent), device_model=device_model
)
@@ -100,14 +106,20 @@ class AccountRegistry:
client, self._pool, self._storage, account_id
)
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):
await client.stop()
return
consumer = JobConsumer(client, self._pool, account_id)
self._running[session_name] = RunningAccount(
client, asyncio.create_task(consumer.run()), device_model
)
saved = self._saved.pop(session_name)
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})")
await enqueue(self._pool, account_id, "sync_dialogs", {})
await enqueue(self._pool, account_id, "sync_contacts", {})
@@ -116,7 +128,8 @@ class AccountRegistry:
account = self._running.pop(session_name, None)
if account is None:
return
await _cancel(account.consumer_task)
for task in account.tasks:
await _cancel(task)
with contextlib.suppress(Exception):
await account.client.stop()
logger.info(f"[yellow]Client stopped:[/] {session_name}")
@@ -124,7 +137,8 @@ class AccountRegistry:
async def _log_out(self, path: Path) -> None:
account = self._running.pop(path.stem, None)
if account is not None:
await _cancel(account.consumer_task)
for task in account.tasks:
await _cancel(task)
client = account.client
else:
client = PyroClient(
@@ -148,9 +162,16 @@ async def _flush_sessions(registry: AccountRegistry) -> None:
await client.storage.save()
async def _listen_changes(
registry: AccountRegistry, tasks: set[asyncio.Task]
) -> asyncpg.Connection:
async def _retry_sessions(registry: AccountRegistry) -> None:
while True:
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:
task = asyncio.create_task(coro)
tasks.add(task)
@@ -163,26 +184,29 @@ async def _listen_changes(
if client.capture is not None:
spawn(make_coro(client.capture))
def on_policy(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
) -> None:
def on_policy(_payload: str) -> None:
reload(lambda capture: capture.reload_policies())
def on_watch(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
) -> None:
def on_watch(_payload: str) -> None:
reload(lambda capture: capture.watches.refresh())
def on_accounts(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
) -> None:
def on_accounts(_payload: str) -> None:
spawn(registry.sync())
conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener("policy_changed", on_policy)
await conn.add_listener(WATCHES_CHANGED_CHANNEL, on_watch)
await conn.add_listener(ACCOUNTS_CHANGED_CHANNEL, on_accounts)
return conn
def on_reconnect() -> None:
on_policy("")
on_watch("")
on_accounts("")
return Listener(
"userbot",
{
"policy_changed": on_policy,
WATCHES_CHANGED_CHANNEL: on_watch,
ACCOUNTS_CHANGED_CHANNEL: on_accounts,
},
on_reconnect,
)
async def runner() -> None:
@@ -191,22 +215,22 @@ async def runner() -> None:
registry = AccountRegistry(pool, storage)
tasks: set[asyncio.Task] = set()
listen_conn: asyncpg.Connection | None = None
listener = _change_listener(registry, tasks)
stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
flush = asyncio.create_task(_flush_sessions(registry))
retry = asyncio.create_task(_retry_sessions(registry))
try:
await registry.sync()
if not registry.clients:
logger.warning("[yellow]No sessions yet. Add an account in the web UI.[/]")
listen_conn = await _listen_changes(registry, tasks)
await listener.start()
logger.info("[green]Userbot running.[/]")
await stop.wait()
finally:
await _cancel(flush)
if listen_conn is not None:
with contextlib.suppress(Exception):
await listen_conn.close()
await _cancel(retry)
await listener.stop()
await registry.close()
await container.close()
+3
View File
@@ -203,6 +203,9 @@ class Job(SQLModel, table=True):
finished_at: datetime | None = Field(
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):
+10 -2
View File
@@ -4,6 +4,7 @@ from typing import Literal
import asyncpg
BG_EVENTS_CHANNEL = "bg_events"
MESSAGE_IDS_PER_EVENT = 500
EventKind = Literal["message", "edit", "delete", "reaction", "presence", "receipt"]
@@ -22,8 +23,15 @@ async def notify_bg_event( # noqa: PLR0913
payload["chat_id"] = chat_id
if message_id is not None:
payload["message_id"] = message_id
if message_ids is not None:
payload["message_ids"] = message_ids
if message_ids is None:
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(
"SELECT pg_notify($1, $2)", BG_EVENTS_CHANNEL, json.dumps(payload)
)
+87
View File
@@ -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)
+27 -11
View File
@@ -167,17 +167,33 @@ class BotRegistry:
await client.stop()
return
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
)
await bots.link_account(self._pool, bot_id, account_id)
await bots.set_status(
self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name
)
await accounts.notify_accounts_changed(self._pool)
try:
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
)
await bots.link_account(self._pool, bot_id, account_id)
await bots.set_status(
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)
self._running[bot_id] = RunningBot(
+10 -16
View File
@@ -6,7 +6,7 @@ import asyncpg
import uvloop
from dependencies.container import container
from utils.env import env
from utils.listen import Listener
from utils.logging import logger, setup_logging
from utils.read.bots import BOTS_CHANGED_CHANNEL
from utils.storage import ContentAddressedStorage
@@ -20,23 +20,19 @@ RETRY_INTERVAL = 300
async def _retry_loop(registry: BotRegistry) -> None:
while True:
await asyncio.sleep(RETRY_INTERVAL)
with contextlib.suppress(Exception):
try:
await registry.sync()
except Exception:
logger.exception("[red]Bot sync failed[/]")
async def _listen_changes(
registry: BotRegistry, tasks: set[asyncio.Task]
) -> asyncpg.Connection:
def on_change(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
) -> None:
def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener:
def on_change(_payload: str) -> None:
task = asyncio.create_task(registry.sync())
tasks.add(task)
task.add_done_callback(tasks.discard)
conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener(BOTS_CHANGED_CHANNEL, on_change)
return conn
return Listener("watcher", {BOTS_CHANGED_CHANNEL: on_change}, lambda: on_change(""))
async def runner() -> None:
@@ -44,7 +40,7 @@ async def runner() -> None:
storage = await container.get(ContentAddressedStorage)
registry = BotRegistry(pool, storage)
tasks: set[asyncio.Task] = set()
listen_conn: asyncpg.Connection | None = None
listener = _change_listener(registry, tasks)
stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
retry = asyncio.create_task(_retry_loop(registry))
@@ -52,16 +48,14 @@ async def runner() -> None:
await registry.sync()
if not registry.running:
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.[/]")
await stop.wait()
finally:
retry.cancel()
with contextlib.suppress(asyncio.CancelledError):
await retry
if listen_conn is not None:
with contextlib.suppress(Exception):
await listen_conn.close()
await listener.stop()
await registry.close()
await container.close()
+10 -1
View File
@@ -1,9 +1,18 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<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>
(() => {
const stored = localStorage.getItem("bg.theme");
+21 -7
View File
@@ -4,13 +4,27 @@ import { PUBLIC_API_BASE_URL } from "$app/env/public";
const BASE = PUBLIC_API_BASE_URL;
const RETRY_DELAY = 2500;
const MISSING_TTL = 120_000;
export type AvatarKind = "peer" | "chat";
const MAX_CACHED = 240;
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>>();
function cacheKey(account: number, kind: AvatarKind, id: number): string {
@@ -60,15 +74,15 @@ async function fetchAvatar(
await delay(RETRY_DELAY);
return fetchAvatar(account, kind, id, key, false);
}
missing.add(key);
missing.set(key, Date.now());
return null;
}
export function loadAvatar(
kind: AvatarKind,
id: number
id: number,
account: number | null = accounts.selectedId
): Promise<string | null> {
const account = accounts.selectedId;
if (account === null) {
return Promise.resolve(null);
}
@@ -77,7 +91,7 @@ export function loadAvatar(
if (cached) {
return Promise.resolve(cached);
}
if (missing.has(key)) {
if (isMissing(key)) {
return Promise.resolve(null);
}
const existing = inflight.get(key);
@@ -110,7 +124,7 @@ async function fetchVariant(
await delay(RETRY_DELAY);
return fetchVariant(account, kind, id, uniqueId, key, false);
}
missing.add(key);
missing.set(key, Date.now());
return null;
}
@@ -128,7 +142,7 @@ export function loadAvatarVariant(
if (cached) {
return Promise.resolve(cached);
}
if (missing.has(key)) {
if (isMissing(key)) {
return Promise.resolve(null);
}
const existing = inflight.get(key);
-16
View File
@@ -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>
<svelte:head>
<title>{title} · Beavergram</title>
</svelte:head>
<header class="chat-header">
<div class="back">
<Button
+157
View File
@@ -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">
import { t } from "#lib/i18n/locale.svelte.js";
const appVersion = "0.0.1";
</script>
<div class="about">
<div class="title">Beavergram</div>
<div class="line">Версия {appVersion}</div>
<div class="line">Лицензия GPL-3.0</div>
<div class="line">{t("settings.about.version", { version: appVersion })}</div>
<div class="line">{t("settings.about.license")}</div>
<a
class="line link"
href="https://github.com/Ajaxy/telegram-tt"
rel="noopener"
target="_blank"
>
UI портирован из Telegram A
{t("settings.about.port")}
</a>
</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">
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";
const options = [
{ label: "Светлая", value: "light" },
{ label: "Тёмная", value: "dark" },
{ label: "Системная", value: "system" },
] as const;
const options = $derived([
{ label: t("settings.theme.light"), value: "light" as const },
{ label: t("settings.theme.dark"), value: "dark" as const },
{ label: t("settings.theme.system"), value: "system" as const },
]);
</script>
<div class="appearance">
{#each options as option (option.value)}
<button
class="theme-row"
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>
<RadioList
onselect={(value) => theme.set(value)}
{options}
selected={theme.preference}
/>
@@ -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 { ripple } from "#lib/actions/ripple.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 SettingsItem from "#lib/components/settings/SettingsItem.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { accountName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
const current = $derived(accounts.selected);
const others = $derived(
@@ -26,11 +26,13 @@
let renaming = $state(false);
let deviceName = $state("");
let busy = $state(false);
let failure = $state<string | null>(null);
const deviceLabel = $derived(current?.device_model ?? DEFAULT_DEVICE);
function openRename() {
deviceName = deviceLabel;
failure = null;
renaming = true;
}
@@ -41,14 +43,13 @@
return;
}
busy = true;
failure = null;
try {
await accounts.renameDevice(current.account_id, value);
renaming = false;
toasts.success("Имя устройства обновлено");
} catch (error) {
toasts.error(
error instanceof ApiError ? error.detail : "Не удалось переименовать"
);
failure =
error instanceof ApiError ? error.detail : t("account.renameFailed");
} finally {
busy = false;
}
@@ -59,12 +60,12 @@
return;
}
busy = true;
failure = null;
try {
await accounts.logout(current.account_id);
confirming = false;
toasts.success("Сессия завершена");
} catch {
toasts.error("Не удалось выйти из аккаунта");
failure = t("account.logoutFailed");
} finally {
busy = false;
}
@@ -74,17 +75,13 @@
<div class="my-account">
{#if current}
<div class="profile">
<Avatar
colorKey={current.account_id}
name={accountName(current)}
size={5}
/>
<AccountAvatar account={current} size={5} />
<div class="name">{accountName(current)}</div>
{#if current.phone}
<div class="phone">+{current.phone}</div>
{/if}
{#if !current.is_active}
<div class="inactive">Сессия завершена, доступен только архив</div>
<div class="inactive">{t("account.inactive")}</div>
{/if}
</div>
{/if}
@@ -94,18 +91,14 @@
{#each others as account (account.account_id)}
<button
class="switch-row"
onclick={() => accounts.select(account.account_id)}
onclick={() => accounts.switchTo(account.account_id)}
type="button"
use:ripple
>
<Avatar
colorKey={account.account_id}
name={accountName(account)}
size={2.25}
/>
<AccountAvatar {account} size={2.25} />
<span>{accountName(account)}</span>
{#if !account.is_active}
<span class="tag">архив</span>
<span class="tag">{t("menu.archived")}</span>
{/if}
<Icon class="chevron" name="arrow-right" size="1rem" />
</button>
@@ -117,14 +110,14 @@
{#if current?.is_active}
<SettingsItem
icon="active-sessions"
label="Имя устройства"
label={t("account.device")}
onclick={openRename}
value={deviceLabel}
/>
{/if}
<SettingsItem
icon="add-user"
label="Добавить аккаунт"
label={t("menu.addAccount")}
onclick={() => {
adding = true;
}}
@@ -132,8 +125,9 @@
{#if current?.is_active}
<SettingsItem
icon="logout"
label="Выйти из аккаунта"
label={t("account.logout")}
onclick={() => {
failure = null;
confirming = true;
}}
/>
@@ -148,18 +142,14 @@
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Имя устройства</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Dialog.Title class="dialog-title">{t("account.device")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<form onsubmit={rename}>
<div class="dialog-body">
<p class="confirm-text">
Так сессия называется в списке устройств Telegram. Чтобы имя
применилось, соединение переподключится — сбор сообщений прервётся
на пару секунд.
</p>
<p class="confirm-text">{t("account.deviceHint")}</p>
<div class="input-group">
<input
class="form-control"
@@ -169,8 +159,11 @@
type="text"
bind:value={deviceName}
>
<label for="device-name">Название</label>
<label for="device-name">{t("account.deviceName")}</label>
</div>
{#if failure}
<p class="failure">{failure}</p>
{/if}
</div>
<div class="dialog-actions">
<Button
@@ -179,7 +172,7 @@
pill
type="submit"
>
Сохранить
{t("common.save")}
</Button>
</div>
</form>
@@ -192,17 +185,22 @@
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Выйти из аккаунта?</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Dialog.Title class="dialog-title">
{t("account.logoutTitle")}
</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
<p class="confirm-text">
Сессия {current ? accountName(current) : ""} будет завершена в
Telegram, новые сообщения перестанут собираться. Уже собранный архив
останется доступным.
{t("account.logoutText", {
name: current ? accountName(current) : "",
})}
</p>
{#if failure}
<p class="failure">{failure}</p>
{/if}
</div>
<div class="dialog-actions">
<Button
@@ -212,10 +210,10 @@
pill
variant="text"
>
Отмена
{t("common.cancel")}
</Button>
<Button loading={busy} onclick={logout} pill variant="danger">
Выйти
{t("account.logoutAction")}
</Button>
</div>
</Dialog.Content>
@@ -292,6 +290,12 @@
color: var(--color-text-secondary);
}
.failure {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: var(--color-error);
}
:global(.switch-row .chevron) {
flex-shrink: 0;
color: var(--color-icon-secondary);
@@ -1,23 +1,19 @@
<script lang="ts">
import About from "#lib/components/settings/About.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 SettingsItem from "#lib/components/settings/SettingsItem.svelte";
import Button from "#lib/components/ui/Button.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";
const features = [
{ icon: "search", label: "Поиск" },
{ icon: "folder", label: "Папки" },
{ icon: "stats", label: "Presence и аналитика" },
];
</script>
<div class="settings">
<header class="settings-header">
<Button
aria-label="Назад"
aria-label={t("common.back")}
onclick={() => ui.closeSettings()}
round
smaller
@@ -25,61 +21,63 @@
>
<Icon name="arrow-left" />
</Button>
<h2>Настройки</h2>
<h2>{t("settings.title")}</h2>
</header>
<div class="settings-body custom-scroll">
<MyAccount />
<div class="section">
<div class="section-title">Внешний вид</div>
<div class="section-title">{t("settings.appearance")}</div>
<Appearance />
</div>
<div class="section">
<div class="section-title">{t("settings.language")}</div>
<Language />
</div>
<div class="section">
<SettingsItem
icon="settings"
label="Политика захвата"
label={t("settings.policy")}
onclick={() => ui.openPanel("policy")}
/>
<SettingsItem
icon="cloud-download"
label="Бэкфилл и задачи"
label={t("settings.jobs")}
onclick={() => ui.openPanel("jobs")}
/>
<SettingsItem
icon="data"
label="Хранилище"
label={t("settings.storage")}
onclick={() => ui.openPanel("storage")}
/>
<SettingsItem
icon="play-story"
label="Сторис"
label={t("settings.stories")}
onclick={() => ui.openPanel("stories-all")}
/>
<SettingsItem
icon="allow-share"
label="Файлы по ссылке"
label={t("settings.shares")}
onclick={() => ui.openPanel("shares")}
/>
<SettingsItem
icon="bots"
label="Боты"
label={t("settings.bots")}
onclick={() => ui.openPanel("bots")}
/>
<SettingsItem
icon="eye"
label="Отслеживания"
label={t("settings.watches")}
onclick={() => ui.openPanel("watches")}
/>
<SettingsItem
icon="unmute"
label="Алерты"
label={t("settings.alerts")}
onclick={() => ui.openPanel("alerts")}
/>
{#each features as feature (feature.label)}
<SettingsItem icon={feature.icon} label={feature.label} soon />
{/each}
</div>
<About />
@@ -6,29 +6,19 @@
icon: string;
label: string;
onclick?: () => void;
soon?: boolean;
value?: string;
}
let { icon, label, value, soon = false, onclick }: Props = $props();
let { icon, label, value, onclick }: Props = $props();
</script>
<button
class="settings-item"
disabled={soon}
{onclick}
type="button"
class:soon
use:ripple
>
<button class="settings-item" {onclick} type="button" use:ripple>
<Icon class="leading" name={icon} size="1.5rem" />
<span class="label">{label}</span>
{#if soon}
<span class="soon-tag">скоро</span>
{:else if value}
{#if value}
<span class="value">{value}</span>
{/if}
{#if onclick && !soon}
{#if onclick}
<Icon class="chevron" name="arrow-right" size="1rem" />
{/if}
</button>
@@ -53,13 +43,9 @@
background-color: transparent;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
&:hover {
background-color: var(--color-chat-hover);
}
&:disabled {
cursor: default;
}
}
:global(.settings-item .leading) {
@@ -78,11 +64,6 @@
color: var(--color-text-secondary);
}
.soon-tag {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
:global(.settings-item .chevron) {
flex-shrink: 0;
color: var(--color-icon-secondary);
+2 -2
View File
@@ -3,7 +3,7 @@
import { initials as toInitials } from "#lib/format/peer.js";
interface Props {
avatar?: { kind: AvatarKind; id: number } | null;
avatar?: { account?: number; kind: AvatarKind; id: number } | null;
colorKey?: number;
deleted?: boolean;
hasAvatar?: boolean;
@@ -41,7 +41,7 @@
return;
}
let active = true;
loadAvatar(avatar.kind, avatar.id).then((resolved) => {
loadAvatar(avatar.kind, avatar.id, avatar.account).then((resolved) => {
if (active) {
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>
+30 -20
View File
@@ -1,29 +1,39 @@
const time = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
});
const day = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
});
const full = new Intl.DateTimeFormat(undefined, {
day: "numeric",
hour: "2-digit",
minute: "2-digit",
month: "short",
year: "numeric",
});
import { locale } from "#lib/i18n/locale.svelte.js";
const OPTIONS = {
day: { day: "numeric", month: "short" },
full: {
day: "numeric",
hour: "2-digit",
minute: "2-digit",
month: "short",
year: "numeric",
},
time: { hour: "2-digit", minute: "2-digit" },
} satisfies Record<string, Intl.DateTimeFormatOptions>;
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 {
return time.format(new Date(iso));
return format("time", new Date(iso));
}
export function formatDay(iso: string): string {
return day.format(new Date(iso));
return format("day", new Date(iso));
}
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 {
@@ -33,7 +43,7 @@ export function formatListDate(iso: string | null): string {
const date = new Date(iso);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return time.format(date);
return format("time", date);
}
return day.format(date);
return format("day", date);
}
+80
View File
@@ -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
);
}
+13
View File
@@ -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;
+29
View File
@@ -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;
+13
View File
@@ -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;
+12
View File
@@ -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;
+9
View File
@@ -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";
import type { Account } from "#lib/api/types.js";
import { browser } from "$app/env";
import { goto } from "$app/navigation";
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 {
if (!browser) {
@@ -20,6 +33,7 @@ function createAccounts() {
let list = $state<Account[]>([]);
let selectedId = $state<number | null>(readSelected());
let loaded = $state(false);
const lastChats = readLastChats();
const selected = $derived(
list.find((account) => account.account_id === selectedId) ?? null
@@ -67,6 +81,17 @@ function createAccounts() {
}
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) {
const updated = await renameAccountDevice(id, deviceModel);
list = list.map((account) =>
@@ -80,6 +105,14 @@ function createAccounts() {
get selectedId() {
return selectedId;
},
switchTo(id: number) {
if (id === selectedId) {
return;
}
select(id);
const chatId = lastChats[id];
goto(chatId === undefined ? "/app" : `/app/${chatId}`);
},
};
}
+3
View File
@@ -54,6 +54,9 @@ function createChats() {
}
function active(): Bucket | undefined {
if (account !== accounts.selectedId) {
return;
}
return buckets[activeKey()];
}
+5
View File
@@ -4,6 +4,7 @@
import "#lib/styles/icons.css";
import "#lib/styles/global.scss";
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 { theme } from "#lib/stores/theme.svelte.js";
import { goto } from "$app/navigation";
@@ -16,6 +17,10 @@
$effect(() => theme.watchSystem());
$effect(() => {
document.documentElement.lang = locale.current;
});
$effect(() => {
const next = theme.resolved;
const root = document.documentElement;
+25 -11
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { untrack } from "svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import ChatList from "#lib/components/ChatList.svelte";
import FolderTabs from "#lib/components/FolderTabs.svelte";
import MainMenu from "#lib/components/MainMenu.svelte";
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
import RightColumn from "#lib/components/RightColumn.svelte";
import SearchInput from "#lib/components/search/SearchInput.svelte";
@@ -11,6 +13,7 @@
import ShareDialog from "#lib/components/shares/ShareDialog.svelte";
import Button from "#lib/components/ui/Button.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 { auth } from "#lib/stores/auth.svelte.js";
import { events } from "#lib/stores/events.svelte.js";
@@ -27,13 +30,32 @@
$effect(() => {
if (!accounts.loaded) {
accounts.load().catch(() => toasts.error("Failed to load accounts"));
accounts.load().catch(() => toasts.error(t("menu.accountsFailed")));
}
});
$effect(() => {
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>
<div
@@ -57,7 +79,7 @@
<header class="left-header">
{#if search.active}
<Button
aria-label="Назад"
aria-label={t("common.back")}
onclick={() => search.close()}
round
smaller
@@ -66,15 +88,7 @@
<Icon name="arrow-left" />
</Button>
{:else}
<Button
aria-label="Меню"
onclick={() => ui.openSettings()}
round
smaller
variant="translucent"
>
<Icon name="menu" />
</Button>
<MainMenu />
{/if}
<SearchInput />
</header>
+5 -1
View File
@@ -1,5 +1,9 @@
<script lang="ts">
import { t } from "#lib/i18n/locale.svelte.js";
</script>
<div class="placeholder">
<span class="pill">Select a chat to view its archive</span>
<span class="pill">{t("common.selectChat")}</span>
</div>
<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

+18
View File
@@ -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

+20
View File
@@ -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" }
]
}