Compare commits

...
Author SHA1 Message Date
hh 73b8903de1 feat(frontend): analytics shows minutes online by hour of week and by day, sessions with start and end, says when last seen is hidden, chat status is live and in russian 2026-10-19 02:41:39 +00:00
hh f44597b2dc feat(backend): presence stats count time online from sessions in the browser time zone, capture uptime marks hours without data, presence reaches every stream of the account, message volume keeps empty days 2026-10-19 02:41:39 +00:00
hh bb38e23f29 feat(frontend): my message menu shows when it was read or listened, group readers open in a right panel 2026-10-19 02:40:49 +00:00
hh f03d7c2890 fix(frontend): right panel on phones stays above the sticky dates of the chat 2026-10-19 02:40:49 +00:00
hh d8dd08d7de feat(backend): read time of my messages from the archive, exact dates and group readers asked from telegram on demand 2026-10-19 02:40:49 +00:00
hh f27943aae6 feat(backend): supergroup read receipts are captured like basic groups 2026-10-19 02:40:30 +00:00
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
102 changed files with 4517 additions and 793 deletions

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ Nothing is ever deleted from the archive.
## MCP ## MCP
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be. Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `presence_stats`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be.
## Run ## Run
+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/* RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,51 @@
"""message reads
Revision ID: d7a3c5e9f2b1
Revises: f8c2d6e1a4b7
Create Date: 2026-10-19 01:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d7a3c5e9f2b1"
down_revision: str | None = "f8c2d6e1a4b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_READS = """
CREATE TABLE message_reads (
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
reader_id bigint NOT NULL,
read_at timestamptz NOT NULL,
source text NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, chat_id, message_id, reader_id)
)
"""
_FETCHES = """
CREATE TABLE read_fetches (
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
outcome text NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now(),
retry_at timestamptz NOT NULL,
PRIMARY KEY (account_id, chat_id, message_id)
)
"""
def upgrade() -> None:
op.execute(_READS)
op.execute(_FETCHES)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS read_fetches")
op.execute("DROP TABLE IF EXISTS message_reads")
@@ -0,0 +1,50 @@
"""capture uptime
Revision ID: d8f4a1c6e2b7
Revises: d7a3c5e9f2b1
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d8f4a1c6e2b7"
down_revision: str | None = "d7a3c5e9f2b1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_UPTIME = """
CREATE TABLE capture_uptime (
account_id integer NOT NULL,
started_at timestamptz NOT NULL,
seen_at timestamptz NOT NULL,
PRIMARY KEY (account_id, started_at)
)
"""
_BACKFILL = """
INSERT INTO capture_uptime (account_id, started_at, seen_at)
SELECT account_id, min(ts), max(ts)
FROM (
SELECT account_id, ts,
count(*) FILTER (WHERE gap > interval '30 minutes')
OVER (PARTITION BY account_id ORDER BY ts) AS stretch
FROM (
SELECT account_id, ts,
ts - lag(ts) OVER (PARTITION BY account_id ORDER BY ts) AS gap
FROM presence
) gaps
) stretches
GROUP BY account_id, stretch
"""
def upgrade() -> None:
op.execute(_UPTIME)
op.execute(_BACKFILL)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS capture_uptime")
@@ -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")
+60 -1
View File
@@ -20,9 +20,20 @@ from api.mcp.download import (
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
from dependencies.container import container from dependencies.container import container
from utils.files import resolve_mime from utils.files import resolve_mime
from utils.read import annotations, chats, media, peers, presence, social, watches from utils.read import (
annotations,
chats,
media,
peers,
presence,
presence_stats,
social,
watches,
)
from utils.read.accounts import self_user_id, unknown_account_hint from utils.read.accounts import self_user_id, unknown_account_hint
from utils.read.models import DEFAULT_LIMIT, Page from utils.read.models import DEFAULT_LIMIT, Page
from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
from utils.read.read_receipts import read_info
from utils.search.models import SearchFilters from utils.search.models import SearchFilters
from utils.search.repository import search_messages from utils.search.repository import search_messages
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
@@ -295,6 +306,23 @@ async def get_callbacks(
return _dump(items) return _dump(items)
@mcp.tool
async def get_read_info(
account_id: int, chat_id: int, message_id: int
) -> dict[str, Any] | None:
"""When the user's own message was read, from the archive only.
`observed_at` is when the archive saw the chat read up to this message,
`exact_at` the read date Telegram reported for a private chat, `readers`
who read it in a group and when, `listened_at` when a voice or round
video was played. `outcome` is the last Telegram answer: ok, not_read_yet,
user_privacy (they hide read time), your_privacy (the user hides theirs),
chat_too_big, too_old, unavailable, flood or error.
"""
info = await read_info(await _pool(), account_id, chat_id, message_id)
return info.model_dump(mode="json") if info else None
@mcp.tool @mcp.tool
async def presence_history( async def presence_history(
account_id: int, account_id: int,
@@ -317,6 +345,37 @@ async def presence_history(
) )
@mcp.tool(name="presence_stats")
async def presence_stats_tool(
account_id: int,
peer_id: int,
days: int = 30,
tz: str = DEFAULT_TZ,
limit: int = DEFAULT_LIMIT,
offset: int = 0,
) -> dict[str, Any]:
"""Time a peer spent online, from status pushes turned into online sessions.
`tz` is an IANA zone name; days and hours are counted in it. `week` holds
the average online minutes for each weekday (0 is Monday) and hour, null
where the archive has no data for that hour. `days` gives online seconds
per day, `gaps` the stretches when capture was down, `sessions` the latest
sessions (online stretches with breaks under 5 minutes), paged by
`limit`/`offset`. `hidden` means the peer hides their last seen time.
"""
pool = await _pool()
if not 1 <= days <= MAX_DAYS:
msg = f"days must be between 1 and {MAX_DAYS}"
raise ToolError(msg)
if not await known_timezone(pool, tz):
msg = f"Unknown time zone: {tz}"
raise ToolError(msg)
stats = await presence_stats.presence_stats(
pool, account_id, peer_id, Page(limit=limit, offset=offset), days=days, tz=tz
)
return stats.model_dump(mode="json")
@mcp.tool @mcp.tool
async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]: async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]:
"""Get name/username/avatar change history of a contact.""" """Get name/username/avatar change history of a contact."""
+9 -18
View File
@@ -1,12 +1,13 @@
import asyncio import asyncio
import json import json
import logging import logging
from datetime import UTC, datetime
from typing import Any 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 +27,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 +40,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)
@@ -65,16 +59,12 @@ class EventHub:
return return
account_id = event.get("account_id") account_id = event.get("account_id")
chat_id = event.get("chat_id") chat_id = event.get("chat_id")
scoped = event.get("kind") == "presence" account_wide = event.get("kind") == "presence"
targets = [ targets = [
sub sub
for sub in self._subscribers for sub in self._subscribers
if sub.account_id == account_id if sub.account_id == account_id
and ( and (account_wide or sub.chat_id is None or sub.chat_id == chat_id)
sub.chat_id == chat_id
if scoped
else sub.chat_id is None or sub.chat_id == chat_id
)
] ]
if not targets: if not targets:
return return
@@ -121,6 +111,7 @@ class EventHub:
"type": "receipt", "type": "receipt",
"chat_id": event["chat_id"], "chat_id": event["chat_id"],
"read_up_to": event["message_id"], "read_up_to": event["message_id"],
"read_at": datetime.now(UTC).isoformat(),
} }
return None return None
+7 -2
View File
@@ -4,8 +4,10 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from api.timezone import checked_tz
from utils.read import analytics from utils.read import analytics
from utils.read.models import ResponseStats, VolumeBucket from utils.read.models import ResponseStats, VolumeBucket
from utils.read.period import DEFAULT_TZ, MAX_DAYS
router = APIRouter(prefix="/api/analytics", tags=["analytics"], route_class=DishkaRoute) router = APIRouter(prefix="/api/analytics", tags=["analytics"], route_class=DishkaRoute)
@@ -18,9 +20,12 @@ async def volume(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
account_id: AccountId, account_id: AccountId,
chat_id: ChatId, chat_id: ChatId,
days: Annotated[int, Query()] = 90, days: Annotated[int, Query(ge=1, le=MAX_DAYS)] = 90,
tz: Annotated[str, Query()] = DEFAULT_TZ,
) -> list[VolumeBucket]: ) -> list[VolumeBucket]:
return await analytics.message_volume(pool, account_id, chat_id, days=days) return await analytics.message_volume(
pool, account_id, chat_id, days=days, tz=await checked_tz(pool, tz)
)
@router.get("/response-time") @router.get("/response-time")
+53 -2
View File
@@ -5,8 +5,17 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from utils.read import presence from api.timezone import checked_tz
from utils.read.models import DEFAULT_LIMIT, Page, PresenceHourly, PresenceSample from utils.read import presence, presence_stats
from utils.read.models import (
DEFAULT_LIMIT,
Page,
PresenceHourly,
PresenceSample,
PresenceSession,
PresenceStats,
)
from utils.read.period import DEFAULT_TZ, MAX_DAYS
router = APIRouter(prefix="/api/presence", tags=["presence"], route_class=DishkaRoute) router = APIRouter(prefix="/api/presence", tags=["presence"], route_class=DishkaRoute)
@@ -14,6 +23,8 @@ AccountId = Annotated[int, Query()]
PeerId = Annotated[int, Query()] PeerId = Annotated[int, Query()]
DateFrom = Annotated[datetime | None, Query()] DateFrom = Annotated[datetime | None, Query()]
DateTo = Annotated[datetime | None, Query()] DateTo = Annotated[datetime | None, Query()]
Days = Annotated[int, Query(ge=1, le=MAX_DAYS)]
Tz = Annotated[str, Query()]
@router.get("") @router.get("")
@@ -54,3 +65,43 @@ async def presence_hourly(
return await presence.presence_hourly( return await presence.presence_hourly(
pool, account_id, peer_id, date_from=date_from, date_to=date_to pool, account_id, peer_id, date_from=date_from, date_to=date_to
) )
@router.get("/stats")
async def stats(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_id: PeerId,
days: Days = 30,
tz: Tz = DEFAULT_TZ,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> PresenceStats:
return await presence_stats.presence_stats(
pool,
account_id,
peer_id,
Page(limit=limit, offset=offset),
days=days,
tz=await checked_tz(pool, tz),
)
@router.get("/sessions")
async def sessions(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_id: PeerId,
days: Days = 30,
tz: Tz = DEFAULT_TZ,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[PresenceSession]:
return await presence_stats.presence_sessions(
pool,
account_id,
peer_id,
Page(limit=limit, offset=offset),
days=days,
tz=await checked_tz(pool, tz),
)
+35 -2
View File
@@ -2,10 +2,13 @@ from typing import Annotated
import asyncpg import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from utils.jobs import enqueue
from utils.read import social from utils.read import social
from utils.read.models import CallbackView, LinkView, ReactionView from utils.read.models import CallbackView, LinkView, ReactionView, ReadInfoView
from utils.read.read_receipts import read_info
router = APIRouter( router = APIRouter(
prefix="/api/messages/{chat_id}/{message_id}", prefix="/api/messages/{chat_id}/{message_id}",
@@ -16,6 +19,10 @@ router = APIRouter(
AccountId = Annotated[int, Query()] AccountId = Annotated[int, Query()]
class RefreshRequest(BaseModel):
account_id: int
@router.get("/callbacks") @router.get("/callbacks")
async def message_callbacks( async def message_callbacks(
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
@@ -35,3 +42,29 @@ async def message_links(
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
) -> list[LinkView]: ) -> list[LinkView]:
return await social.get_links(pool, account_id, chat_id, message_id) return await social.get_links(pool, account_id, chat_id, message_id)
@router.get("/read")
async def message_read(
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
) -> ReadInfoView:
info = await read_info(pool, account_id, chat_id, message_id)
if info is None:
raise HTTPException(status_code=404, detail="message not found")
return info
@router.post("/read/refresh")
async def refresh_message_read(
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, body: RefreshRequest
) -> dict[str, int | None]:
info = await read_info(pool, body.account_id, chat_id, message_id)
if info is None or not info.can_fetch:
return {"job_id": None}
job_id = await enqueue(
pool,
body.account_id,
"fetch_read_info",
{"chat_id": chat_id, "message_id": message_id},
)
return {"job_id": job_id}
+10
View File
@@ -0,0 +1,10 @@
import asyncpg
from fastapi import HTTPException
from utils.read.period import known_timezone
async def checked_tz(pool: asyncpg.Pool, tz: str) -> str:
if not await known_timezone(pool, tz):
raise HTTPException(status_code=422, detail=f"Unknown time zone: {tz}")
return tz
+3
View File
@@ -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)
+3 -15
View File
@@ -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
+2 -1
View File
@@ -13,6 +13,7 @@ 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
if ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).presence:
await repository.insert_status( await repository.insert_status(
ctx.pool, ctx.pool,
ctx.account_id, ctx.account_id,
@@ -23,8 +24,8 @@ async def on_user_status(client: PyroClient, user: User) -> None:
user.next_offline_date, user.next_offline_date,
raw_json(user.raw), raw_json(user.raw),
) )
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) 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")
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute] 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) 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(
@@ -7,25 +7,35 @@ from userbot.modules.raw_json import raw_json
from userbot.modules.read_receipts import repository from userbot.modules.read_receipts import repository
from utils.events import notify_bg_event from utils.events import notify_bg_event
HANDLES = (raw.types.UpdateReadHistoryOutbox,) HANDLES = (raw.types.UpdateReadHistoryOutbox, raw.types.UpdateReadChannelOutbox)
def _watermark(update: raw.base.Update) -> tuple[int, int] | None:
if isinstance(update, raw.types.UpdateReadHistoryOutbox):
return utils.get_peer_id(update.peer), update.max_id
if isinstance(update, raw.types.UpdateReadChannelOutbox):
peer = raw.types.PeerChannel(channel_id=update.channel_id)
return utils.get_peer_id(peer), update.max_id
return None
async def handle( async def handle(
client: PyroClient, update: raw.base.Update, _users: dict, _chats: dict client: PyroClient, update: raw.base.Update, _users: dict, _chats: dict
) -> None: ) -> None:
ctx = client.capture ctx = client.capture
if ctx is None or not isinstance(update, raw.types.UpdateReadHistoryOutbox): watermark = _watermark(update)
if ctx is None or watermark is None:
return return
chat_id = utils.get_peer_id(update.peer) chat_id, max_id = watermark
await repository.insert_read( await repository.insert_read(
ctx.pool, ctx.pool,
ctx.account_id, ctx.account_id,
chat_id, chat_id,
chat_id, chat_id,
datetime.now(UTC), datetime.now(UTC),
update.max_id, max_id,
raw_json(update), raw_json(update),
) )
await notify_bg_event( await notify_bg_event(
ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=update.max_id ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=max_id
) )
+8 -3
View File
@@ -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]
+13 -1
View File
@@ -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
+27 -12
View File
@@ -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, if ctx.pending_deletes.take(chat_id, message.id):
message.id, await repository.mark_deleted_late(
message.date, 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,
) )
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()
+158 -65
View File
@@ -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): if not changed:
return False return False
await conn.execute(_SNAPSHOT_ORIGINAL, account_id, chat_id, message_id) if _is_stale(current, row):
await conn.execute( await _record_past(conn, account_id, row)
_TOUCH_EDITED, return False
account_id, await _record_edit(conn, account_id, row, mark_edited=True)
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
)
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"
)
+30 -16
View File
@@ -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:
await repository.bump_flood_wait(self.pool, ctx.job_id)
wait = exc.value if isinstance(exc.value, int) else 1 wait = exc.value if isinstance(exc.value, int) else 1
await repository.defer_flood_wait(self.pool, ctx.job_id, wait)
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( await repository.finish(self.pool, ctx.job_id, JobStatus.FAILED, str(exc))
self.pool, ctx.job_id, JobStatus.FAILED, str(exc)
)
return
else: else:
await repository.finish(self.pool, ctx.job_id, JobStatus.DONE) 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)
@@ -5,6 +5,7 @@ from userbot.modules.jobs.handlers import (
fetch_avatar, fetch_avatar,
fetch_custom_emoji, fetch_custom_emoji,
fetch_media, fetch_media,
fetch_read_info,
search_peers, search_peers,
sync_contacts, sync_contacts,
sync_dialogs, sync_dialogs,
@@ -18,6 +19,7 @@ __all__ = [
"fetch_avatar", "fetch_avatar",
"fetch_custom_emoji", "fetch_custom_emoji",
"fetch_media", "fetch_media",
"fetch_read_info",
"search_peers", "search_peers",
"sync_contacts", "sync_contacts",
"sync_dialogs", "sync_dialogs",
@@ -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
@@ -0,0 +1,124 @@
from datetime import UTC, datetime, timedelta
from pyrogram import Client, raw
from pyrogram.errors import (
ChannelInvalid,
ChannelPrivate,
ChatTooBig,
FloodPremiumWait,
FloodWait,
MessageIdInvalid,
MessageNotReadYet,
MessageTooOld,
MsgIdInvalid,
MsgTooOld,
PeerIdInvalid,
RPCError,
UserPrivacyRestricted,
YourPrivacyRestricted,
)
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.read_receipts.repository import save_fetch, save_reads
from utils.jobs import enqueue
from utils.logging import logger
from utils.policy.models import ChatKind
from utils.read.peers import get_peers
from utils.read.read_receipts import read_info
RETRY = timedelta(seconds=60)
LATER = timedelta(days=1)
Refusal = tuple[tuple[type[Exception], ...], str, timedelta]
REFUSALS: tuple[Refusal, ...] = (
((MessageNotReadYet,), "not_read_yet", RETRY),
((UserPrivacyRestricted,), "user_privacy", RETRY),
((YourPrivacyRestricted,), "your_privacy", LATER),
((ChatTooBig,), "chat_too_big", LATER),
((MsgTooOld, MessageTooOld), "too_old", LATER),
(
(
PeerIdInvalid,
ChannelInvalid,
ChannelPrivate,
MessageIdInvalid,
MsgIdInvalid,
KeyError,
ValueError,
),
"unavailable",
RETRY,
),
)
def _refusal(exc: Exception) -> tuple[str, timedelta]:
for kinds, outcome, retry in REFUSALS:
if isinstance(exc, kinds):
return outcome, retry
return "error", RETRY
def _at(timestamp: int) -> datetime:
return datetime.fromtimestamp(timestamp, UTC)
async def ask_telegram(
client: Client, chat_id: int, message_id: int, *, group: bool
) -> list[tuple[int, datetime]]:
peer = await client.resolve_peer(chat_id)
if peer is None:
msg = f"unknown peer {chat_id}"
raise ValueError(msg)
if group:
participants = await client.invoke(
raw.functions.messages.GetMessageReadParticipants(
peer=peer, msg_id=message_id
)
)
return [(item.user_id, _at(item.date)) for item in participants]
outbox = await client.invoke(
raw.functions.messages.GetOutboxReadDate(peer=peer, msg_id=message_id)
)
return [(chat_id, _at(outbox.date))]
async def _enrich_unknown(ctx: JobContext, chat_id: int, reader_ids: list[int]) -> None:
known = {
peer.peer_id for peer in await get_peers(ctx.pool, ctx.account_id, reader_ids)
}
if set(reader_ids) - known:
await enqueue(ctx.pool, ctx.account_id, "enrich_chat", {"chat_id": chat_id})
@register("fetch_read_info")
async def fetch_read_info(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
chat_id = ctx.job.params["chat_id"]
message_id = ctx.job.params["message_id"]
info = await read_info(ctx.pool, ctx.account_id, chat_id, message_id)
if info is None or not info.can_fetch:
return
group = info.chat_kind == ChatKind.GROUP
try:
reads = await ask_telegram(client, chat_id, message_id, group=group)
except (FloodWait, FloodPremiumWait) as exc:
wait = exc.value if isinstance(exc.value, int) else 0
retry = max(RETRY, timedelta(seconds=wait))
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, "flood", retry)
return
except (RPCError, KeyError, ValueError) as exc:
outcome, retry = _refusal(exc)
if outcome == "error":
logger.warning(f"[yellow]Read info {chat_id}/{message_id}: {exc!r}[/]")
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, outcome, retry)
return
source = "participants" if group else "outbox_date"
await save_reads(ctx.pool, ctx.account_id, chat_id, message_id, source, reads)
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, "ok", RETRY)
if group and reads:
await _enrich_unknown(ctx, chat_id, [reader_id for reader_id, _ in reads])
@@ -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(
+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 " "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)
if history:
await conn.execute(_INSERT_HISTORY, account_id, peer_id, *args) 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
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timedelta
import asyncpg import asyncpg
@@ -9,6 +9,26 @@ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (account_id, chat_id, message_id, ts) DO NOTHING ON CONFLICT (account_id, chat_id, message_id, ts) DO NOTHING
""" """
_UPSERT_READ = """
INSERT INTO message_reads
(account_id, chat_id, message_id, reader_id, read_at, source, fetched_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (account_id, chat_id, message_id, reader_id) DO UPDATE SET
read_at = EXCLUDED.read_at,
source = EXCLUDED.source,
fetched_at = EXCLUDED.fetched_at
"""
_UPSERT_FETCH = """
INSERT INTO read_fetches
(account_id, chat_id, message_id, outcome, fetched_at, retry_at)
VALUES ($1, $2, $3, $4, now(), now() + $5::interval)
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
outcome = EXCLUDED.outcome,
fetched_at = EXCLUDED.fetched_at,
retry_at = EXCLUDED.retry_at
"""
async def insert_read( # noqa: PLR0913 async def insert_read( # noqa: PLR0913
pool: asyncpg.Pool, pool: asyncpg.Pool,
@@ -34,3 +54,33 @@ async def insert_listened( # noqa: PLR0913
await pool.execute( await pool.execute(
_INSERT, account_id, chat_id, message_id, ts, reader_id, "listened", raw _INSERT, account_id, chat_id, message_id, ts, reader_id, "listened", raw
) )
async def save_reads( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
message_id: int,
source: str,
reads: list[tuple[int, datetime]],
) -> None:
await pool.executemany(
_UPSERT_READ,
[
(account_id, chat_id, message_id, reader_id, read_at, source)
for reader_id, read_at in reads
],
)
async def save_fetch( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
message_id: int,
outcome: str,
retry_after: timedelta,
) -> None:
await pool.execute(
_UPSERT_FETCH, account_id, chat_id, message_id, outcome, retry_after
)
Whitespace-only changes.
@@ -0,0 +1,16 @@
import asyncpg
_HEARTBEAT = """
WITH extended AS (
UPDATE capture_uptime SET seen_at = now()
WHERE account_id = $1 AND seen_at >= now() - interval '150 seconds'
RETURNING account_id
)
INSERT INTO capture_uptime (account_id, started_at, seen_at)
SELECT $1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM extended)
"""
async def heartbeat(pool: asyncpg.Pool, account_id: int) -> None:
await pool.execute(_HEARTBEAT, account_id)
+66 -29
View File
@@ -11,10 +11,13 @@ 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 userbot.modules.uptime import repository as uptime
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 +31,14 @@ from utils.storage import ContentAddressedStorage
setup_logging() setup_logging()
SESSION_FLUSH_INTERVAL = 60 SESSION_FLUSH_INTERVAL = 60
SESSION_RETRY_INTERVAL = 60
UPTIME_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 +59,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 +92,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 +108,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 +130,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 +139,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 +164,25 @@ 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[/]")
async def _keep_uptime(registry: AccountRegistry, pool: asyncpg.Pool) -> None:
while True:
for client in registry.clients:
if client.capture is not None and client.is_connected:
with contextlib.suppress(Exception):
await uptime.heartbeat(pool, client.capture.account_id)
await asyncio.sleep(UPTIME_INTERVAL)
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 +195,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 +226,24 @@ 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))
beat = asyncio.create_task(_keep_uptime(registry, pool))
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 _cancel(beat)
await listen_conn.close() await listener.stop()
await registry.close() await registry.close()
await container.close() await container.close()
+3
View File
@@ -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):
+10 -2
View File
@@ -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)
) )
+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)
+20 -12
View File
@@ -4,23 +4,31 @@ import asyncpg
from utils.read.accounts import self_user_id from utils.read.accounts import self_user_id
from utils.read.models import ResponseStats, VolumeBucket from utils.read.models import ResponseStats, VolumeBucket
from utils.read.period import period_bounds
_VOLUME = """
WITH counts AS (
SELECT (date AT TIME ZONE $5)::date AS day, count(*) AS total,
count(*) FILTER (WHERE sender_id = $3) AS outgoing
FROM messages
WHERE account_id = $1 AND chat_id = $2 AND date >= $4
GROUP BY 1
)
SELECT local_day::date AS bucket, coalesce(total, 0) AS total,
coalesce(outgoing, 0) AS outgoing
FROM generate_series(($4 AT TIME ZONE $5)::date, (now() AT TIME ZONE $5)::date,
interval '1 day') AS local_day
LEFT JOIN counts ON counts.day = local_day::date
ORDER BY 1
"""
async def message_volume( async def message_volume(
pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int = 90 pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int, tz: str
) -> list[VolumeBucket]: ) -> list[VolumeBucket]:
self_id = await self_user_id(pool, account_id) self_id = await self_user_id(pool, account_id)
rows = await pool.fetch( since, _ = await period_bounds(pool, days, tz)
"SELECT date_trunc('day', date) AS bucket, count(*) AS total, " rows = await pool.fetch(_VOLUME, account_id, chat_id, self_id, since, tz)
"count(*) FILTER (WHERE sender_id = $3) AS outgoing "
"FROM messages "
"WHERE account_id = $1 AND chat_id = $2 AND date >= $4 "
"GROUP BY bucket ORDER BY bucket",
account_id,
chat_id,
self_id,
datetime.now(UTC) - timedelta(days=days),
)
return [ return [
VolumeBucket( VolumeBucket(
bucket=row["bucket"], bucket=row["bucket"],
+12 -6
View File
@@ -10,7 +10,7 @@ from utils.read.models import (
MessageView, MessageView,
Page, Page,
) )
from utils.read.read_receipts import read_up_to from utils.read.read_receipts import read_times, read_up_to
_MESSAGE_COLS = ( _MESSAGE_COLS = (
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, " "chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
@@ -285,12 +285,18 @@ async def _apply_read_status(
self_id = await self_user_id(pool, account_id) self_id = await self_user_id(pool, account_id)
if self_id is None: if self_id is None:
return return
marker = await read_up_to(pool, account_id, chat_id) own = [view for view in views if view.sender_id == self_id]
if marker is None: if not own:
return return
for view in views: marker = await read_up_to(pool, account_id, chat_id)
if view.sender_id == self_id and view.message_id <= marker: times = await read_times(
view.read = True pool, account_id, chat_id, [(view.message_id, view.date) for view in own]
)
for view in own:
view.read_at = times.get(view.message_id)
view.read = view.read_at is not None or (
marker is not None and view.message_id <= marker
)
def _build_album( def _build_album(
+79 -2
View File
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import date, datetime
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
@@ -203,6 +203,7 @@ class MessageView(BaseModel):
is_sticker: bool = False is_sticker: bool = False
is_animated_emoji: bool = False is_animated_emoji: bool = False
read: bool = False read: bool = False
read_at: datetime | None = None
class MessageVersionView(BaseModel): class MessageVersionView(BaseModel):
@@ -361,8 +362,61 @@ class PresenceHourly(BaseModel):
last_seen: datetime | None last_seen: datetime | None
class PresenceSession(BaseModel):
started: datetime
ended: datetime
seconds: float
online_seconds: float
ongoing: bool
class PresenceHour(BaseModel):
weekday: int
hour: int
hours: int
online_minutes: float | None
covered_hours: float
class PresenceDay(BaseModel):
day: date
online_seconds: float
covered_seconds: float
class CoverageGap(BaseModel):
started: datetime
ended: datetime
class PresenceSummary(BaseModel):
online_seconds: float
daily_seconds: float | None
covered_seconds: float
session_count: int
median_session_seconds: float | None
first_online: int | None
last_offline: int | None
last_seen: datetime | None
class PresenceStats(BaseModel):
peer_id: int
tz: str
since: datetime
until: datetime
current: PresenceSample | None
online: bool
hidden: bool
summary: PresenceSummary
week: list[PresenceHour]
days: list[PresenceDay]
gaps: list[CoverageGap]
sessions: list[PresenceSession]
class VolumeBucket(BaseModel): class VolumeBucket(BaseModel):
bucket: datetime bucket: date
total: int total: int
outgoing: int outgoing: int
incoming: int incoming: int
@@ -387,6 +441,29 @@ class PeerView(BaseModel):
updated_at: datetime updated_at: datetime
class ReaderView(BaseModel):
peer_id: int
read_at: datetime
peer: PeerView | None
class ReadInfoView(BaseModel):
chat_id: int
message_id: int
chat_kind: str
own: bool
voice: bool
observed_at: datetime | None
listened_at: datetime | None
exact_at: datetime | None
readers: list[ReaderView]
read: bool
unread: bool
outcome: str | None
fetched_at: datetime | None
can_fetch: bool
class PeerHistoryView(BaseModel): class PeerHistoryView(BaseModel):
observed_at: datetime observed_at: datetime
first_name: str | None first_name: str | None
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
import asyncpg
DEFAULT_TZ = "UTC"
MAX_DAYS = 365
_timezones: set[str] = set()
async def known_timezone(pool: asyncpg.Pool, tz: str) -> bool:
if not _timezones:
rows = await pool.fetch("SELECT name FROM pg_timezone_names")
_timezones.update(row["name"] for row in rows)
return tz in _timezones
async def period_bounds(
pool: asyncpg.Pool, days: int, tz: str
) -> tuple[datetime, datetime]:
row = await pool.fetchrow(
"SELECT (date_trunc('day', now() AT TIME ZONE $1) "
"- make_interval(days => $2 - 1)) AT TIME ZONE $1 AS since, now() AS until",
tz,
days,
)
return row["since"], row["until"]
+289
View File
@@ -0,0 +1,289 @@
import json
import asyncpg
from utils.read.models import (
CoverageGap,
Page,
PresenceDay,
PresenceHour,
PresenceSession,
PresenceStats,
PresenceSummary,
)
from utils.read.period import period_bounds
from utils.read.presence import current_presence
SECONDS_IN_HOUR = 3600
SECONDS_IN_DAY = 86_400
MIN_CELL_COVERAGE = 1800
MIN_CELL_SHARE = 0.5
STATS_WORK_MEM = "32MB"
FUZZY_STATUSES = {"recently", "last_week", "last_month", "long_time_ago"}
_SESSIONS = """
marks AS (
SELECT ts, status,
least(next_offline_date, ts + interval '5 minutes',
lead(coalesce(last_online_date, ts)) OVER w, $4) AS ended,
lag(CASE WHEN status = 'online'
THEN least(next_offline_date, ts + interval '5 minutes')
ELSE coalesce(last_online_date, ts) END) OVER w AS previous
FROM presence
WHERE account_id = $1 AND peer_id = $2
AND ts >= $3::timestamptz - interval '5 minutes' AND ts <= $4
WINDOW w AS (ORDER BY ts)
),
online AS (
SELECT greatest(ts, $3) AS started, ended,
count(*) FILTER (WHERE previous IS NULL
OR ts > previous + interval '5 minutes')
OVER (ORDER BY ts) AS visit
FROM marks
WHERE status = 'online' AND ended > greatest(ts, $3)
),
sessions AS (
SELECT min(started) AS started, max(ended) AS ended,
extract(epoch FROM sum(ended - started))::float8 AS online_seconds
FROM online
GROUP BY visit
)
"""
_SESSION_FIELDS = """
started, ended, extract(epoch FROM ended - started)::float8 AS seconds,
online_seconds, ended >= $4 AS ongoing
"""
_SESSION_PAGE = f"""
WITH {_SESSIONS}
SELECT {_SESSION_FIELDS}
FROM sessions
ORDER BY started DESC
LIMIT $5 OFFSET $6
""" # noqa: S608
_COVERED_HOURS = """
SELECT local_hour,
extract(epoch FROM sum(
least(ended, (local_hour + interval '1 hour') AT TIME ZONE $5)
- greatest(started, local_hour AT TIME ZONE $5)))::float8 AS seconds
FROM uptime,
generate_series(date_trunc('hour', started AT TIME ZONE $5),
date_trunc('hour', ended AT TIME ZONE $5),
interval '1 hour') AS local_hour
GROUP BY local_hour
"""
_STATS = f"""
WITH {_SESSIONS},
uptime AS (
SELECT greatest(started_at, $3) AS started,
least(seen_at + interval '90 seconds', $4) AS ended
FROM capture_uptime
WHERE account_id = $1 AND started_at < $4
AND seen_at + interval '90 seconds' > $3
),
quarters AS (
SELECT quarter, sum(ended - started - spill) AS inside, sum(spill) AS spill
FROM (SELECT started, ended, quarter,
greatest(ended - quarter - interval '15 minutes',
interval '0') AS spill
FROM (SELECT started, ended,
date_bin('15 minutes', started, timestamptz 'epoch') AS quarter
FROM online) binned) split
GROUP BY quarter
),
online_hours AS (
SELECT date_trunc('hour', quarter AT TIME ZONE $5) AS local_hour,
extract(epoch FROM sum(share))::float8 AS seconds
FROM (SELECT quarter, inside AS share FROM quarters
UNION ALL
SELECT quarter + interval '15 minutes', spill FROM quarters) shares
GROUP BY 1
),
covered_hours AS ({_COVERED_HOURS}),
hours AS (
SELECT local_hour,
extract(epoch FROM
least((local_hour + interval '1 hour') AT TIME ZONE $5, $4)
- greatest(local_hour AT TIME ZONE $5, $3))::float8 AS span,
coalesce(online.seconds, 0) AS online,
least(3600, greatest(coalesce(covered.seconds, 0),
coalesce(online.seconds, 0))) AS covered
FROM generate_series(date_trunc('hour', $3 AT TIME ZONE $5),
date_trunc('hour', $4 AT TIME ZONE $5),
interval '1 hour') AS local_hour
LEFT JOIN online_hours AS online USING (local_hour)
LEFT JOIN covered_hours AS covered USING (local_hour)
),
week AS (
SELECT extract(isodow FROM local_hour)::int - 1 AS weekday,
extract(hour FROM local_hour)::int AS hour,
count(*) AS hours, sum(span) AS span,
sum(online) AS online, sum(covered) AS covered
FROM hours
GROUP BY 1, 2
),
days AS (
SELECT local_hour::date AS day, sum(online) AS online, sum(covered) AS covered
FROM hours
GROUP BY 1
),
uptime_reach AS (
SELECT started,
coalesce(max(ended) OVER (ORDER BY started
ROWS BETWEEN UNBOUNDED PRECEDING
AND 1 PRECEDING), $3) AS reached
FROM uptime
),
gaps AS (
SELECT reached AS started, started AS ended FROM uptime_reach
UNION ALL
SELECT coalesce(max(ended), $3), $4 FROM uptime
),
quiet AS (
SELECT hour
FROM week
GROUP BY hour
ORDER BY sum(online) / nullif(sum(covered), 0) NULLS LAST, hour
LIMIT 1
),
activity_days AS (
SELECT (sessions.started AT TIME ZONE $5) - make_interval(hours => quiet.hour)
AS started,
(sessions.ended AT TIME ZONE $5) - make_interval(hours => quiet.hour)
AS ended,
(($4 AT TIME ZONE $5) - make_interval(hours => quiet.hour))::date AS today
FROM sessions, quiet
),
daily AS (
SELECT extract(epoch FROM min(started - started::date))::float8 AS first_online,
extract(epoch FROM max(ended - started::date))::float8 AS last_offline
FROM activity_days
WHERE started::date < today
GROUP BY started::date
)
SELECT
(SELECT count(*) FROM sessions) AS session_count,
(SELECT coalesce(sum(online_seconds), 0) FROM sessions) AS online_seconds,
(SELECT sum(covered) FROM hours) AS covered_seconds,
(SELECT percentile_cont(0.5) WITHIN GROUP (
ORDER BY extract(epoch FROM ended - started)) FROM sessions) AS median_session,
(SELECT max(ended) FROM sessions) AS last_seen,
(SELECT hour FROM quiet) AS day_start,
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY first_online)
FROM daily) AS first_online,
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY last_offline)
FROM daily) AS last_offline,
(SELECT json_agg(json_build_object(
'weekday', weekday, 'hour', hour, 'hours', hours, 'span', span,
'online', online, 'covered', covered))
FROM week) AS week,
(SELECT json_agg(json_build_object(
'day', day, 'online_seconds', online, 'covered_seconds', covered)
ORDER BY day)
FROM days) AS days,
(SELECT json_agg(json_build_object('started', started, 'ended', ended)
ORDER BY started)
FROM gaps WHERE ended - started > interval '5 minutes') AS gaps,
(SELECT json_agg(row_to_json(page) ORDER BY page.started DESC)
FROM (SELECT {_SESSION_FIELDS} FROM sessions
ORDER BY started DESC LIMIT $6 OFFSET $7) page) AS sessions
""" # noqa: S608
def _time_of_day(shifted: float | None, day_start: int | None) -> int | None:
if shifted is None or day_start is None:
return None
return round(shifted + day_start * SECONDS_IN_HOUR) % SECONDS_IN_DAY
def _week(rows: list[dict]) -> list[PresenceHour]:
cells = {(row["weekday"], row["hour"]): row for row in rows}
week: list[PresenceHour] = []
for weekday in range(7):
for hour in range(24):
row = cells.get(
(weekday, hour), {"hours": 0, "span": 0, "online": 0, "covered": 0}
)
covered = row["covered"]
minutes = (
min(60.0, row["online"] / covered * 60)
if covered > 0
and covered >= min(MIN_CELL_COVERAGE, row["span"] * MIN_CELL_SHARE)
else None
)
week.append(
PresenceHour(
weekday=weekday,
hour=hour,
hours=row["hours"],
online_minutes=minutes,
covered_hours=covered / SECONDS_IN_HOUR,
)
)
return week
async def presence_stats( # noqa: PLR0913
pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str
) -> PresenceStats:
since, until = await period_bounds(pool, days, tz)
async with pool.acquire() as conn, conn.transaction():
await conn.execute(f"SET LOCAL work_mem = '{STATS_WORK_MEM}'")
row = await conn.fetchrow(
_STATS,
account_id,
peer_id,
since,
until,
tz,
page.capped_limit,
page.offset,
)
current = await current_presence(pool, account_id, peer_id)
sessions = [PresenceSession(**item) for item in json.loads(row["sessions"] or "[]")]
covered = row["covered_seconds"] or 0
online = row["online_seconds"]
return PresenceStats(
peer_id=peer_id,
tz=tz,
since=since,
until=until,
current=current,
online=current is not None
and current.status == "online"
and current.next_offline_date is not None
and current.next_offline_date > until,
hidden=row["session_count"] == 0
and current is not None
and current.status in FUZZY_STATUSES,
summary=PresenceSummary(
online_seconds=online,
daily_seconds=online / covered * SECONDS_IN_DAY
if covered >= SECONDS_IN_HOUR
else None,
covered_seconds=covered,
session_count=row["session_count"],
median_session_seconds=row["median_session"],
first_online=_time_of_day(row["first_online"], row["day_start"]),
last_offline=_time_of_day(row["last_offline"], row["day_start"]),
last_seen=row["last_seen"]
or (current.last_online_date if current else None),
),
week=_week(json.loads(row["week"] or "[]")),
days=[PresenceDay(**item) for item in json.loads(row["days"] or "[]")],
gaps=[CoverageGap(**item) for item in json.loads(row["gaps"] or "[]")],
sessions=sessions,
)
async def presence_sessions( # noqa: PLR0913
pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str
) -> list[PresenceSession]:
since, until = await period_bounds(pool, days, tz)
rows = await pool.fetch(
_SESSION_PAGE, account_id, peer_id, since, until, page.capped_limit, page.offset
)
return [PresenceSession(**dict(row)) for row in rows]
+178
View File
@@ -1,5 +1,89 @@
from datetime import UTC, datetime, timedelta
import asyncpg import asyncpg
from utils.policy.models import ChatKind
from utils.read.discover import chat_kind
from utils.read.models import ReaderView, ReadInfoView
from utils.read.peers import get_peers
FETCH_WINDOW = timedelta(days=7)
CLOCK_SKEW = timedelta(minutes=1)
VOICE_KINDS = ["voice", "video_note"]
_READ_TIMES = """
SELECT m.id,
COALESCE(
(SELECT min(mr.read_at) FROM message_reads mr
WHERE mr.account_id = $1 AND mr.chat_id = $2 AND mr.message_id = m.id),
CASE WHEN m.date >= $5 THEN (
SELECT r.ts FROM read_receipts r
WHERE r.account_id = $1 AND r.chat_id = $2 AND r.kind = 'read'
AND r.message_id >= m.id AND r.ts >= m.date - $6::interval
ORDER BY r.ts LIMIT 1
) END
) AS read_at
FROM unnest($3::bigint[], $4::timestamptz[]) AS m(id, date)
"""
_MESSAGE = """
SELECT m.sender_id, m.date, a.tg_user_id, a.is_bot,
EXISTS (
SELECT 1 FROM media md
WHERE md.account_id = m.account_id AND md.chat_id = m.chat_id
AND md.message_id = m.message_id AND md.kind = ANY($4::text[])
) AS voice
FROM messages m
JOIN accounts a ON a.account_id = m.account_id
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
LIMIT 1
"""
_RECEIPTS = """
SELECT
(SELECT ts FROM read_receipts
WHERE account_id = $1 AND chat_id = $2 AND kind = 'read'
AND message_id >= $3 AND ts >= $4
ORDER BY ts LIMIT 1) AS observed_at,
(SELECT min(ts) FROM read_receipts
WHERE account_id = $1 AND chat_id = $2 AND kind = 'listened'
AND message_id = $3) AS listened_at,
(SELECT max(message_id) FROM read_receipts
WHERE account_id = $1 AND chat_id = $2 AND kind = 'read') AS watermark
"""
_READS = """
SELECT reader_id, read_at, source FROM message_reads
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
ORDER BY read_at
"""
_FETCH = """
SELECT outcome, fetched_at, retry_at FROM read_fetches
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
"""
_INHERITED = """
SELECT outcome FROM read_fetches
WHERE account_id = $1 AND retry_at > now()
AND ((chat_id = $2 AND outcome = 'chat_too_big') OR outcome = 'your_privacy')
LIMIT 1
"""
_capture_starts: dict[int, datetime] = {}
async def capture_start(pool: asyncpg.Pool, account_id: int) -> datetime | None:
cached = _capture_starts.get(account_id)
if cached is not None:
return cached
start = await pool.fetchval(
"SELECT min(ts) FROM read_receipts WHERE account_id = $1", account_id
)
if start is not None:
_capture_starts[account_id] = start
return start
async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int | None: async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int | None:
return await pool.fetchval( return await pool.fetchval(
@@ -8,3 +92,97 @@ async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int |
account_id, account_id,
chat_id, chat_id,
) )
async def read_times(
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
messages: list[tuple[int, datetime]],
) -> dict[int, datetime]:
if not messages:
return {}
start = await capture_start(pool, account_id) or datetime.max.replace(tzinfo=UTC)
rows = await pool.fetch(
_READ_TIMES,
account_id,
chat_id,
[message_id for message_id, _ in messages],
[date for _, date in messages],
start,
CLOCK_SKEW,
)
return {row["id"]: row["read_at"] for row in rows if row["read_at"] is not None}
async def read_info(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> ReadInfoView | None:
message = await pool.fetchrow(
_MESSAGE, account_id, chat_id, message_id, VOICE_KINDS
)
if message is None:
return None
kind = await chat_kind(pool, account_id, chat_id)
date: datetime = message["date"]
receipts = await pool.fetchrow(
_RECEIPTS, account_id, chat_id, message_id, date - CLOCK_SKEW
)
reads = await pool.fetch(_READS, account_id, chat_id, message_id)
fetch = await pool.fetchrow(_FETCH, account_id, chat_id, message_id)
inherited = await pool.fetchval(_INHERITED, account_id, chat_id)
start = await capture_start(pool, account_id)
observed_at = receipts["observed_at"] if start and date >= start else None
exact_at = next(
(row["read_at"] for row in reads if row["source"] == "outbox_date"), None
)
reader_rows = [row for row in reads if row["source"] == "participants"]
known = {
peer.peer_id: peer
for peer in await get_peers(
pool, account_id, [row["reader_id"] for row in reader_rows]
)
}
readers = [
ReaderView(
peer_id=row["reader_id"],
read_at=row["read_at"],
peer=known.get(row["reader_id"]),
)
for row in reader_rows
]
outcome = fetch["outcome"] if fetch else inherited
now = datetime.now(UTC)
own = (
message["sender_id"] is not None
and message["sender_id"] == (message["tg_user_id"])
)
applicable = own and kind != ChatKind.CHANNEL and chat_id != message["tg_user_id"]
watermark = receipts["watermark"]
covered = watermark is not None and message_id <= watermark
seen = covered or any((exact_at, readers, receipts["listened_at"]))
evidence = watermark is not None or outcome in {"ok", "not_read_yet"}
due = fetch is None or fetch["retry_at"] <= now
return ReadInfoView(
chat_id=chat_id,
message_id=message_id,
chat_kind=kind.value,
own=own,
voice=message["voice"],
observed_at=observed_at,
listened_at=receipts["listened_at"],
exact_at=exact_at,
readers=readers,
read=applicable and seen,
unread=applicable and not seen and evidence,
outcome=outcome,
fetched_at=fetch["fetched_at"] if fetch else None,
can_fetch=applicable
and not message["is_bot"]
and now - date < FETCH_WINDOW
and exact_at is None
and inherited is None
and due,
)
+17 -1
View File
@@ -167,6 +167,7 @@ class BotRegistry:
await client.stop() await client.stop()
return return
try:
account_id = await accounts.sync_account( account_id = await accounts.sync_account(
self._pool, me, str(bot_id), is_bot=True self._pool, me, str(bot_id), is_bot=True
) )
@@ -175,9 +176,24 @@ class BotRegistry:
) )
await bots.link_account(self._pool, bot_id, account_id) await bots.link_account(self._pool, bot_id, account_id)
await bots.set_status( await bots.set_status(
self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name self._pool,
bot_id,
bots.STATUS_OK,
username=me.username,
title=me.full_name,
) )
await accounts.notify_accounts_changed(self._pool) 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(
+10 -16
View File
@@ -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()
+10 -1
View File
@@ -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");
+21 -7
View File
@@ -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);
+39 -10
View File
@@ -28,9 +28,11 @@ import type {
PolicyRecord, PolicyRecord,
PolicyRule, PolicyRule,
PolicyScope, PolicyScope,
PresenceHourly,
PresenceSample, PresenceSample,
PresenceSession,
PresenceStats,
ReactionView, ReactionView,
ReadInfo,
ResponseStats, ResponseStats,
ScopeTarget, ScopeTarget,
SearchHit, SearchHit,
@@ -205,30 +207,38 @@ export function getCurrentPresence(
}); });
} }
export function getPresenceHistory( export function getPresenceStats(
peerId: number, peerId: number,
days: number,
tz: string,
page: Page = {} page: Page = {}
): Promise<PresenceSample[]> { ): Promise<PresenceStats> {
return request<PresenceSample[]>("/presence", { return request<PresenceStats>("/presence/stats", {
account: true, account: true,
query: { peer_id: peerId, ...page }, query: { days, peer_id: peerId, tz, ...page },
}); });
} }
export function getPresenceHourly(peerId: number): Promise<PresenceHourly[]> { export function getPresenceSessions(
return request<PresenceHourly[]>("/presence/hourly", { peerId: number,
days: number,
tz: string,
page: Page = {}
): Promise<PresenceSession[]> {
return request<PresenceSession[]>("/presence/sessions", {
account: true, account: true,
query: { peer_id: peerId }, query: { days, peer_id: peerId, tz, ...page },
}); });
} }
export function getMessageVolume( export function getMessageVolume(
chatId: number, chatId: number,
days = 90 days: number,
tz: string
): Promise<VolumeBucket[]> { ): Promise<VolumeBucket[]> {
return request<VolumeBucket[]>("/analytics/volume", { return request<VolumeBucket[]>("/analytics/volume", {
account: true, account: true,
query: { chat_id: chatId, days }, query: { chat_id: chatId, days, tz },
}); });
} }
@@ -304,6 +314,25 @@ export function getChatLinks(
}); });
} }
export function getReadInfo(
chatId: number,
messageId: number
): Promise<ReadInfo> {
return request<ReadInfo>(`/messages/${chatId}/${messageId}/read`, {
account: true,
});
}
export function refreshReadInfo(
chatId: number,
messageId: number
): Promise<{ job_id: number | null }> {
return request<{ job_id: number | null }>(
`/messages/${chatId}/${messageId}/read/refresh`,
{ body: { account_id: accounts.selectedId }, method: "POST" }
);
}
export function getMessageReactions( export function getMessageReactions(
chatId: number, chatId: number,
messageId: number messageId: number
+85 -4
View File
@@ -204,6 +204,7 @@ export interface MessageView {
quote: string | null; quote: string | null;
reactions: ReactionCount[]; reactions: ReactionCount[];
read: boolean; read: boolean;
read_at: string | null;
reply: ReplyView | null; reply: ReplyView | null;
sender_id: number | null; sender_id: number | null;
service: ServiceView | null; service: ServiceView | null;
@@ -335,12 +336,57 @@ export interface PresenceSample {
ts: string; ts: string;
} }
export interface PresenceHourly { export interface PresenceSession {
bucket: string; ended: string;
ongoing: boolean;
online_seconds: number;
seconds: number;
started: string;
}
export interface PresenceHour {
covered_hours: number;
hour: number;
hours: number;
online_minutes: number | null;
weekday: number;
}
export interface PresenceDay {
covered_seconds: number;
day: string;
online_seconds: number;
}
export interface CoverageGap {
ended: string;
started: string;
}
export interface PresenceSummary {
covered_seconds: number;
daily_seconds: number | null;
first_online: number | null;
last_offline: number | null;
last_seen: string | null; last_seen: string | null;
online_samples: number; median_session_seconds: number | null;
online_seconds: number;
session_count: number;
}
export interface PresenceStats {
current: PresenceSample | null;
days: PresenceDay[];
gaps: CoverageGap[];
hidden: boolean;
online: boolean;
peer_id: number; peer_id: number;
samples: number; sessions: PresenceSession[];
since: string;
summary: PresenceSummary;
tz: string;
until: string;
week: PresenceHour[];
} }
export interface VolumeBucket { export interface VolumeBucket {
@@ -369,6 +415,40 @@ export interface PeerView {
username: string | null; username: string | null;
} }
export interface ReaderView {
peer: PeerView | null;
peer_id: number;
read_at: string;
}
export type ReadOutcome =
| "ok"
| "not_read_yet"
| "user_privacy"
| "your_privacy"
| "chat_too_big"
| "too_old"
| "unavailable"
| "flood"
| "error";
export interface ReadInfo {
can_fetch: boolean;
chat_id: number;
chat_kind: "dm" | "group" | "channel";
exact_at: string | null;
fetched_at: string | null;
listened_at: string | null;
message_id: number;
observed_at: string | null;
outcome: ReadOutcome | null;
own: boolean;
read: boolean;
readers: ReaderView[];
unread: boolean;
voice: boolean;
}
export interface PeerHistoryView { export interface PeerHistoryView {
first_name: string | null; first_name: string | null;
is_deleted_account: boolean; is_deleted_account: boolean;
@@ -518,6 +598,7 @@ export interface LivePresenceEvent {
export interface LiveReceiptEvent { export interface LiveReceiptEvent {
chat_id: number; chat_id: number;
read_at: string;
read_up_to: number; read_up_to: number;
type: "receipt"; type: "receipt";
} }
-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

+17 -40
View File
@@ -1,10 +1,6 @@
<script lang="ts"> <script lang="ts">
import { import { enqueueBackfill, getPeer } from "#lib/api/endpoints.js";
enqueueBackfill, import type { PeerView } from "#lib/api/types.js";
getCurrentPresence,
getPeer,
} from "#lib/api/endpoints.js";
import type { PeerView, PresenceSample } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.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 ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
@@ -16,6 +12,7 @@
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { presence } from "#lib/stores/presence.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -26,13 +23,10 @@
let { chatId }: Props = $props(); let { chatId }: Props = $props();
const PRESENCE_INTERVAL = 30_000;
const isDm = $derived(chatId > 0); const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId)); const chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId)); const discovered = $derived(discover.get(chatId));
let peer = $state<PeerView | null>(null); let peer = $state<PeerView | null>(null);
let presence = $state<PresenceSample | null>(null);
let backfilling = $state(false); let backfilling = $state(false);
async function backfill() { async function backfill() {
@@ -81,34 +75,8 @@
}); });
$effect(() => { $effect(() => {
if (accounts.selectedId === null || !isDm) { presence.follow(accounts.selectedId, isDm ? chatId : null);
presence = null; return () => presence.follow(null, null);
return;
}
let active = true;
presence = null;
const refresh = () => {
if (document.visibilityState !== "visible") {
return;
}
getCurrentPresence(chatId)
.then((result) => {
if (active) {
presence = result;
}
})
.catch(() => {
if (active) {
presence = null;
}
});
};
refresh();
const timer = setInterval(refresh, PRESENCE_INTERVAL);
return () => {
active = false;
clearInterval(timer);
};
}); });
const fallbackTitle = $derived( const fallbackTitle = $derived(
@@ -119,8 +87,8 @@
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle); const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
const subtitle = $derived.by(() => { const subtitle = $derived.by(() => {
if (isDm) { if (isDm) {
if (presence) { if (presence.sample) {
return formatPresence(presence); return formatPresence(presence.sample, presence.now);
} }
if (peer?.username) { if (peer?.username) {
return `@${peer.username}`; return `@${peer.username}`;
@@ -139,6 +107,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
@@ -172,7 +144,12 @@
/> />
<div class="info"> <div class="info">
<h2 class="title">{title}</h2> <h2 class="title">{title}</h2>
<span class="subtitle" class:online={isDm && isOnline(presence)}> <span
class={[
"subtitle",
{ online: isDm && isOnline(presence.sample, presence.now) },
]}
>
{subtitle} {subtitle}
</span> </span>
</div> </div>
+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>
@@ -11,6 +11,7 @@
import Poll from "#lib/components/Poll.svelte"; import Poll from "#lib/components/Poll.svelte";
import Reactions from "#lib/components/Reactions.svelte"; import Reactions from "#lib/components/Reactions.svelte";
import ReplyHeader from "#lib/components/ReplyHeader.svelte"; import ReplyHeader from "#lib/components/ReplyHeader.svelte";
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
@@ -247,6 +248,9 @@
</div> </div>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="note" onselect={openAnnotations} <ContextMenuItem icon="note" onselect={openAnnotations}
>Заметки</ContextMenuItem >Заметки</ContextMenuItem
> >
@@ -363,13 +363,13 @@
); );
} }
function applyReceipt(readUpTo: number) { function applyReceipt(readUpTo: number, readAt: string) {
if (ownId === null) { if (ownId === null) {
return; return;
} }
messages = messages.map((m) => messages = messages.map((m) =>
m.sender_id === ownId && m.message_id <= readUpTo && !m.read m.sender_id === ownId && m.message_id <= readUpTo && !m.read
? { ...m, read: true } ? { ...m, read: true, read_at: readAt }
: m : m
); );
} }
@@ -388,7 +388,7 @@
) { ) {
applyDelete(event.message_ids); applyDelete(event.message_ids);
} else if (event.type === "receipt" && event.chat_id === chatId) { } else if (event.type === "receipt" && event.chat_id === chatId) {
applyReceipt(event.read_up_to); applyReceipt(event.read_up_to, event.read_at);
} }
} }
@@ -14,6 +14,7 @@
import TgsSticker from "#lib/components/media/TgsSticker.svelte"; import TgsSticker from "#lib/components/media/TgsSticker.svelte";
import VideoNote from "#lib/components/media/VideoNote.svelte"; import VideoNote from "#lib/components/media/VideoNote.svelte";
import VoiceMessage from "#lib/components/media/VoiceMessage.svelte"; import VoiceMessage from "#lib/components/media/VoiceMessage.svelte";
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
@@ -222,6 +223,9 @@
</div> </div>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen} <ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть на весь экран</ContextMenuItem >Открыть на весь экран</ContextMenuItem
> >
@@ -2,6 +2,7 @@
import type { MessageView } from "#lib/api/types.js"; import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatTime } from "#lib/format/datetime.js"; import { formatTime } from "#lib/format/datetime.js";
import { readTitle } from "#lib/format/read.js";
interface Props { interface Props {
message: MessageView; message: MessageView;
@@ -25,11 +26,9 @@
{/if} {/if}
<span class="time">{formatTime(message.date)}</span> <span class="time">{formatTime(message.date)}</span>
{#if own} {#if own}
<Icon <span class="ticks" title={readTitle(message)}>
class="ticks" <Icon name={message.read ? "message-read" : "check"} size="1rem" />
name={message.read ? "message-read" : "check"} </span>
size="1rem"
/>
{/if} {/if}
</span> </span>
@@ -52,6 +51,10 @@
white-space: nowrap; white-space: nowrap;
} }
.ticks {
display: inline-flex;
}
.edited { .edited {
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
@@ -8,6 +8,7 @@
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte"; import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte"; import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
import ProfilePanel from "#lib/components/profile/ProfilePanel.svelte"; import ProfilePanel from "#lib/components/profile/ProfilePanel.svelte";
import ReadersPanel from "#lib/components/read/ReadersPanel.svelte";
import ChatSearchPanel from "#lib/components/search/ChatSearchPanel.svelte"; import ChatSearchPanel from "#lib/components/search/ChatSearchPanel.svelte";
import SharesPanel from "#lib/components/shares/SharesPanel.svelte"; import SharesPanel from "#lib/components/shares/SharesPanel.svelte";
import CallbacksPanel from "#lib/components/social/CallbacksPanel.svelte"; import CallbacksPanel from "#lib/components/social/CallbacksPanel.svelte";
@@ -18,13 +19,14 @@
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 WatchesPanel from "#lib/components/watches/WatchesPanel.svelte"; import WatchesPanel from "#lib/components/watches/WatchesPanel.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 { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { storageUi } from "#lib/stores/storage.svelte.js"; import { storageUi } from "#lib/stores/storage.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
const titles: Record<RightPanel, string> = { const titles: Record<RightPanel, string> = $derived({
alerts: "Алерты", alerts: "Алерты",
annotations: "Заметки", annotations: "Заметки",
bots: "Боты", bots: "Боты",
@@ -35,6 +37,7 @@
presence: "Аналитика", presence: "Аналитика",
profile: "Профиль", profile: "Профиль",
reactions: "Реакции", reactions: "Реакции",
readers: t("read.readers.title"),
search: "Поиск", search: "Поиск",
shares: "Файлы по ссылке", shares: "Файлы по ссылке",
storage: "Хранилище", storage: "Хранилище",
@@ -44,7 +47,7 @@
"stories-all": "Все сторис", "stories-all": "Все сторис",
versions: "Версии", versions: "Версии",
watches: "Отслеживания", watches: "Отслеживания",
}; });
const parents: Partial<Record<RightPanel, RightPanel>> = { const parents: Partial<Record<RightPanel, RightPanel>> = {
"storage-chat": "storage", "storage-chat": "storage",
@@ -104,6 +107,8 @@
<ChatSearchPanel /> <ChatSearchPanel />
{:else if ui.rightPanel === "reactions"} {:else if ui.rightPanel === "reactions"}
<ReactionsPanel /> <ReactionsPanel />
{:else if ui.rightPanel === "readers"}
<ReadersPanel />
{:else if ui.rightPanel === "callbacks"} {:else if ui.rightPanel === "callbacks"}
<CallbacksPanel /> <CallbacksPanel />
{:else if ui.rightPanel === "links"} {:else if ui.rightPanel === "links"}
@@ -1,193 +1,295 @@
<script lang="ts"> <script lang="ts">
import { import {
getCurrentPresence,
getMessageVolume, getMessageVolume,
getPresenceHistory, getPresenceStats,
getPresenceHourly,
getResponseStats, getResponseStats,
} from "#lib/api/endpoints.js"; } from "#lib/api/endpoints.js";
import type { import type {
PresenceHourly, PresenceStats,
PresenceSample,
ResponseStats, ResponseStats,
VolumeBucket, VolumeBucket,
} from "#lib/api/types.js"; } from "#lib/api/types.js";
import OnlineDays from "#lib/components/presence/OnlineDays.svelte";
import PresenceHeatmap from "#lib/components/presence/PresenceHeatmap.svelte"; import PresenceHeatmap from "#lib/components/presence/PresenceHeatmap.svelte";
import PresenceSessions from "#lib/components/presence/PresenceSessions.svelte";
import VolumeChart from "#lib/components/presence/VolumeChart.svelte"; import VolumeChart from "#lib/components/presence/VolumeChart.svelte";
import Button from "#lib/components/ui/Button.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import { formatFull } from "#lib/format/datetime.js"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatPresence } from "#lib/format/presence.js"; import { browserTimeZone, timeZoneLabel } from "#lib/format/datetime.js";
import {
formatClock,
formatLongSpan,
formatPresence,
formatSpan,
isOnline,
} from "#lib/format/presence.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { events } from "#lib/stores/events.svelte.js"; import { presence } from "#lib/stores/presence.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
const HISTORY_LIMIT = 200; const TEXT = {
const SECONDS_IN_MINUTE = 60; average: "В среднем за день",
byDay: "По дням",
byHour: "По часам недели",
failed: "Не удалось загрузить аналитику.",
firstOnline: "Обычно появляется",
hidden:
"Скрывает, когда был(а) в сети. Telegram показывает только «был(а) недавно», поэтому время в сети не посчитать.",
lastOffline: "Обычно уходит",
median: "Обычная сессия",
messages: "Сообщения",
mine: "Я отвечаю",
missing: "Нет данных",
noData: "нет данных",
noMessages: "За этот период сообщений нет.",
noStatus: "Статусов пока нет.",
openChat: "Откройте чат",
period: (count: number) => `${count} дней`,
retry: "Повторить",
sessions: "Сессии",
theirs: "Мне отвечают",
title: "Аналитика",
total: (count: number) => `в сети за ${count} дней`,
};
const PERIODS = [7, 30, 90];
const DEFAULT_PERIOD = 30;
const SESSION_PAGE = 30;
const SECONDS_IN_HOUR = 3600; const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400; const MS_IN_SECOND = 1000;
const tz = browserTimeZone();
const zone = timeZoneLabel();
const chatId = $derived( const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null page.params.chatId ? Number(page.params.chatId) : null
); );
const isDm = $derived(chatId !== null && chatId > 0); const isDm = $derived(chatId !== null && chatId > 0);
let current = $state<PresenceSample | null>(null); let days = $state(DEFAULT_PERIOD);
let hourly = $state<PresenceHourly[]>([]); let stats = $state<PresenceStats | null>(null);
let history = $state<PresenceSample[]>([]); let volume = $state<VolumeBucket[] | null>(null);
let volume = $state<VolumeBucket[]>([]);
let response = $state<ResponseStats | null>(null); let response = $state<ResponseStats | null>(null);
let loading = $state(false); let shownDays = $state(DEFAULT_PERIOD);
let failed = $state(false);
let shownChat: number | null = null;
let token = 0;
const changes = $derived.by(() => { const sample = $derived(
const result: PresenceSample[] = []; presence.sample?.peer_id === chatId
let last: string | null = null; ? presence.sample
for (const sample of history) { : (stats?.current ?? null)
if (sample.status !== last) { );
result.push(sample); const online = $derived(isOnline(sample, presence.now));
last = sample.status; const tracked = $derived(
stats !== null &&
(stats.current !== null || stats.summary.session_count > 0)
);
const quiet = $derived(
volume?.every((bucket) => bucket.total === 0) ?? false
);
const rows = $derived.by(() => {
if (stats === null) {
return [];
} }
const { summary } = stats;
const result: { label: string; value: string }[] = [];
if (summary.daily_seconds !== null) {
result.push({
label: TEXT.average,
value: formatSpan(summary.daily_seconds),
});
}
if (summary.median_session_seconds !== null) {
result.push({
label: TEXT.median,
value: formatSpan(summary.median_session_seconds),
});
}
if (summary.first_online !== null) {
result.push({
label: TEXT.firstOnline,
value: formatClock(summary.first_online),
});
}
if (summary.last_offline !== null) {
result.push({
label: TEXT.lastOffline,
value: formatClock(summary.last_offline),
});
}
const period =
(new Date(stats.until).getTime() - new Date(stats.since).getTime()) /
MS_IN_SECOND;
const missing = period - summary.covered_seconds;
if (missing >= SECONDS_IN_HOUR) {
result.push({ label: TEXT.missing, value: formatLongSpan(missing) });
} }
return result; return result;
}); });
function formatDuration(seconds: number | null): string { function reply(seconds: number | null): string {
if (seconds === null) { return seconds === null ? TEXT.noData : formatSpan(seconds);
return "—";
} }
if (seconds < SECONDS_IN_MINUTE) {
return `${Math.round(seconds)} с`; async function load(id: number, period: number) {
token += 1;
const current = token;
failed = false;
if (shownChat !== id) {
shownChat = id;
stats = null;
volume = null;
response = null;
} }
if (seconds < SECONDS_IN_HOUR) { try {
return `${Math.round(seconds / SECONDS_IN_MINUTE)} мин`; const [nextStats, nextVolume, nextResponse] = await Promise.all([
id > 0
? getPresenceStats(id, period, tz, { limit: SESSION_PAGE })
: Promise.resolve(null),
getMessageVolume(id, period, tz),
getResponseStats(id),
]);
if (current === token) {
stats = nextStats;
volume = nextVolume;
response = nextResponse;
shownDays = period;
}
} catch {
if (current === token) {
failed = true;
} }
if (seconds < SECONDS_IN_DAY) {
return `${Math.round(seconds / SECONDS_IN_HOUR)} ч`;
} }
return `${Math.round(seconds / SECONDS_IN_DAY)} д`;
} }
$effect(() => { $effect(() => {
if (accounts.selectedId === null || chatId === null) { if (accounts.selectedId !== null && chatId !== null) {
return; load(chatId, days);
} }
const id = chatId;
let active = true;
loading = true;
current = null;
hourly = [];
history = [];
volume = [];
response = null;
Promise.all([getMessageVolume(id), getResponseStats(id)])
.then(([vol, resp]) => {
if (active) {
volume = vol;
response = resp;
}
})
.catch(() => undefined)
.finally(() => {
if (active) {
loading = false;
}
});
if (id > 0) {
Promise.all([
getCurrentPresence(id),
getPresenceHourly(id),
getPresenceHistory(id, { limit: HISTORY_LIMIT }),
])
.then(([cur, hrs, hist]) => {
if (active) {
current = cur;
hourly = hrs;
history = hist;
}
})
.catch(() => undefined);
}
const unsub = events.subscribe((event) => {
if (event.type === "presence" && event.peer_id === id && event.sample) {
current = event.sample;
}
});
return () => {
active = false;
unsub();
};
}); });
</script> </script>
{#if chatId === null} {#if chatId === null}
<EmptyState description="Откройте чат" title="Аналитика" /> <EmptyState description={TEXT.openChat} title={TEXT.title} />
{:else} {:else}
<div class="analytics"> <div class="analytics">
<div class="periods">
{#each PERIODS as period (period)}
<button
aria-pressed={days === period}
class={["period", { on: days === period }]}
onclick={() => {
days = period;
}}
type="button"
>
{TEXT.period(period)}
</button>
{/each}
</div>
{#if failed}
<div class="state">
<p>{TEXT.failed}</p>
<Button
onclick={() => chatId !== null && load(chatId, days)}
smaller
variant="text"
>
{TEXT.retry}
</Button>
</div>
{:else if volume === null || (isDm && stats === null)}
<div class="state"><Spinner /></div>
{:else}
{#if isDm && stats}
<section class="hero">
{#if tracked && !stats.hidden}
<div class="total-line">
<span class="total"
>{formatSpan(stats.summary.online_seconds)}</span
>
<span class="count">{TEXT.total(shownDays)}</span>
</div>
{/if}
{#if sample}
<p class={["status", { online }]}>
{formatPresence(sample, presence.now)}
</p>
{/if}
{#if stats.hidden}
<p class="note">{TEXT.hidden}</p>
{:else if !tracked}
<p class="note">{TEXT.noStatus}</p>
{/if}
</section>
{#if tracked && !stats.hidden}
{#if rows.length > 0}
<section> <section>
<h3>Объём сообщений (90 дней)</h3> <dl class="rows">
{#if volume.length === 0} {#each rows as row (row.label)}
<p class="muted">{loading ? "Загрузка…" : "Нет сообщений"}</p> <div class="row">
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
{/each}
</dl>
</section>
{/if}
<section>
<div class="section-title">
<h3>{TEXT.byHour}</h3>
<span>{zone}</span>
</div>
<PresenceHeatmap week={stats.week} />
</section>
<section>
<div class="section-title"><h3>{TEXT.byDay}</h3></div>
<OnlineDays days={stats.days} />
</section>
{/if}
{/if}
<section>
<div class="section-title"><h3>{TEXT.messages}</h3></div>
{#if quiet}
<p class="state">{TEXT.noMessages}</p>
{:else} {:else}
<VolumeChart {volume} /> <VolumeChart {volume} />
{/if} {/if}
</section> {#if response && response.mine_count + response.their_count > 0}
<dl class="rows replies">
<section> <div class="row">
<h3>Время ответа</h3> <dt>{TEXT.mine}</dt>
{#if response} <dd>{reply(response.mine_median_seconds)}</dd>
<div class="stats">
<div class="stat">
<span class="value"
>{formatDuration(response.mine_median_seconds)}</span
>
<span class="caption">я отвечаю ({response.mine_count})</span>
</div> </div>
<div class="stat"> <div class="row">
<span class="value" <dt>{TEXT.theirs}</dt>
>{formatDuration(response.their_median_seconds)}</span <dd>{reply(response.their_median_seconds)}</dd>
>
<span class="caption">мне отвечают ({response.their_count})</span>
</div> </div>
</div> </dl>
{:else}
<p class="muted">{loading ? "Загрузка…" : "Нет данных"}</p>
{/if} {/if}
</section> </section>
{#if isDm} {#if isDm && stats && tracked && !stats.hidden}
<section> <section>
<div class="status" class:online={current?.status === "online"}> <div class="section-title"><h3>{TEXT.sessions}</h3></div>
{current ? formatPresence(current) : "нет данных"} {#key `${chatId}:${shownDays}:${stats.until}`}
</div> <PresenceSessions
days={shownDays}
first={stats.sessions}
pageSize={SESSION_PAGE}
peerId={stats.peer_id}
{tz}
/>
{/key}
</section> </section>
<section>
<h3>Когда в сети</h3>
{#if hourly.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Пока нет данных"}</p>
{:else}
<PresenceHeatmap {hourly} />
{/if} {/if}
</section>
<section>
<h3>История статусов</h3>
{#if changes.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Нет записей"}</p>
{:else}
<ul class="changes">
{#each changes as sample (sample.ts)}
<li>
<span
class="dot"
class:online={sample.status === "online"}
></span>
<span class="label">{formatPresence(sample)}</span>
<span class="time">{formatFull(sample.ts)}</span>
</li>
{/each}
</ul>
{/if}
</section>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -196,48 +298,89 @@
.analytics { .analytics {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; padding-bottom: 0.5rem;
padding: 1rem; }
.periods {
display: flex;
gap: 0.25rem;
margin: 0.75rem 1rem 0.25rem;
padding: 0.125rem;
border-radius: 0.625rem;
background-color: var(--color-background-secondary);
}
.period {
cursor: pointer;
flex: 1;
height: 2rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.8125rem;
color: var(--color-text-secondary);
white-space: nowrap;
background: none;
transition:
background-color 0.15s ease-out,
color 0.15s ease-out;
&.on {
color: var(--color-text);
background-color: var(--color-background);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (pointer: coarse) {
height: 2.75rem;
}
} }
section { section {
display: flex; padding: 0.5rem 0;
flex-direction: column;
gap: 0.75rem; & + section {
border-top: 1px solid var(--color-borders);
}
} }
h3 { .hero {
margin: 0; padding: 0.75rem 1rem 0.875rem;
font-size: 0.75rem; }
.total-line {
display: flex;
flex-wrap: wrap;
gap: 0 0.625rem;
align-items: baseline;
}
.total {
font-size: 1.875rem;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--color-text-secondary); font-variant-numeric: tabular-nums;
text-transform: uppercase; line-height: 1.2;
letter-spacing: 0.03em; letter-spacing: -0.01em;
} }
.stats { .count {
display: flex; font-size: 0.875rem;
gap: 1.5rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.value {
font-size: 1.5rem;
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.caption {
font-size: 0.75rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.status { .status {
margin: 0.25rem 0 0;
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
@@ -246,49 +389,80 @@
} }
} }
.muted { .note {
margin: 0; margin: 0.5rem 0 0;
font-size: 0.8125rem; font-size: 0.875rem;
line-height: 1.4;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.changes { .section-title {
display: flex; display: flex;
flex-direction: column; gap: 0.75rem;
gap: 0.5rem; align-items: baseline;
margin: 0; justify-content: space-between;
padding: 0;
list-style: none; padding: 0.375rem 1rem 0.625rem;
}
.changes li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem; font-size: 0.8125rem;
} font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
.dot { h3 {
width: 0.5rem; margin: 0;
height: 0.5rem; font-size: inherit;
flex-shrink: 0; font-weight: var(--font-weight-medium);
border-radius: 50%; color: var(--color-primary);
background-color: var(--color-text-secondary); white-space: nowrap;
&.online {
background-color: var(--color-primary);
} }
} }
.label { .rows {
flex: 1; margin: 0;
overflow: hidden; }
text-overflow: ellipsis;
.replies {
margin-top: 0.5rem;
}
.row {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
min-height: 2.5rem;
padding: 0 1rem;
font-size: 0.9375rem;
}
dt {
color: var(--color-text);
}
dd {
margin: 0;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
white-space: nowrap; white-space: nowrap;
} }
.time { .state {
flex-shrink: 0; display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
margin: 0;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
} }
</style> </style>
@@ -0,0 +1,144 @@
<script lang="ts">
import type { PresenceDay } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props {
days: PresenceDay[];
}
const { days }: Props = $props();
const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400;
const TEXT = {
bar: (day: string, online: number, missing: number) => {
if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) {
return `${day}, нет данных`;
}
const gap =
missing >= SECONDS_IN_HOUR ? `, нет данных ${formatSpan(missing)}` : "";
return `${day}, ${formatSpan(online)} в сети${gap}`;
},
today: "сегодня",
};
const peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds)));
const first = $derived(days[0]);
function label(day: string): string {
return formatDay(parseDay(day).toISOString());
}
function absent(day: PresenceDay, index: number): number {
if (index === days.length - 1) {
return 0;
}
return Math.max(0, SECONDS_IN_DAY - day.covered_seconds);
}
</script>
<div class="days">
<div class="chart">
<span class="peak">{formatSpan(peak)}</span>
<div class={["bars", { dense: days.length > 45 }]}>
{#each days as day, index (day.day)}
<div
class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]}
title={TEXT.bar(
label(day.day),
day.online_seconds,
absent(day, index)
)}
>
<span
class={["bar", { zero: day.online_seconds === 0 }]}
style:height="{(day.online_seconds / peak) * 100}%"
></span>
</div>
{/each}
</div>
</div>
{#if first}
<div class="axis">
<span>{label(first.day)}</span>
<span>{TEXT.today}</span>
</div>
{/if}
</div>
<style lang="scss">
.days {
padding: 0 1rem;
}
.chart {
position: relative;
padding-top: 1.125rem;
border-top: 1px dashed var(--color-borders);
}
.peak {
position: absolute;
top: 0.125rem;
left: 0;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.bars {
display: flex;
gap: 3px;
align-items: flex-end;
height: 6rem;
&.dense {
gap: 1px;
}
}
.slot {
display: flex;
flex: 1;
align-items: flex-end;
height: 100%;
&.empty {
border-radius: 2px 2px 0 0;
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.35) 0 1px,
transparent 1px 4px
);
}
}
.bar {
width: 100%;
min-height: 2px;
border-radius: 2px 2px 0 0;
background-color: var(--color-primary);
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.3);
}
.empty & {
min-height: 0;
}
}
.axis {
display: flex;
justify-content: space-between;
margin-top: 0.375rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
@@ -1,116 +1,217 @@
<script lang="ts"> <script lang="ts">
import type { PresenceHourly } from "#lib/api/types.js"; import type { PresenceHour } from "#lib/api/types.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props { interface Props {
hourly: PresenceHourly[]; week: PresenceHour[];
} }
const { hourly }: Props = $props(); const { week }: Props = $props();
const DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; const TEXT = {
const HOURS = Array.from({ length: 24 }, (_, hour) => hour); cell: (day: string, cell: PresenceHour) => {
const HOUR_TICKS = new Set([0, 6, 12, 18]); const slot = `${day}, ${cell.hour}:00`;
if (cell.hours === 0) {
interface Cell { return slot;
online: number;
total: number;
} }
if (cell.online_minutes === null) {
return `${slot}, нет данных`;
}
return `${slot}, в среднем ${formatSpan(cell.online_minutes * 60)} в сети`;
},
days: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
empty: "нет данных",
max: (minutes: number) => `до ${formatSpan(minutes * 60)}`,
};
const grid = $derived.by(() => { const HOURS_IN_DAY = 24;
const cells: Cell[][] = DAYS.map(() => const HOUR_TICKS = [0, 6, 12, 18];
HOURS.map(() => ({ online: 0, total: 0 })) const MIN_SHADE = 0.18;
const rows = $derived(
TEXT.days.map((_, weekday) =>
week
.filter((cell) => cell.weekday === weekday)
.sort((a, b) => a.hour - b.hour)
)
); );
for (const bucket of hourly) { const peak = $derived(
const date = new Date(bucket.bucket); Math.max(0, ...week.map((cell) => cell.online_minutes ?? 0))
const day = (date.getDay() + 6) % 7; );
const cell = cells[day]?.[date.getHours()]; const hasGaps = $derived(week.some(missing));
if (cell) {
cell.online += bucket.online_samples;
cell.total += bucket.samples;
}
}
return cells;
});
function intensity(cell: Cell): number { function missing(cell: PresenceHour): boolean {
return cell.total > 0 ? cell.online / cell.total : 0; return cell.hours > 0 && cell.online_minutes === null;
} }
function cellTitle(day: number, hour: number, cell: Cell): string { function shade(minutes: number | null): number {
const pct = cell.total > 0 ? Math.round(intensity(cell) * 100) : 0; if (minutes === null || minutes === 0 || peak === 0) {
return `${DAYS[day]} ${hour}:00 — ${pct}% online`; return 0;
}
return MIN_SHADE + (1 - MIN_SHADE) * (minutes / peak);
} }
</script> </script>
<div class="heatmap"> <div class="heatmap">
{#each grid as row, day (day)} {#each rows as cells, weekday (weekday)}
<div class="heat-row"> <div class="row">
<span class="day-label">{DAYS[day]}</span> <span class="day">{TEXT.days[weekday]}</span>
<div class="cells"> <div class="cells">
{#each row as cell, hour (hour)} {#each cells as cell (cell.hour)}
<span <span
class="cell" class={[
title={cellTitle(day, hour, cell)} "cell",
style:--i={intensity(cell)} {
class:empty={cell.total === 0} empty: missing(cell),
outside: cell.hours === 0,
zero: cell.online_minutes === 0,
},
]}
title={TEXT.cell(TEXT.days[weekday] ?? "", cell)}
style:--shade="{shade(cell.online_minutes) * 100}%"
></span> ></span>
{/each} {/each}
</div> </div>
</div> </div>
{/each} {/each}
<div class="heat-row axis"> <div class="row">
<span class="day-label"></span> <span class="day"></span>
<div class="cells"> <div class="ticks">
{#each HOURS as hour (hour)} {#each HOUR_TICKS as hour (hour)}
<span class="tick">{HOUR_TICKS.has(hour) ? hour : ""}</span> <span style:left="{(hour / HOURS_IN_DAY) * 100}%">{hour}</span>
{/each} {/each}
</div> </div>
</div> </div>
<div class="legend">
<span class="key"><span class="swatch zero"></span>0</span>
{#if peak > 0}
<span class="key">
<span class="scale"></span>
{TEXT.max(peak)}
</span>
{/if}
{#if hasGaps}
<span class="key">
<span class="swatch empty"></span>
{TEXT.empty}
</span>
{/if}
</div>
</div> </div>
<style lang="scss"> <style lang="scss">
.heatmap { .heatmap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 3px; gap: 2px;
padding: 0 1rem;
} }
.heat-row { .row {
display: flex; display: flex;
align-items: center;
gap: 0.5rem; gap: 0.5rem;
align-items: center;
} }
.day-label { .day {
width: 1.5rem;
flex-shrink: 0; flex-shrink: 0;
font-size: 0.6875rem; width: 1.375rem;
font-size: 0.75rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-align: right;
} }
.cells { .cells {
display: grid; display: grid;
flex: 1; flex: 1;
grid-template-columns: repeat(24, 1fr); grid-template-columns: repeat(24, minmax(0, 1fr));
gap: 3px; gap: 2px;
} }
.cell { .cell {
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 2px; border-radius: 2px;
background-color: var(--color-primary); background-color: color-mix(
opacity: calc(0.12 + 0.88 * var(--i)); in srgb,
var(--color-primary) var(--shade),
transparent
);
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.12);
}
&.outside {
background: none;
}
&.empty { &.empty {
background-color: var(--color-text-secondary); background: repeating-linear-gradient(
opacity: 0.08; 135deg,
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
transparent 1px 3px
);
} }
} }
.tick { .ticks {
font-size: 0.625rem; position: relative;
flex: 1;
height: 1rem;
span {
position: absolute;
top: 0.125rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-align: left; }
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
align-items: center;
margin: 0.375rem 0 0 1.875rem;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.key {
display: inline-flex;
gap: 0.375rem;
align-items: center;
}
.scale {
width: 2.5rem;
height: 0.625rem;
border-radius: 2px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-primary) 18%, transparent),
var(--color-primary)
);
}
.swatch {
width: 0.625rem;
height: 0.625rem;
border-radius: 2px;
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.12);
}
&.empty {
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
transparent 1px 3px
);
}
} }
</style> </style>
@@ -0,0 +1,205 @@
<script lang="ts">
import { getPresenceSessions } from "#lib/api/endpoints.js";
import type { PresenceSession } from "#lib/api/types.js";
import Button from "#lib/components/ui/Button.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatLongDay, formatTime } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props {
days: number;
first: PresenceSession[];
pageSize: number;
peerId: number;
tz: string;
}
const { days, first, pageSize, peerId, tz }: Props = $props();
const TEXT = {
failed: "Не удалось загрузить сессии.",
more: "Показать ещё",
none: "За этот период в сети не был(а).",
now: "в сети",
retry: "Повторить",
since: (time: string) => `с ${time}`,
span: (from: string, to: string) => (from === to ? from : `${from}–${to}`),
today: "Сегодня",
yesterday: "Вчера",
};
const MS_IN_DAY = 86_400_000;
let extra = $state<PresenceSession[]>([]);
let loading = $state(false);
let failed = $state(false);
let done = $state(false);
const sessions = $derived([...first, ...extra]);
const exhausted = $derived(done || first.length < pageSize);
const groups = $derived.by(() => {
const result: { label: string; sessions: PresenceSession[] }[] = [];
for (const session of sessions) {
const label = dayLabel(new Date(session.started));
const last = result.at(-1);
if (last?.label === label) {
last.sessions.push(session);
} else {
result.push({ label, sessions: [session] });
}
}
return result;
});
function dayLabel(date: Date): string {
const today = new Date();
if (date.toDateString() === today.toDateString()) {
return TEXT.today;
}
if (
date.toDateString() ===
new Date(today.getTime() - MS_IN_DAY).toDateString()
) {
return TEXT.yesterday;
}
const text = formatLongDay(date);
return text.charAt(0).toUpperCase() + text.slice(1);
}
async function loadMore() {
if (loading) {
return;
}
loading = true;
failed = false;
try {
const batch = await getPresenceSessions(peerId, days, tz, {
limit: pageSize,
offset: sessions.length,
});
const seen = new Set(sessions.map((session) => session.started));
extra = [
...extra,
...batch.filter((session) => !seen.has(session.started)),
];
done = batch.length < pageSize;
} catch {
failed = true;
} finally {
loading = false;
}
}
</script>
{#if sessions.length === 0}
<p class="state">{TEXT.none}</p>
{:else}
{#each groups as group (group.label)}
<h4 class="day">{group.label}</h4>
<ul>
{#each group.sessions as session (session.started)}
<li>
<span>
{session.ongoing
? TEXT.since(formatTime(session.started))
: TEXT.span(
formatTime(session.started),
formatTime(session.ended)
)}
</span>
<span class={["length", { online: session.ongoing }]}>
{session.ongoing ? TEXT.now : formatSpan(session.seconds)}
</span>
</li>
{/each}
</ul>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>{TEXT.failed}</p>
<Button onclick={loadMore} smaller variant="text">{TEXT.retry}</Button>
</div>
{:else if !exhausted}
<button class="more" onclick={loadMore} type="button">{TEXT.more}</button>
{/if}
{/if}
<style lang="scss">
.day {
margin: 0;
padding: 0.625rem 1rem 0.25rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-normal);
color: var(--color-text-secondary);
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
min-height: 2.5rem;
padding: 0 1rem;
font-size: 0.9375rem;
font-variant-numeric: tabular-nums;
}
.length {
font-size: 0.875rem;
color: var(--color-text-secondary);
&.online {
color: var(--color-primary);
}
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
margin: 0;
padding: 1.5rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { VolumeBucket } from "#lib/api/types.js"; import type { VolumeBucket } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js";
interface Props { interface Props {
volume: VolumeBucket[]; volume: VolumeBucket[];
@@ -7,47 +8,104 @@
const { volume }: Props = $props(); const { volume }: Props = $props();
const max = $derived(Math.max(1, ...volume.map((bucket) => bucket.total))); const TEXT = {
bar: (day: string, bucket: VolumeBucket) =>
bucket.total === 0
? `${day}, нет сообщений`
: `${day}, всего ${bucket.total}: входящих ${bucket.incoming}, моих ${bucket.outgoing}`,
incoming: "входящие",
outgoing: "мои",
today: "сегодня",
};
function barTitle(bucket: VolumeBucket): string { const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
return `${bucket.bucket.slice(0, 10)} — всего ${bucket.total} (вх ${bucket.incoming} / исх ${bucket.outgoing})`; const first = $derived(volume[0]);
function label(day: string): string {
return formatDay(parseDay(day).toISOString());
} }
</script> </script>
<div class="volume"> <div class="volume">
<div class="bars"> <div class="chart">
<span class="peak">{peak}</span>
<div class={["bars", { dense: volume.length > 45 }]}>
{#each volume as bucket (bucket.bucket)} {#each volume as bucket (bucket.bucket)}
<div class="slot" title={TEXT.bar(label(bucket.bucket), bucket)}>
<div <div
class="bar" class={["bar", { zero: bucket.total === 0 }]}
title={barTitle(bucket)} style:height="{(bucket.total / peak) * 100}%"
style:height={`${(bucket.total / max) * 100}%`}
> >
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
<div class="seg in" style:flex-grow={bucket.incoming}></div> <div class="seg in" style:flex-grow={bucket.incoming}></div>
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
</div>
</div> </div>
{/each} {/each}
</div> </div>
</div>
{#if first}
<div class="axis">
<span>{label(first.bucket)}</span>
<span>{TEXT.today}</span>
</div>
{/if}
<div class="legend"> <div class="legend">
<span class="key"><span class="swatch out"></span>исходящие</span> <span class="key"><span class="swatch in"></span>{TEXT.incoming}</span>
<span class="key"><span class="swatch in"></span>входящие</span> <span class="key"><span class="swatch out"></span>{TEXT.outgoing}</span>
</div> </div>
</div> </div>
<style lang="scss"> <style lang="scss">
.volume {
padding: 0 1rem;
}
.chart {
position: relative;
padding-top: 1.125rem;
border-top: 1px dashed var(--color-borders);
}
.peak {
position: absolute;
top: 0.125rem;
left: 0;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.bars { .bars {
display: flex; display: flex;
gap: 3px;
align-items: flex-end; align-items: flex-end;
height: 6rem;
&.dense {
gap: 1px; gap: 1px;
height: 7.5rem; }
}
.slot {
display: flex;
flex: 1;
align-items: flex-end;
height: 100%;
} }
.bar { .bar {
display: flex; display: flex;
flex: 1;
flex-direction: column; flex-direction: column;
width: 100%;
min-height: 2px; min-height: 2px;
overflow: hidden; overflow: hidden;
border-radius: 2px 2px 0 0; border-radius: 2px 2px 0 0;
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.3);
}
} }
.seg.out { .seg.out {
@@ -55,8 +113,18 @@
} }
.seg.in { .seg.in {
background-color: var(--color-text-secondary); background-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
opacity: 0.4; }
.axis {
display: flex;
justify-content: space-between;
margin-top: 0.375rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
} }
.legend { .legend {
@@ -69,8 +137,8 @@
.key { .key {
display: flex; display: flex;
align-items: center;
gap: 0.375rem; gap: 0.375rem;
align-items: center;
} }
.swatch { .swatch {
@@ -83,8 +151,11 @@
} }
&.in { &.in {
background-color: var(--color-text-secondary); background-color: color-mix(
opacity: 0.4; in srgb,
var(--color-primary) 40%,
transparent
);
} }
} }
</style> </style>
@@ -0,0 +1,104 @@
<script lang="ts">
import { ContextMenu } from "bits-ui";
import { untrack } from "svelte";
import type { MessageView } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { peerColorIndex, peerName } from "#lib/format/peer.js";
import { readLine } from "#lib/format/read.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { reads } from "#lib/stores/reads.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
interface Props {
message: MessageView;
}
const AVATARS = 3;
let { message }: Props = $props();
const entry = $derived(reads.get(message.chat_id, message.message_id));
const fetching = $derived(entry?.fetching ?? false);
const shown = $derived(readLine(message, entry?.info ?? null, fetching));
$effect(() => {
const chatId = message.chat_id;
const messageId = message.message_id;
untrack(() => reads.open(chatId, messageId));
});
function openReaders() {
ui.openMessagePanel("readers", message.message_id);
}
</script>
{#if shown?.kind === "readers"}
<ContextMenu.Item class="bg-menu-item read-item" onSelect={openReaders}>
<Icon name="group" size="1.125rem" />
<span class="label"
>{t("read.readers", { count: shown.readers.length })}</span
>
{#if fetching}
<Spinner color="gray" size="1rem" />
{/if}
<span class="avatars">
{#each shown.readers.slice(0, AVATARS) as reader (reader.peer_id)}
<Avatar
avatar={{ id: reader.peer_id, kind: "peer" }}
colorKey={peerColorIndex(reader.peer_id)}
hasAvatar={reader.peer?.has_avatar ?? false}
name={peerName(reader.peer) || String(reader.peer_id)}
size={1.5}
/>
{/each}
</span>
</ContextMenu.Item>
<ContextMenu.Separator class="bg-menu-separator" />
{:else if shown}
<div class="bg-menu-item read-item static" class:muted={shown.muted}>
<Icon name={shown.icon} size="1.125rem" />
<span class="label">{shown.text}</span>
{#if fetching}
<Spinner color="gray" size="1rem" />
{/if}
</div>
<ContextMenu.Separator class="bg-menu-separator" />
{/if}
<style lang="scss">
:global(.bg-menu-item.read-item) {
min-height: 2.5rem;
}
.static {
cursor: default;
&:hover {
background-color: transparent;
}
}
.muted {
color: var(--color-text-secondary);
}
.label {
flex: 1;
white-space: nowrap;
}
.avatars {
display: flex;
margin-inline-start: 0.25rem;
& > :global(.Avatar) {
border: 2px solid var(--color-background);
}
& > :global(.Avatar + .Avatar) {
margin-inline-start: -0.5rem;
}
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import { untrack } from "svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { peerColorIndex, peerName } from "#lib/format/peer.js";
import { readWhen } from "#lib/format/read.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { reads } from "#lib/stores/reads.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const messageId = $derived(ui.panelMessageId);
const entry = $derived(
chatId === null || messageId === null
? undefined
: reads.get(chatId, messageId)
);
const readers = $derived(entry?.info.readers ?? []);
$effect(() => {
const cid = chatId;
const mid = messageId;
if (cid !== null && mid !== null) {
untrack(() => reads.open(cid, mid));
}
});
function openProfile(peerId: number) {
goto(`/app/${peerId}`);
ui.openPanel("profile");
}
</script>
{#if entry === undefined}
<div class="center"><Spinner /></div>
{:else if readers.length === 0}
<EmptyState title={t("read.readers.empty")} />
{:else}
<ul class="readers">
{#each readers as reader (reader.peer_id)}
{@const name = peerName(reader.peer) || String(reader.peer_id)}
<li>
<button onclick={() => openProfile(reader.peer_id)} type="button">
<Avatar
avatar={{ id: reader.peer_id, kind: "peer" }}
colorKey={peerColorIndex(reader.peer_id)}
hasAvatar={reader.peer?.has_avatar ?? false}
{name}
size={2.625}
/>
<span class="info">
<span class="name">{name}</span>
<span class="time">{readWhen(reader.read_at)}</span>
</span>
</button>
</li>
{/each}
</ul>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.readers {
display: flex;
flex-direction: column;
margin: 0;
padding: 0.5rem;
list-style: none;
}
button {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.4375rem 0.75rem;
border: 0;
border-radius: var(--border-radius-default-small);
font: inherit;
color: inherit;
text-align: start;
background: transparent;
&:hover,
&:focus-visible {
background-color: var(--color-chat-hover);
}
}
.info {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
overflow: hidden;
font-weight: var(--font-weight-medium);
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
</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);
+2 -2
View File
@@ -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>
+52 -15
View File
@@ -1,29 +1,45 @@
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",
month: "short",
});
const full = new Intl.DateTimeFormat(undefined, {
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
month: "short", month: "short",
year: "numeric", year: "numeric",
}); },
longDay: { day: "numeric", month: "long", weekday: "long" },
offset: { timeZoneName: "shortOffset" },
time: { hour: "2-digit", minute: "2-digit" },
} satisfies Record<string, Intl.DateTimeFormatOptions>;
const formatters = new Map<string, Intl.DateTimeFormat>();
function formatter(kind: keyof typeof OPTIONS): Intl.DateTimeFormat {
const key = `${locale.tag}:${kind}`;
let cached = formatters.get(key);
if (!cached) {
cached = new Intl.DateTimeFormat(locale.tag, OPTIONS[kind]);
formatters.set(key, cached);
}
return cached;
}
function format(kind: keyof typeof OPTIONS, date: Date): string {
return formatter(kind).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 +49,28 @@ 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);
}
export function formatLongDay(date: Date): string {
return format("longDay", date);
}
export function parseDay(value: string): Date {
const [year, month, date] = value.split("-").map(Number);
return new Date(year ?? 0, (month ?? 1) - 1, date ?? 1);
}
export function browserTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
export function timeZoneLabel(): string {
return (
formatter("offset")
.formatToParts(new Date())
.find((part) => part.type === "timeZoneName")?.value ?? browserTimeZone()
);
} }
+107 -27
View File
@@ -1,37 +1,117 @@
import type { PresenceSample } from "#lib/api/types.js"; import type { PresenceSample } from "#lib/api/types.js";
import { formatListDate } from "#lib/format/datetime.js"; import { formatDay, formatTime } from "#lib/format/datetime.js";
import { plural } from "#lib/format/shares.js";
export function isOnline(sample: PresenceSample | null): boolean { const SECONDS_IN_MINUTE = 60;
if (sample === null || sample.status !== "online") { const MINUTES_IN_HOUR = 60;
const SECONDS_IN_HOUR = 3600;
const MS_IN_MINUTE = 60_000;
const SECONDS_IN_DAY = 86_400;
const MS_IN_DAY = 86_400_000;
const TEXT = {
ago: (minutes: number) =>
`был(а) ${minutes} ${plural(minutes, "минуту", "минуты", "минут")} назад`,
days: (days: number) => `${days} ${plural(days, "день", "дня", "дней")}`,
hours: (hours: number) => `${hours} ч`,
hoursMinutes: (hours: number, minutes: number) => `${hours} ч ${minutes} мин`,
justNow: "был(а) только что",
lastMonth: "был(а) в этом месяце",
lastWeek: "был(а) на этой неделе",
longAgo: "был(а) давно",
minutes: (minutes: number) => `${minutes} мин`,
offline: "не в сети",
on: (day: string, time: string) => `был(а) ${day} в ${time}`,
online: "в сети",
recently: "был(а) недавно",
seconds: (seconds: number) => `${seconds} с`,
today: (time: string) => `был(а) сегодня в ${time}`,
yesterday: (time: string) => `был(а) вчера в ${time}`,
};
const FUZZY: Record<string, string> = {
last_month: TEXT.lastMonth,
last_week: TEXT.lastWeek,
long_time_ago: TEXT.longAgo,
recently: TEXT.recently,
};
export function isOnline(
sample: PresenceSample | null,
now = Date.now()
): boolean {
if (sample?.status !== "online" || sample.next_offline_date === null) {
return false; return false;
} }
if (sample.next_offline_date === null) { return new Date(sample.next_offline_date).getTime() > now;
return false;
}
return new Date(sample.next_offline_date).getTime() > Date.now();
} }
export function formatPresence(sample: PresenceSample): string { function lastSeen(iso: string, now: number): string {
switch (sample.status) { const date = new Date(iso);
case "online": const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE);
return isOnline(sample) if (minutes < 1) {
? "online" return TEXT.justNow;
: lastSeen(sample.last_online_date ?? sample.ts); }
case "recently": if (minutes < MINUTES_IN_HOUR) {
return "last seen recently"; return TEXT.ago(minutes);
case "last_week": }
return "last seen within a week"; const time = formatTime(iso);
case "last_month": const today = new Date(now);
return "last seen within a month"; if (date.toDateString() === today.toDateString()) {
case "long_time_ago": return TEXT.today(time);
return "last seen a long time ago"; }
default: if (date.toDateString() === new Date(now - MS_IN_DAY).toDateString()) {
return TEXT.yesterday(time);
}
return TEXT.on(formatDay(iso), time);
}
export function formatPresence(
sample: PresenceSample,
now = Date.now()
): string {
if (sample.status === "online") {
return isOnline(sample, now)
? TEXT.online
: lastSeen(sample.next_offline_date ?? sample.ts, now);
}
const fuzzy = FUZZY[sample.status];
if (fuzzy) {
return fuzzy;
}
return sample.last_online_date return sample.last_online_date
? lastSeen(sample.last_online_date) ? lastSeen(sample.last_online_date, now)
: "offline"; : TEXT.offline;
}
} }
function lastSeen(date: string): string { export function formatSpan(seconds: number): string {
return `last seen ${formatListDate(date)}`; if (seconds < SECONDS_IN_MINUTE) {
return TEXT.seconds(Math.round(seconds));
}
const minutes = Math.round(seconds / SECONDS_IN_MINUTE);
if (minutes < MINUTES_IN_HOUR) {
return TEXT.minutes(minutes);
}
const hours = Math.floor(minutes / MINUTES_IN_HOUR);
const rest = minutes % MINUTES_IN_HOUR;
return rest === 0 ? TEXT.hours(hours) : TEXT.hoursMinutes(hours, rest);
}
export function formatClock(secondsOfDay: number): string {
const hours = Math.floor(secondsOfDay / SECONDS_IN_HOUR);
const minutes = Math.floor(
(secondsOfDay % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE
);
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
export function formatLongSpan(seconds: number): string {
if (seconds < 2 * SECONDS_IN_DAY) {
return formatSpan(seconds);
}
const days = Math.floor(seconds / SECONDS_IN_DAY);
const hours = Math.round((seconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR);
return hours === 0
? TEXT.days(days)
: `${TEXT.days(days)} ${TEXT.hours(hours)}`;
} }
+104
View File
@@ -0,0 +1,104 @@
import type {
MessageView,
ReaderView,
ReadInfo,
ReadOutcome,
} from "#lib/api/types.js";
import { formatTime } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
export type ReadLine =
| { kind: "readers"; readers: ReaderView[] }
| { icon: string; kind: "text"; muted: boolean; text: string };
const REASONS: Partial<Record<ReadOutcome, MessageKey>> = {
chat_too_big: "read.reason.chat_too_big",
error: "read.reason.error",
flood: "read.reason.flood",
too_old: "read.reason.too_old",
unavailable: "read.reason.unavailable",
user_privacy: "read.reason.user_privacy",
your_privacy: "read.reason.your_privacy",
};
const dayFormatters = new Map<string, Intl.DateTimeFormat>();
function dayLabel(date: Date, withYear: boolean): string {
const formatKey = `${locale.tag}:${withYear}`;
let formatter = dayFormatters.get(formatKey);
if (!formatter) {
formatter = new Intl.DateTimeFormat(locale.tag, {
day: "numeric",
month: "short",
year: withYear ? "numeric" : undefined,
});
dayFormatters.set(formatKey, formatter);
}
const parts = formatter.formatToParts(date);
const pick = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value.replace(".", "");
return [pick("day"), pick("month"), pick("year")].filter(Boolean).join(" ");
}
export function readWhen(iso: string): string {
const date = new Date(iso);
const now = new Date();
const time = formatTime(iso);
if (date.toDateString() === now.toDateString()) {
return t("read.when.today", { time });
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) {
return t("read.when.yesterday", { time });
}
const withYear = date.getFullYear() !== now.getFullYear();
return t("read.when.day", { day: dayLabel(date, withYear), time });
}
function line(text: string, icon = "message-read", muted = false): ReadLine {
return { icon, kind: "text", muted, text };
}
export function readTitle(message: MessageView): string | undefined {
if (message.read_at) {
return t("read.at", { when: readWhen(message.read_at) });
}
return message.read ? t("read.done") : undefined;
}
export function readLine(
message: MessageView,
info: ReadInfo | null,
fetching: boolean
): ReadLine | null {
if (info === null) {
const title = readTitle(message);
return title ? line(title) : null;
}
if (info.chat_kind === "group" && info.readers.length > 0) {
return { kind: "readers", readers: info.readers };
}
if (info.voice && info.listened_at) {
return line(
t("read.listened", { when: readWhen(info.listened_at) }),
"play-story"
);
}
const at = info.exact_at ?? info.observed_at;
if (at) {
return line(t("read.at", { when: readWhen(at) }));
}
if (info.read) {
return line(t("read.done"));
}
if (info.unread) {
return line(t("read.unread"), "check");
}
const reason = info.outcome ? REASONS[info.outcome] : undefined;
if (reason) {
return line(t(reason), "eye-crossed-outline", true);
}
return fetching ? line(t("read.pending"), "message-read", true) : null;
}
+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
);
}
+15
View File
@@ -0,0 +1,15 @@
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 { read } from "#lib/i18n/messages/read.js";
import { settings } from "#lib/i18n/messages/settings.js";
export const messages = {
...account,
...common,
...menu,
...read,
...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;
+49
View File
@@ -0,0 +1,49 @@
import type { Messages } from "#lib/i18n/types.js";
export const read = {
"read.at": { en: "Read {when}", ru: "Прочитано {when}" },
"read.done": { en: "Read", ru: "Прочитано" },
"read.listened": { en: "Listened {when}", ru: "Прослушано {when}" },
"read.pending": { en: "Read time", ru: "Время прочтения" },
"read.readers": { en: "Read by {count}", ru: "Прочитали: {count}" },
"read.readers.empty": {
en: "Nobody has read it yet",
ru: "Пока никто не прочитал",
},
"read.readers.title": { en: "Read by", ru: "Прочитали" },
"read.reason.chat_too_big": {
en: "The group has over 100 members",
ru: "В группе больше 100 участников",
},
"read.reason.error": {
en: "Telegram did not answer",
ru: "Telegram не ответил",
},
"read.reason.flood": {
en: "Telegram asked to wait",
ru: "Telegram попросил подождать",
},
"read.reason.too_old": {
en: "Telegram no longer keeps the time",
ru: "Telegram уже не хранит время",
},
"read.reason.unavailable": {
en: "Telegram cannot find the chat",
ru: "Telegram не находит чат",
},
"read.reason.user_privacy": {
en: "They hide their read time",
ru: "Собеседник скрывает время прочтения",
},
"read.reason.your_privacy": {
en: "You hide your read time",
ru: "Вы скрываете своё время прочтения",
},
"read.unread": { en: "Not read yet", ru: "Не прочитано" },
"read.when.day": { en: "{day} at {time}", ru: "{day} в {time}" },
"read.when.today": { en: "today at {time}", ru: "сегодня в {time}" },
"read.when.yesterday": {
en: "yesterday at {time}",
ru: "вчера в {time}",
},
} 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"; } 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}`);
},
}; };
} }
+3
View File
@@ -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()];
} }
@@ -0,0 +1,73 @@
import { getCurrentPresence } from "#lib/api/endpoints.js";
import type { PresenceSample } from "#lib/api/types.js";
import { events } from "#lib/stores/events.svelte.js";
const CLOCK_INTERVAL = 30_000;
function createPresence() {
let peerId: number | null = null;
let key: string | null = null;
let sample = $state<PresenceSample | null>(null);
let now = $state(Date.now());
let clock: ReturnType<typeof setInterval> | undefined;
let token = 0;
async function refresh() {
const id = peerId;
token += 1;
const current = token;
if (id === null) {
return;
}
try {
const next = await getCurrentPresence(id);
if (current === token) {
sample = next;
now = Date.now();
}
} catch {
if (current === token) {
sample = null;
}
}
}
events.subscribe((event) => {
if (event.type === "presence" && event.peer_id === peerId) {
token += 1;
({ sample } = event);
now = Date.now();
}
});
events.onReconnect(() => {
refresh();
});
return {
follow(accountId: number | null, id: number | null) {
const next =
accountId === null || id === null ? null : `${accountId}:${id}`;
if (next === key) {
return;
}
key = next;
peerId = next === null ? null : id;
sample = null;
clearInterval(clock);
if (peerId !== null) {
clock = setInterval(() => {
now = Date.now();
}, CLOCK_INTERVAL);
refresh();
}
},
get now(): number {
return now;
},
get sample(): PresenceSample | null {
return sample;
},
};
}
export const presence = createPresence();
+77
View File
@@ -0,0 +1,77 @@
import { getJob, getReadInfo, refreshReadInfo } from "#lib/api/endpoints.js";
import type { ReadInfo } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
const POLL_INTERVAL = 1000;
const POLL_MAX = 20;
export interface ReadEntry {
fetching: boolean;
info: ReadInfo;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function waitForJob(jobId: number) {
for (let attempt = 0; attempt < POLL_MAX; attempt += 1) {
// biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose
await delay(POLL_INTERVAL);
const job = await getJob(jobId);
if (job.status !== "pending" && job.status !== "running") {
return;
}
}
}
function createReads() {
let entries = $state<Record<string, ReadEntry>>({});
const inflight = new Set<string>();
function key(chatId: number, messageId: number): string {
return `${accounts.selectedId}:${chatId}:${messageId}`;
}
async function load(entryKey: string, chatId: number, messageId: number) {
const info = await getReadInfo(chatId, messageId);
entries[entryKey] = { fetching: info.can_fetch, info };
if (!info.can_fetch) {
return;
}
try {
const { job_id } = await refreshReadInfo(chatId, messageId);
if (job_id !== null) {
await waitForJob(job_id);
}
entries[entryKey] = {
fetching: false,
info: await getReadInfo(chatId, messageId),
};
} finally {
entries[entryKey].fetching = false;
}
}
return {
get(chatId: number, messageId: number): ReadEntry | undefined {
return entries[key(chatId, messageId)];
},
open(chatId: number, messageId: number) {
const entryKey = key(chatId, messageId);
if (accounts.selectedId === null || inflight.has(entryKey)) {
return;
}
inflight.add(entryKey);
load(entryKey, chatId, messageId)
.catch(() => undefined)
.finally(() => {
inflight.delete(entryKey);
});
},
};
}
export const reads = createReads();
+1
View File
@@ -3,6 +3,7 @@ export type RightPanel =
| "search" | "search"
| "versions" | "versions"
| "reactions" | "reactions"
| "readers"
| "callbacks" | "callbacks"
| "links" | "links"
| "annotations" | "annotations"
+5
View File
@@ -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;
+26 -12
View File
@@ -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>
@@ -140,7 +154,7 @@
#RightColumn { #RightColumn {
position: relative; position: relative;
z-index: 1; z-index: var(--z-right-column);
} }
#MiddleColumn { #MiddleColumn {
+5 -1
View File
@@ -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

Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more