Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec5979437d | ||
|
|
59f171a62d | ||
|
|
e3559da2a7 |
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
||||
_commit: v0.6.0
|
||||
_commit: v0.7.1
|
||||
_src_path: https://git.kotikot.com/templates/backend-python.git
|
||||
author_email: h@kotikot.com
|
||||
author_name: h
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
_commit: v0.7.0
|
||||
_commit: v0.8.0
|
||||
_src_path: https://git.kotikot.com/templates/frontend-svelte.git
|
||||
install_deps: false
|
||||
layout: part
|
||||
|
||||
@@ -24,7 +24,7 @@ Nothing is ever deleted from the archive.
|
||||
|
||||
## 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`, `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.
|
||||
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.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:7dd72af08bdef012fdf433082190d2b5133a96b6a43aca0bb71c2aa793e47e20
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,50 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,39 +0,0 @@
|
||||
"""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")
|
||||
@@ -20,20 +20,9 @@ from api.mcp.download import (
|
||||
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
||||
from dependencies.container import container
|
||||
from utils.files import resolve_mime
|
||||
from utils.read import (
|
||||
annotations,
|
||||
chats,
|
||||
media,
|
||||
peers,
|
||||
presence,
|
||||
presence_stats,
|
||||
social,
|
||||
watches,
|
||||
)
|
||||
from utils.read import annotations, chats, media, peers, presence, social, watches
|
||||
from utils.read.accounts import self_user_id, unknown_account_hint
|
||||
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.repository import search_messages
|
||||
from utils.storage import ContentAddressedStorage
|
||||
@@ -306,23 +295,6 @@ async def get_callbacks(
|
||||
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
|
||||
async def presence_history(
|
||||
account_id: int,
|
||||
@@ -345,37 +317,6 @@ 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
|
||||
async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]:
|
||||
"""Get name/username/avatar change history of a contact."""
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from utils.env import env
|
||||
from utils.events import BG_EVENTS_CHANNEL
|
||||
from utils.listen import Listener
|
||||
from utils.read import chats as chats_read
|
||||
from utils.read import presence as presence_read
|
||||
|
||||
@@ -27,7 +26,7 @@ class EventHub:
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: set[Subscriber] = set()
|
||||
self._pool: asyncpg.Pool | None = None
|
||||
self._listener = Listener("realtime", {BG_EVENTS_CHANNEL: self._on_notify})
|
||||
self._conn: asyncpg.Connection | None = None
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
|
||||
def subscribe(self, account_id: int, chat_id: int | None) -> Subscriber:
|
||||
@@ -40,14 +39,21 @@ class EventHub:
|
||||
|
||||
async def start(self, pool: asyncpg.Pool) -> None:
|
||||
self._pool = pool
|
||||
await self._listener.start()
|
||||
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||
await conn.add_listener(BG_EVENTS_CHANNEL, self._on_notify)
|
||||
self._conn = conn
|
||||
logger.info("Realtime hub listening on %s", BG_EVENTS_CHANNEL)
|
||||
|
||||
async def stop(self) -> None:
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
await self._listener.stop()
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
def _on_notify(self, payload: str) -> None:
|
||||
def _on_notify(
|
||||
self, _conn: asyncpg.Connection, _pid: int, _channel: str, payload: str
|
||||
) -> None:
|
||||
task = asyncio.create_task(self._dispatch(payload))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
@@ -59,12 +65,16 @@ class EventHub:
|
||||
return
|
||||
account_id = event.get("account_id")
|
||||
chat_id = event.get("chat_id")
|
||||
account_wide = event.get("kind") == "presence"
|
||||
scoped = event.get("kind") == "presence"
|
||||
targets = [
|
||||
sub
|
||||
for sub in self._subscribers
|
||||
if sub.account_id == account_id
|
||||
and (account_wide or sub.chat_id is None or sub.chat_id == chat_id)
|
||||
and (
|
||||
sub.chat_id == chat_id
|
||||
if scoped
|
||||
else sub.chat_id is None or sub.chat_id == chat_id
|
||||
)
|
||||
]
|
||||
if not targets:
|
||||
return
|
||||
@@ -111,7 +121,6 @@ class EventHub:
|
||||
"type": "receipt",
|
||||
"chat_id": event["chat_id"],
|
||||
"read_up_to": event["message_id"],
|
||||
"read_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@@ -4,10 +4,8 @@ import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from api.timezone import checked_tz
|
||||
from utils.read import analytics
|
||||
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)
|
||||
|
||||
@@ -20,12 +18,9 @@ async def volume(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: AccountId,
|
||||
chat_id: ChatId,
|
||||
days: Annotated[int, Query(ge=1, le=MAX_DAYS)] = 90,
|
||||
tz: Annotated[str, Query()] = DEFAULT_TZ,
|
||||
days: Annotated[int, Query()] = 90,
|
||||
) -> list[VolumeBucket]:
|
||||
return await analytics.message_volume(
|
||||
pool, account_id, chat_id, days=days, tz=await checked_tz(pool, tz)
|
||||
)
|
||||
return await analytics.message_volume(pool, account_id, chat_id, days=days)
|
||||
|
||||
|
||||
@router.get("/response-time")
|
||||
|
||||
@@ -5,17 +5,8 @@ import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from api.timezone import checked_tz
|
||||
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
|
||||
from utils.read import presence
|
||||
from utils.read.models import DEFAULT_LIMIT, Page, PresenceHourly, PresenceSample
|
||||
|
||||
router = APIRouter(prefix="/api/presence", tags=["presence"], route_class=DishkaRoute)
|
||||
|
||||
@@ -23,8 +14,6 @@ AccountId = Annotated[int, Query()]
|
||||
PeerId = Annotated[int, Query()]
|
||||
DateFrom = Annotated[datetime | None, Query()]
|
||||
DateTo = Annotated[datetime | None, Query()]
|
||||
Days = Annotated[int, Query(ge=1, le=MAX_DAYS)]
|
||||
Tz = Annotated[str, Query()]
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -65,43 +54,3 @@ async def presence_hourly(
|
||||
return await presence.presence_hourly(
|
||||
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),
|
||||
)
|
||||
@@ -2,13 +2,10 @@ from typing import Annotated
|
||||
|
||||
import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from utils.jobs import enqueue
|
||||
from utils.read import social
|
||||
from utils.read.models import CallbackView, LinkView, ReactionView, ReadInfoView
|
||||
from utils.read.read_receipts import read_info
|
||||
from utils.read.models import CallbackView, LinkView, ReactionView
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/messages/{chat_id}/{message_id}",
|
||||
@@ -19,10 +16,6 @@ router = APIRouter(
|
||||
AccountId = Annotated[int, Query()]
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
account_id: int
|
||||
|
||||
|
||||
@router.get("/callbacks")
|
||||
async def message_callbacks(
|
||||
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
||||
@@ -42,29 +35,3 @@ async def message_links(
|
||||
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
||||
) -> list[LinkView]:
|
||||
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}
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
@@ -20,9 +20,6 @@ async def on_deleted_messages(client: PyroClient, messages: list[Message]) -> No
|
||||
box.append(message.id)
|
||||
else:
|
||||
channels.setdefault(chat_id, []).append(message.id)
|
||||
ctx.pending_deletes.remember(None, box)
|
||||
for chat_id, ids in channels.items():
|
||||
ctx.pending_deletes.remember(chat_id, ids)
|
||||
if box:
|
||||
await repository.mark_deleted_box(ctx.pool, ctx.account_id, box)
|
||||
await notify_bg_event(ctx.pool, "delete", ctx.account_id, message_ids=box)
|
||||
|
||||
@@ -2,9 +2,10 @@ from pyrogram.types import Message
|
||||
|
||||
from userbot.modules.capture import repository
|
||||
from userbot.modules.capture.chat_meta import meta_from_chat
|
||||
from userbot.modules.capture.message import message_row
|
||||
from userbot.modules.capture.message import sender_id
|
||||
from userbot.modules.client import PyroClient
|
||||
from userbot.modules.media import capture_media
|
||||
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
|
||||
from userbot.modules.raw_json import raw_json
|
||||
from utils.events import notify_bg_event
|
||||
|
||||
|
||||
@@ -26,7 +27,18 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
|
||||
if not toggles.track_edits_deletes:
|
||||
return
|
||||
changed = await repository.add_version(
|
||||
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
chat_id,
|
||||
message.id,
|
||||
message.date,
|
||||
sender_id(message),
|
||||
message.text or message.caption,
|
||||
raw_json(message),
|
||||
message.edit_date,
|
||||
media_unique_id(message),
|
||||
has_media=message.media is not None,
|
||||
is_self_destruct=self_destruct_ttl(message) is not None,
|
||||
)
|
||||
if not changed:
|
||||
return
|
||||
|
||||
@@ -13,7 +13,6 @@ async def on_user_status(client: PyroClient, user: User) -> None:
|
||||
ctx = client.capture
|
||||
if ctx is None or user.status is None:
|
||||
return
|
||||
if ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).presence:
|
||||
await repository.insert_status(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
@@ -24,8 +23,8 @@ async def on_user_status(client: PyroClient, user: User) -> None:
|
||||
user.next_offline_date,
|
||||
raw_json(user.raw),
|
||||
)
|
||||
await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id)
|
||||
await ctx.watches.on_status(user.id, is_online=user.status.name.lower() == "online")
|
||||
await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id)
|
||||
|
||||
|
||||
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
|
||||
@@ -25,14 +25,8 @@ async def _handle_user(
|
||||
current = await get_peer(ctx.pool, ctx.account_id, update.user_id)
|
||||
if current == fields:
|
||||
return
|
||||
is_bot = bool(getattr(raw_user, "bot", False))
|
||||
await write_profile(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
update.user_id,
|
||||
fields,
|
||||
raw_json(raw_user),
|
||||
history=ctx.resolve_peer(update.user_id, is_bot=is_bot).profile_history,
|
||||
ctx.pool, ctx.account_id, update.user_id, fields, raw_json(raw_user)
|
||||
)
|
||||
changed_photo = current is None or current.photo_unique_id != photo_unique_id
|
||||
if photo_file_id and photo_unique_id and changed_photo:
|
||||
@@ -57,14 +51,7 @@ async def _merge_partial(
|
||||
merged = replace(current or ProfileFields(), **overrides)
|
||||
if merged == current:
|
||||
return
|
||||
await write_profile(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
peer_id,
|
||||
merged,
|
||||
raw_str,
|
||||
history=ctx.resolve_peer(peer_id).profile_history,
|
||||
)
|
||||
await write_profile(ctx.pool, ctx.account_id, peer_id, merged, raw_str)
|
||||
|
||||
|
||||
async def handle(
|
||||
|
||||
@@ -7,35 +7,25 @@ from userbot.modules.raw_json import raw_json
|
||||
from userbot.modules.read_receipts import repository
|
||||
from utils.events import notify_bg_event
|
||||
|
||||
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
|
||||
HANDLES = (raw.types.UpdateReadHistoryOutbox,)
|
||||
|
||||
|
||||
async def handle(
|
||||
client: PyroClient, update: raw.base.Update, _users: dict, _chats: dict
|
||||
) -> None:
|
||||
ctx = client.capture
|
||||
watermark = _watermark(update)
|
||||
if ctx is None or watermark is None:
|
||||
if ctx is None or not isinstance(update, raw.types.UpdateReadHistoryOutbox):
|
||||
return
|
||||
chat_id, max_id = watermark
|
||||
chat_id = utils.get_peer_id(update.peer)
|
||||
await repository.insert_read(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
chat_id,
|
||||
chat_id,
|
||||
datetime.now(UTC),
|
||||
max_id,
|
||||
update.max_id,
|
||||
raw_json(update),
|
||||
)
|
||||
await notify_bg_event(
|
||||
ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=max_id
|
||||
ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=update.max_id
|
||||
)
|
||||
@@ -1,19 +1,14 @@
|
||||
from pyrogram.types import Story
|
||||
|
||||
from userbot.modules.client import PyroClient
|
||||
from userbot.modules.stories.service import save_story, story_peer_id
|
||||
from userbot.modules.stories.service import save_story
|
||||
|
||||
|
||||
@PyroClient.on_story()
|
||||
async def on_story(client: PyroClient, story: Story) -> None:
|
||||
ctx = client.capture
|
||||
if ctx is None:
|
||||
if client.capture is None:
|
||||
return
|
||||
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)
|
||||
await save_story(client, client.capture, story)
|
||||
|
||||
|
||||
handlers = on_story.handlers # ty: ignore[unresolved-attribute]
|
||||
@@ -2,11 +2,10 @@ import asyncpg
|
||||
from pyrogram import Client
|
||||
|
||||
from userbot.modules.capture.identity import ChatMetaCache, PeerIdentityCache
|
||||
from userbot.modules.capture.pending import PendingDeletes
|
||||
from userbot.modules.contacts import ContactCache
|
||||
from userbot.modules.folders import FolderCache
|
||||
from userbot.modules.watches import WatchCache
|
||||
from utils.policy.models import CaptureToggles, ChatKind, ChatMeta, PolicySet
|
||||
from utils.policy.models import CaptureToggles, ChatMeta, PolicySet
|
||||
from utils.policy.repository import load_policy_set
|
||||
from utils.policy.resolver import resolve
|
||||
from utils.storage import ContentAddressedStorage
|
||||
@@ -29,7 +28,6 @@ class CaptureContext:
|
||||
self.watches = WatchCache(pool, account_id)
|
||||
self.peer_identity = PeerIdentityCache()
|
||||
self.chat_meta = ChatMetaCache()
|
||||
self.pending_deletes = PendingDeletes()
|
||||
self.policies = PolicySet()
|
||||
|
||||
async def reload_policies(self) -> None:
|
||||
@@ -38,16 +36,6 @@ class CaptureContext:
|
||||
def resolve(self, chat: ChatMeta) -> CaptureToggles:
|
||||
return resolve(chat, self.folders.folders, self.policies)
|
||||
|
||||
def resolve_peer(self, peer_id: int, *, is_bot: bool = False) -> CaptureToggles:
|
||||
return self.resolve(
|
||||
ChatMeta(
|
||||
chat_id=peer_id,
|
||||
kind=ChatKind.DM,
|
||||
is_bot=is_bot,
|
||||
is_contact=peer_id in self.contacts.ids,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def build_capture_context(
|
||||
client: Client,
|
||||
|
||||
@@ -58,14 +58,7 @@ async def _capture_peer(message: Message, ctx: CaptureContext) -> None:
|
||||
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||
if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields):
|
||||
return
|
||||
await write_profile(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
user.id,
|
||||
fields,
|
||||
raw_json(user),
|
||||
history=ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).profile_history,
|
||||
)
|
||||
await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
|
||||
if photo_file_id and photo_unique_id:
|
||||
from userbot.modules.avatars import note_avatar # noqa: PLC0415
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pyrogram import Client
|
||||
from pyrogram.types import Message
|
||||
|
||||
from userbot.modules.capture import repository
|
||||
from userbot.modules.capture.context import CaptureContext
|
||||
from userbot.modules.capture.repository import MessageRow
|
||||
from userbot.modules.links import extract_links
|
||||
from userbot.modules.links import repository as links_repository
|
||||
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
|
||||
from userbot.modules.media import capture_media, self_destruct_ttl
|
||||
from userbot.modules.raw_json import raw_json
|
||||
from utils.policy.models import CaptureToggles
|
||||
|
||||
@@ -37,39 +34,27 @@ def callbacks(message: Message) -> list[tuple[int, str | None, bytes | None]]:
|
||||
return buttons
|
||||
|
||||
|
||||
def message_row(message: Message, chat_id: int, date: datetime) -> MessageRow:
|
||||
return MessageRow(
|
||||
chat_id=chat_id,
|
||||
message_id=message.id,
|
||||
date=date,
|
||||
sender_id=sender_id(message),
|
||||
text=message.text or message.caption,
|
||||
raw=raw_json(message),
|
||||
has_media=message.media is not None,
|
||||
is_self_destruct=self_destruct_ttl(message) is not None,
|
||||
edit_date=message.edit_date,
|
||||
media_unique_id=media_unique_id(message),
|
||||
)
|
||||
|
||||
|
||||
async def capture_message(
|
||||
client: Client, message: Message, ctx: CaptureContext, toggles: CaptureToggles
|
||||
) -> None:
|
||||
if message.empty or message.chat is None or message.date is None:
|
||||
return
|
||||
chat_id = message.chat.id or 0
|
||||
current = await repository.upsert_message(
|
||||
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
|
||||
)
|
||||
if ctx.pending_deletes.take(chat_id, message.id):
|
||||
await repository.mark_deleted_late(
|
||||
ctx.pool, ctx.account_id, chat_id, message.id, message.date
|
||||
await repository.upsert_message(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
chat_id,
|
||||
message.id,
|
||||
message.date,
|
||||
sender_id(message),
|
||||
message.text or message.caption,
|
||||
raw_json(message),
|
||||
has_media=message.media is not None,
|
||||
is_self_destruct=self_destruct_ttl(message) is not None,
|
||||
)
|
||||
from userbot.modules.capture.identity import capture_identity # noqa: PLC0415
|
||||
|
||||
await capture_identity(message, ctx)
|
||||
if not current:
|
||||
return
|
||||
await capture_media(client, message, ctx, chat_id, message.id, toggles)
|
||||
buttons = callbacks(message)
|
||||
if buttons:
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import time
|
||||
|
||||
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
|
||||
|
||||
PENDING_DELETE_SECONDS = 120.0
|
||||
PENDING_DELETE_LIMIT = 10_000
|
||||
|
||||
DeleteKey = tuple[int | None, int]
|
||||
|
||||
|
||||
def _key(chat_id: int | None, message_id: int) -> DeleteKey:
|
||||
if chat_id is None or chat_id > CHANNEL_ID_THRESHOLD:
|
||||
return None, message_id
|
||||
return chat_id, message_id
|
||||
|
||||
|
||||
class PendingDeletes:
|
||||
def __init__(self) -> None:
|
||||
self._expires: dict[DeleteKey, float] = {}
|
||||
|
||||
def remember(self, chat_id: int | None, message_ids: list[int]) -> None:
|
||||
now = time.monotonic()
|
||||
while self._expires:
|
||||
oldest = next(iter(self._expires))
|
||||
fresh = self._expires[oldest] > now
|
||||
if fresh and len(self._expires) < PENDING_DELETE_LIMIT:
|
||||
break
|
||||
del self._expires[oldest]
|
||||
for message_id in message_ids:
|
||||
key = _key(chat_id, message_id)
|
||||
self._expires.pop(key, None)
|
||||
self._expires[key] = now + PENDING_DELETE_SECONDS
|
||||
|
||||
def take(self, chat_id: int, message_id: int) -> bool:
|
||||
expires = self._expires.pop(_key(chat_id, message_id), None)
|
||||
return expires is not None and expires > time.monotonic()
|
||||
@@ -1,4 +1,3 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
import asyncpg
|
||||
@@ -22,19 +21,13 @@ _TOUCH_EDITED = """
|
||||
INSERT INTO messages
|
||||
(account_id, chat_id, message_id, date, sender_id, text, raw,
|
||||
has_media, is_self_destruct, edited_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, CASE WHEN $10 THEN now() END)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, now())
|
||||
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
|
||||
text = EXCLUDED.text,
|
||||
raw = EXCLUDED.raw,
|
||||
has_media = EXCLUDED.has_media,
|
||||
is_self_destruct = EXCLUDED.is_self_destruct,
|
||||
edited_at = 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)
|
||||
)
|
||||
edited_at = now()
|
||||
"""
|
||||
|
||||
_FIND_DELETED = """
|
||||
@@ -47,12 +40,6 @@ UPDATE messages SET deleted_at = now()
|
||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4
|
||||
"""
|
||||
|
||||
_MARK_DELETED_LATE = """
|
||||
UPDATE messages SET deleted_at = now()
|
||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4
|
||||
AND deleted_at IS NULL
|
||||
"""
|
||||
|
||||
_INSERT_VERSION = """
|
||||
INSERT INTO message_versions
|
||||
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
||||
@@ -65,7 +52,7 @@ INSERT INTO message_versions
|
||||
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
||||
SELECT account_id, chat_id, message_id, clock_timestamp(), NULL, text, raw
|
||||
FROM messages m
|
||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
|
||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM message_versions v
|
||||
WHERE v.account_id = m.account_id AND v.chat_id = m.chat_id
|
||||
@@ -74,37 +61,15 @@ WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
|
||||
_INSERT_PAST_VERSION = """
|
||||
INSERT INTO message_versions
|
||||
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
||||
SELECT $1::int, $2::bigint, $3::bigint,
|
||||
coalesce(
|
||||
(SELECT min(v.observed_at) - interval '1 microsecond'
|
||||
FROM message_versions v
|
||||
WHERE v.account_id = $1 AND v.chat_id = $2 AND v.message_id = $3
|
||||
AND v.edit_date > $4::timestamptz),
|
||||
$4::timestamptz,
|
||||
$5::timestamptz
|
||||
),
|
||||
$4::timestamptz, $6::text, $7::jsonb
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM message_versions v
|
||||
WHERE v.account_id = $1 AND v.chat_id = $2 AND v.message_id = $3
|
||||
AND v.edit_date IS NOT DISTINCT FROM $4::timestamptz
|
||||
AND v.text IS NOT DISTINCT FROM $6::text
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
|
||||
_CURRENT_CONTENT = """
|
||||
SELECT
|
||||
m.text AS text,
|
||||
m.raw->>'edit_date' AS edit_date,
|
||||
(SELECT d.unique_id FROM media d
|
||||
WHERE d.account_id = m.account_id AND d.chat_id = m.chat_id
|
||||
AND d.message_id = m.message_id) AS media_unique_id
|
||||
FROM messages m
|
||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
|
||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
|
||||
ORDER BY m.date DESC LIMIT 1
|
||||
"""
|
||||
|
||||
_CURRENT_MEDIA = """
|
||||
@@ -156,110 +121,33 @@ WHERE evicted_key = $2 OR (unique_id = $1 AND evicted_at IS NOT NULL)
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MessageRow:
|
||||
chat_id: int
|
||||
message_id: int
|
||||
date: datetime
|
||||
sender_id: int | None
|
||||
text: str | None
|
||||
raw: str
|
||||
has_media: bool
|
||||
is_self_destruct: bool
|
||||
edit_date: datetime | None
|
||||
media_unique_id: str | None
|
||||
|
||||
@property
|
||||
def columns(self) -> tuple:
|
||||
return (
|
||||
self.chat_id,
|
||||
self.message_id,
|
||||
self.date,
|
||||
self.sender_id,
|
||||
self.text,
|
||||
self.raw,
|
||||
self.has_media,
|
||||
self.is_self_destruct,
|
||||
)
|
||||
|
||||
|
||||
async def _lock_current(
|
||||
conn: asyncpg.Connection, account_id: int, row: MessageRow
|
||||
) -> asyncpg.Record | None:
|
||||
await conn.execute(_LOCK_MESSAGE, account_id, row.chat_id, row.message_id)
|
||||
return await conn.fetchrow(
|
||||
_CURRENT_CONTENT, account_id, row.chat_id, row.message_id, row.date
|
||||
)
|
||||
|
||||
|
||||
def _is_stale(current: asyncpg.Record, row: MessageRow) -> bool:
|
||||
stored = current["edit_date"]
|
||||
if stored is None:
|
||||
return False
|
||||
if row.edit_date is None:
|
||||
return True
|
||||
try:
|
||||
return row.edit_date < datetime.fromisoformat(stored)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
async def _record_past(
|
||||
conn: asyncpg.Connection, account_id: int, row: MessageRow
|
||||
async def upsert_message( # noqa: PLR0913
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
date: datetime,
|
||||
sender_id: int | None,
|
||||
text: str | None,
|
||||
raw: str,
|
||||
*,
|
||||
has_media: bool,
|
||||
is_self_destruct: bool,
|
||||
) -> None:
|
||||
await conn.execute(
|
||||
_INSERT_PAST_VERSION,
|
||||
await pool.execute(
|
||||
_UPSERT_MESSAGE,
|
||||
account_id,
|
||||
row.chat_id,
|
||||
row.message_id,
|
||||
row.edit_date,
|
||||
row.date,
|
||||
row.text,
|
||||
row.raw,
|
||||
chat_id,
|
||||
message_id,
|
||||
date,
|
||||
sender_id,
|
||||
text,
|
||||
raw,
|
||||
has_media,
|
||||
is_self_destruct,
|
||||
)
|
||||
|
||||
|
||||
async def _record_edit(
|
||||
conn: asyncpg.Connection, account_id: int, row: MessageRow, *, mark_edited: bool
|
||||
) -> None:
|
||||
await conn.execute(
|
||||
_SNAPSHOT_ORIGINAL, account_id, row.chat_id, row.message_id, row.date
|
||||
)
|
||||
await conn.execute(_TOUCH_EDITED, account_id, *row.columns, mark_edited)
|
||||
await conn.execute(
|
||||
_INSERT_VERSION,
|
||||
account_id,
|
||||
row.chat_id,
|
||||
row.message_id,
|
||||
row.edit_date,
|
||||
row.text,
|
||||
row.raw,
|
||||
)
|
||||
|
||||
|
||||
async def upsert_message(pool: asyncpg.Pool, account_id: int, row: MessageRow) -> bool:
|
||||
async with pool.acquire() as conn, conn.transaction():
|
||||
current = await _lock_current(conn, account_id, row)
|
||||
if current is None:
|
||||
await conn.execute(_UPSERT_MESSAGE, account_id, *row.columns)
|
||||
return True
|
||||
stored_media = current["media_unique_id"]
|
||||
changed = current["text"] != row.text or (
|
||||
stored_media is not None and stored_media != row.media_unique_id
|
||||
)
|
||||
if _is_stale(current, row):
|
||||
if changed:
|
||||
await _record_past(conn, account_id, row)
|
||||
return False
|
||||
if changed:
|
||||
await _record_edit(
|
||||
conn, account_id, row, mark_edited=row.edit_date is not None
|
||||
)
|
||||
else:
|
||||
await conn.execute(_UPSERT_MESSAGE, account_id, *row.columns)
|
||||
return True
|
||||
|
||||
|
||||
async def max_message_id(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int
|
||||
) -> int | None:
|
||||
@@ -300,29 +188,48 @@ async def mark_deleted_channel(
|
||||
await _mark_deleted(pool, account_id, rows)
|
||||
|
||||
|
||||
async def add_version(pool: asyncpg.Pool, account_id: int, row: MessageRow) -> bool:
|
||||
async def add_version( # noqa: PLR0913
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
date: datetime,
|
||||
sender_id: int | None,
|
||||
text: str | None,
|
||||
raw: str,
|
||||
edit_date: datetime | None,
|
||||
media_unique_id: str | None,
|
||||
*,
|
||||
has_media: bool,
|
||||
is_self_destruct: bool,
|
||||
) -> bool:
|
||||
async with pool.acquire() as conn, conn.transaction():
|
||||
current = await _lock_current(conn, account_id, row)
|
||||
if current is not None:
|
||||
changed = (
|
||||
current["text"] != row.text
|
||||
or current["media_unique_id"] != row.media_unique_id
|
||||
current = await conn.fetchrow(_CURRENT_CONTENT, account_id, chat_id, message_id)
|
||||
text_changed = current is None or current["text"] != text
|
||||
media_changed = (
|
||||
current is not None and current["media_unique_id"] != media_unique_id
|
||||
)
|
||||
if not changed:
|
||||
if not (text_changed or media_changed):
|
||||
return False
|
||||
if _is_stale(current, row):
|
||||
await _record_past(conn, account_id, row)
|
||||
return False
|
||||
await _record_edit(conn, account_id, row, mark_edited=True)
|
||||
await conn.execute(_SNAPSHOT_ORIGINAL, account_id, chat_id, message_id)
|
||||
await conn.execute(
|
||||
_TOUCH_EDITED,
|
||||
account_id,
|
||||
chat_id,
|
||||
message_id,
|
||||
date,
|
||||
sender_id,
|
||||
text,
|
||||
raw,
|
||||
has_media,
|
||||
is_self_destruct,
|
||||
)
|
||||
await conn.execute(
|
||||
_INSERT_VERSION, account_id, chat_id, message_id, edit_date, text, raw
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def mark_deleted_late(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int, date: datetime
|
||||
) -> None:
|
||||
await pool.execute(_MARK_DELETED_LATE, account_id, chat_id, message_id, date)
|
||||
|
||||
|
||||
async def current_media(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
||||
) -> asyncpg.Record | None:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from userbot.modules.catchup.service import SavedState, catch_up, read_saved_state
|
||||
|
||||
__all__ = ["SavedState", "catch_up", "read_saved_state"]
|
||||
@@ -1,41 +0,0 @@
|
||||
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)
|
||||
@@ -1,255 +0,0 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from pyrogram import raw, utils
|
||||
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
|
||||
|
||||
from userbot.modules.capture.chat_meta import meta_from_peer
|
||||
from userbot.modules.capture.context import CaptureContext
|
||||
from userbot.modules.catchup import repository
|
||||
from userbot.modules.catchup.repository import UpdateCursor
|
||||
from userbot.modules.client import PyroClient
|
||||
from utils.logging import logger
|
||||
from utils.policy.models import ChatKind
|
||||
|
||||
BOX_SLICES = 50
|
||||
CHANNEL_SLICES = 10
|
||||
CHANNEL_LIMIT = 100
|
||||
MAX_CHANNELS = 50
|
||||
QUEUE_HIGH_WATER = 200
|
||||
QUEUE_WAIT_SECONDS = 0.5
|
||||
NO_QTS = -1
|
||||
MAX_AGE_SECONDS = 3 * 24 * 3600
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SavedState:
|
||||
box: UpdateCursor | None = None
|
||||
channels: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoxWalk:
|
||||
cursor: UpdateCursor
|
||||
done: bool = False
|
||||
updates: int = 0
|
||||
channels: dict[int, raw.base.Chat] = field(default_factory=dict)
|
||||
|
||||
|
||||
def read_saved_state(path: Path) -> SavedState:
|
||||
try:
|
||||
uri = f"{path.resolve().as_uri()}?mode=ro"
|
||||
with closing(sqlite3.connect(uri, uri=True)) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, pts, qts, date FROM update_state"
|
||||
).fetchall()
|
||||
except Exception as exc:
|
||||
logger.warning(f"[yellow]Session {path.stem}: no saved update state ({exc})[/]")
|
||||
return SavedState()
|
||||
box = None
|
||||
channels: dict[int, int] = {}
|
||||
for state_id, pts, qts, date in rows:
|
||||
if pts is None:
|
||||
continue
|
||||
if state_id == 0:
|
||||
box = UpdateCursor(
|
||||
pts, NO_QTS if qts is None else qts, date or int(time.time())
|
||||
)
|
||||
else:
|
||||
channels[state_id] = pts
|
||||
return SavedState(box, channels)
|
||||
|
||||
|
||||
async def _dispatch(
|
||||
client: PyroClient,
|
||||
new_messages: list,
|
||||
other_updates: list,
|
||||
users: list,
|
||||
chats: list,
|
||||
) -> int:
|
||||
await client.fetch_peers(users)
|
||||
await client.fetch_peers(chats)
|
||||
user_map = {user.id: user for user in users}
|
||||
chat_map = {chat.id: chat for chat in chats}
|
||||
queue = client.dispatcher.updates_queue
|
||||
for message in new_messages:
|
||||
update = raw.types.UpdateNewMessage(message=message, pts=0, pts_count=0)
|
||||
queue.put_nowait((update, user_map, chat_map))
|
||||
for update in other_updates:
|
||||
queue.put_nowait((update, user_map, chat_map))
|
||||
while queue.qsize() > QUEUE_HIGH_WATER: # noqa: ASYNC110
|
||||
await asyncio.sleep(QUEUE_WAIT_SECONDS)
|
||||
return len(new_messages) + len(other_updates)
|
||||
|
||||
|
||||
def _channel_hints(other_updates: list, chats: list) -> dict[int, raw.base.Chat]:
|
||||
by_id = {chat.id: chat for chat in chats}
|
||||
return {
|
||||
utils.get_channel_id(update.channel_id): by_id[update.channel_id]
|
||||
for update in other_updates
|
||||
if isinstance(update, raw.types.UpdateChannelTooLong)
|
||||
and update.channel_id in by_id
|
||||
}
|
||||
|
||||
|
||||
async def _walk_box(
|
||||
client: PyroClient, ctx: CaptureContext, cursor: UpdateCursor
|
||||
) -> BoxWalk:
|
||||
walk = BoxWalk(cursor)
|
||||
for _ in range(BOX_SLICES):
|
||||
diff = await client.invoke(
|
||||
raw.functions.updates.GetDifference(
|
||||
pts=walk.cursor.pts, date=walk.cursor.date, qts=walk.cursor.qts
|
||||
)
|
||||
)
|
||||
if isinstance(diff, raw.types.updates.DifferenceEmpty):
|
||||
walk.done = True
|
||||
return walk
|
||||
if isinstance(diff, raw.types.updates.DifferenceTooLong):
|
||||
logger.warning(
|
||||
f"[yellow]Account {ctx.account_id}: too much missed to catch up "
|
||||
f"(pts {walk.cursor.pts} to {diff.pts}), that gap stays[/]"
|
||||
)
|
||||
walk.done = True
|
||||
return walk
|
||||
walk.updates += await _dispatch(
|
||||
client, diff.new_messages, diff.other_updates, diff.users, diff.chats
|
||||
)
|
||||
walk.channels.update(_channel_hints(diff.other_updates, diff.chats))
|
||||
final = isinstance(diff, raw.types.updates.Difference)
|
||||
state = diff.state if final else diff.intermediate_state
|
||||
moved = UpdateCursor(state.pts, state.qts, state.date or walk.cursor.date)
|
||||
if moved == walk.cursor and not final:
|
||||
logger.warning(
|
||||
f"[yellow]Account {ctx.account_id}: catch-up stopped advancing "
|
||||
f"at pts={moved.pts}[/]"
|
||||
)
|
||||
return walk
|
||||
walk.cursor = moved
|
||||
await repository.save_cursor(ctx.pool, ctx.account_id, moved)
|
||||
if final:
|
||||
walk.done = True
|
||||
return walk
|
||||
return walk
|
||||
|
||||
|
||||
async def _walk_channel(client: PyroClient, peer_id: int, pts: int) -> int:
|
||||
peer = await client.resolve_peer(peer_id)
|
||||
if not isinstance(peer, raw.types.InputPeerChannel):
|
||||
return 0
|
||||
channel = raw.types.InputChannel(
|
||||
channel_id=peer.channel_id, access_hash=peer.access_hash
|
||||
)
|
||||
updates = 0
|
||||
for _ in range(CHANNEL_SLICES):
|
||||
diff = await client.invoke(
|
||||
raw.functions.updates.GetChannelDifference(
|
||||
channel=channel,
|
||||
filter=raw.types.ChannelMessagesFilterEmpty(),
|
||||
pts=pts,
|
||||
limit=CHANNEL_LIMIT,
|
||||
)
|
||||
)
|
||||
if isinstance(diff, raw.types.updates.ChannelDifferenceEmpty):
|
||||
return updates
|
||||
if isinstance(diff, raw.types.updates.ChannelDifferenceTooLong):
|
||||
logger.info(f"Channel {peer_id}: too much missed to catch up")
|
||||
return updates
|
||||
updates += await _dispatch(
|
||||
client, diff.new_messages, diff.other_updates, diff.users, diff.chats
|
||||
)
|
||||
if diff.final:
|
||||
return updates
|
||||
pts = diff.pts
|
||||
return updates
|
||||
|
||||
|
||||
def _captured_channels(
|
||||
ctx: CaptureContext, hints: dict[int, raw.base.Chat], saved: dict[int, int]
|
||||
) -> list[int]:
|
||||
wanted: list[tuple[bool, int]] = []
|
||||
for peer_id, chat in hints.items():
|
||||
if peer_id not in saved:
|
||||
continue
|
||||
meta = meta_from_peer(
|
||||
raw.types.PeerChannel(channel_id=chat.id),
|
||||
{chat.id: chat},
|
||||
{},
|
||||
ctx.contacts.ids,
|
||||
)
|
||||
toggles = ctx.resolve(meta)
|
||||
if toggles.messages or toggles.track_edits_deletes:
|
||||
wanted.append((meta.kind is ChatKind.CHANNEL, peer_id))
|
||||
return [peer_id for _, peer_id in sorted(wanted)[:MAX_CHANNELS]]
|
||||
|
||||
|
||||
async def _walk_channels(
|
||||
client: PyroClient, ctx: CaptureContext, walk: BoxWalk, saved: dict[int, int]
|
||||
) -> int:
|
||||
peer_ids = _captured_channels(ctx, walk.channels, saved)
|
||||
updates = 0
|
||||
for peer_id in peer_ids:
|
||||
try:
|
||||
updates += await _walk_channel(client, peer_id, saved[peer_id])
|
||||
except (FloodWait, FloodPremiumWait) as exc:
|
||||
logger.warning(
|
||||
f"[yellow]Account {ctx.account_id}: channel catch-up stopped "
|
||||
f"by a flood wait:[/] {exc}"
|
||||
)
|
||||
break
|
||||
except RPCError as exc:
|
||||
logger.warning(
|
||||
f"[yellow]Account {ctx.account_id}: channel {peer_id} "
|
||||
f"catch-up failed:[/] {exc}"
|
||||
)
|
||||
skipped = len(walk.channels) - len(peer_ids)
|
||||
if skipped:
|
||||
logger.info(f"Account {ctx.account_id}: {skipped} channel gap(s) left as is")
|
||||
return updates
|
||||
|
||||
|
||||
async def catch_up(client: PyroClient, saved: SavedState) -> None:
|
||||
ctx = client.capture
|
||||
if ctx is None:
|
||||
return
|
||||
try:
|
||||
pending = await repository.load_cursor(ctx.pool, ctx.account_id)
|
||||
cursor = pending or saved.box
|
||||
if cursor is None:
|
||||
logger.info(f"Account {ctx.account_id}: no saved update state to catch up")
|
||||
return
|
||||
if time.time() - cursor.date > MAX_AGE_SECONDS:
|
||||
logger.warning(
|
||||
f"[yellow]Account {ctx.account_id}: last update seen too long ago "
|
||||
f"(pts={cursor.pts}), not catching up[/]"
|
||||
)
|
||||
await repository.clear_cursor(ctx.pool, ctx.account_id)
|
||||
return
|
||||
await repository.save_cursor(ctx.pool, ctx.account_id, cursor)
|
||||
logger.info(
|
||||
f"Account {ctx.account_id}: catching up from pts={cursor.pts}"
|
||||
f"{' (resumed)' if pending else ''}"
|
||||
)
|
||||
walk = await _walk_box(client, ctx, cursor)
|
||||
if not walk.done:
|
||||
logger.warning(
|
||||
f"[yellow]Account {ctx.account_id}: catch-up unfinished at "
|
||||
f"pts={walk.cursor.pts},[/] {walk.updates} updates replayed, "
|
||||
f"resumes on the next start"
|
||||
)
|
||||
return
|
||||
from_channels = await _walk_channels(client, ctx, walk, saved.channels)
|
||||
await repository.clear_cursor(ctx.pool, ctx.account_id)
|
||||
logger.info(
|
||||
f"[green]Account {ctx.account_id}: caught up,[/] {walk.updates} updates "
|
||||
f"from the main box, {from_channels} from channels"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"[red]Account {ctx.account_id}: catch-up failed,[/] "
|
||||
f"resumes on the next start"
|
||||
)
|
||||
@@ -9,12 +9,11 @@ from userbot.modules.jobs import repository
|
||||
from userbot.modules.jobs.context import JobContext
|
||||
from userbot.modules.jobs.registry import JOB_HANDLERS, JobHandler
|
||||
from utils.db.models import Job, JobStatus
|
||||
from utils.listen import Listener
|
||||
from utils.env import env
|
||||
from utils.logging import logger
|
||||
|
||||
JOBS_CHANGED_CHANNEL = "jobs_changed"
|
||||
POLL_INTERVAL_SECONDS = 60.0
|
||||
RESTART_SECONDS = 10.0
|
||||
|
||||
|
||||
class JobConsumer:
|
||||
@@ -39,20 +38,25 @@ class JobConsumer:
|
||||
self.pool, ctx.job_id, JobStatus.FAILED, f"no handler for {job.kind!r}"
|
||||
)
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
await handler(ctx)
|
||||
except (FloodWait, FloodPremiumWait) as exc:
|
||||
await repository.bump_flood_wait(self.pool, ctx.job_id)
|
||||
wait = exc.value if isinstance(exc.value, int) else 1
|
||||
await repository.defer_flood_wait(self.pool, ctx.job_id, wait)
|
||||
logger.warning(
|
||||
f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind}), "
|
||||
f"deferred[/]"
|
||||
f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind})[/]"
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
except Exception as exc:
|
||||
logger.exception(f"Job {ctx.job_id} ({job.kind}) failed")
|
||||
await repository.finish(self.pool, ctx.job_id, JobStatus.FAILED, str(exc))
|
||||
await repository.finish(
|
||||
self.pool, ctx.job_id, JobStatus.FAILED, str(exc)
|
||||
)
|
||||
return
|
||||
else:
|
||||
await repository.finish(self.pool, ctx.job_id, JobStatus.DONE)
|
||||
return
|
||||
|
||||
async def drain(self) -> None:
|
||||
while True:
|
||||
@@ -62,41 +66,23 @@ class JobConsumer:
|
||||
await self.execute(job)
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._serve()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"[red]Job consumer for account {self.account_id} failed,[/] "
|
||||
f"restarting in {RESTART_SECONDS:.0f}s"
|
||||
)
|
||||
await asyncio.sleep(RESTART_SECONDS)
|
||||
|
||||
async def _serve(self) -> None:
|
||||
requeued = await repository.requeue_running(self.pool, self.account_id)
|
||||
if requeued:
|
||||
logger.info(f"[yellow]Requeued {requeued} stale running job(s).[/]")
|
||||
pruned = await repository.prune_finished(self.pool, self.account_id)
|
||||
if pruned:
|
||||
logger.info(f"[yellow]Pruned {pruned} finished job(s).[/]")
|
||||
listener = Listener(
|
||||
f"jobs {self.account_id}",
|
||||
{JOBS_CHANGED_CHANNEL: lambda _payload: self._wake.set()},
|
||||
self._wake.set,
|
||||
)
|
||||
await listener.start()
|
||||
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||
await conn.add_listener(JOBS_CHANGED_CHANNEL, lambda *_: self._wake.set())
|
||||
logger.info(f"[green]Job consumer running for account {self.account_id}.[/]")
|
||||
try:
|
||||
while True:
|
||||
self._wake.clear()
|
||||
await self.drain()
|
||||
self._wake.clear()
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
self._wake.wait(), timeout=await self._idle_seconds()
|
||||
self._wake.wait(), timeout=POLL_INTERVAL_SECONDS
|
||||
)
|
||||
finally:
|
||||
await listener.stop()
|
||||
|
||||
async def _idle_seconds(self) -> float:
|
||||
due = await repository.seconds_to_next_due(self.pool, self.account_id)
|
||||
return POLL_INTERVAL_SECONDS if due is None else min(due, POLL_INTERVAL_SECONDS)
|
||||
with contextlib.suppress(Exception):
|
||||
await conn.close()
|
||||
@@ -5,7 +5,6 @@ from userbot.modules.jobs.handlers import (
|
||||
fetch_avatar,
|
||||
fetch_custom_emoji,
|
||||
fetch_media,
|
||||
fetch_read_info,
|
||||
search_peers,
|
||||
sync_contacts,
|
||||
sync_dialogs,
|
||||
@@ -19,7 +18,6 @@ __all__ = [
|
||||
"fetch_avatar",
|
||||
"fetch_custom_emoji",
|
||||
"fetch_media",
|
||||
"fetch_read_info",
|
||||
"search_peers",
|
||||
"sync_contacts",
|
||||
"sync_dialogs",
|
||||
|
||||
@@ -27,14 +27,7 @@ LIMIT 100
|
||||
|
||||
async def _save_user(ctx: CaptureContext, user: User) -> None:
|
||||
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||
await write_profile(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
user.id,
|
||||
fields,
|
||||
raw_json(user),
|
||||
history=ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).profile_history,
|
||||
)
|
||||
await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
|
||||
if photo_file_id and photo_unique_id:
|
||||
await note_avatar(
|
||||
ctx.pool, ctx.account_id, user.id, "peer", photo_unique_id, photo_file_id
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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,16 +22,7 @@ async def sync_contacts(ctx: JobContext) -> None:
|
||||
if not isinstance(user, User):
|
||||
continue
|
||||
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||
await write_profile(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
user.id,
|
||||
fields,
|
||||
raw_json(user),
|
||||
history=capture.resolve_peer(
|
||||
user.id, is_bot=bool(user.is_bot)
|
||||
).profile_history,
|
||||
)
|
||||
await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
|
||||
if photo_file_id and photo_unique_id:
|
||||
await note_avatar(
|
||||
ctx.pool,
|
||||
|
||||
@@ -34,14 +34,7 @@ async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) ->
|
||||
continue
|
||||
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||
await write_profile(
|
||||
ctx.pool,
|
||||
ctx.account_id,
|
||||
user.id,
|
||||
fields,
|
||||
raw_json(user),
|
||||
history=ctx.resolve_peer(
|
||||
user.id, is_bot=bool(user.is_bot)
|
||||
).profile_history,
|
||||
ctx.pool, ctx.account_id, user.id, fields, raw_json(user)
|
||||
)
|
||||
if photo_file_id and photo_unique_id:
|
||||
await note_avatar(
|
||||
|
||||
@@ -32,7 +32,6 @@ async def claim_next(pool: asyncpg.Pool, account_id: int) -> Job | None:
|
||||
"updated_at = now(), attempts = attempts + 1 "
|
||||
"WHERE id = ("
|
||||
" SELECT id FROM jobs WHERE account_id = $1 AND status = 'pending' "
|
||||
" AND (run_after IS NULL OR run_after <= now()) "
|
||||
" ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1"
|
||||
") RETURNING *",
|
||||
account_id,
|
||||
@@ -77,21 +76,11 @@ async def report_progress(
|
||||
)
|
||||
|
||||
|
||||
async def defer_flood_wait(pool: asyncpg.Pool, job_id: int, seconds: int) -> None:
|
||||
async def bump_flood_wait(pool: asyncpg.Pool, job_id: int) -> None:
|
||||
await pool.execute(
|
||||
"UPDATE jobs SET status = 'pending', flood_waits = flood_waits + 1, "
|
||||
"run_after = now() + make_interval(secs => $2), updated_at = now() "
|
||||
"WHERE id = $1 AND status = 'running'",
|
||||
"UPDATE jobs SET flood_waits = flood_waits + 1, updated_at = now() "
|
||||
"WHERE id = $1",
|
||||
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,14 +7,6 @@ INSERT INTO links
|
||||
(account_id, chat_id, message_id, position, url, kind, web_url, web_title,
|
||||
web_description, web_site_name, raw)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
|
||||
ON CONFLICT (account_id, chat_id, message_id, position) DO UPDATE SET
|
||||
url = EXCLUDED.url,
|
||||
kind = EXCLUDED.kind,
|
||||
web_url = EXCLUDED.web_url,
|
||||
web_title = EXCLUDED.web_title,
|
||||
web_description = EXCLUDED.web_description,
|
||||
web_site_name = EXCLUDED.web_site_name,
|
||||
raw = EXCLUDED.raw
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -49,14 +49,8 @@ async def get_peer(
|
||||
)
|
||||
|
||||
|
||||
async def write_profile( # noqa: PLR0913
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int,
|
||||
peer_id: int,
|
||||
fields: ProfileFields,
|
||||
raw: str,
|
||||
*,
|
||||
history: bool,
|
||||
async def write_profile(
|
||||
pool: asyncpg.Pool, account_id: int, peer_id: int, fields: ProfileFields, raw: str
|
||||
) -> None:
|
||||
args = (
|
||||
fields.first_name,
|
||||
@@ -69,5 +63,4 @@ async def write_profile( # noqa: PLR0913
|
||||
)
|
||||
async with pool.acquire() as conn, conn.transaction():
|
||||
await conn.execute(_UPSERT_PEER, account_id, peer_id, *args)
|
||||
if history:
|
||||
await conn.execute(_INSERT_HISTORY, account_id, peer_id, *args)
|
||||
@@ -1,6 +1,5 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pyrogram.enums import ChatType
|
||||
from pyrogram.types import Chat
|
||||
|
||||
from userbot.modules.avatars import note_avatar
|
||||
@@ -14,15 +13,7 @@ from userbot.modules.raw_json import raw_json
|
||||
async def save_private(ctx: CaptureContext, chat: Chat) -> bool:
|
||||
chat_id = chat.id or 0
|
||||
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
|
||||
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,
|
||||
)
|
||||
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, raw_json(chat))
|
||||
if photo_file_id and photo_unique_id:
|
||||
await note_avatar(
|
||||
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
import asyncpg
|
||||
|
||||
@@ -9,26 +9,6 @@ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
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
|
||||
pool: asyncpg.Pool,
|
||||
@@ -54,33 +34,3 @@ async def insert_listened( # noqa: PLR0913
|
||||
await pool.execute(
|
||||
_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.
@@ -1,16 +0,0 @@
|
||||
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)
|
||||
@@ -11,13 +11,10 @@ import uvloop
|
||||
|
||||
from dependencies.container import container
|
||||
from userbot.modules.capture import CaptureContext, build_capture_context
|
||||
from userbot.modules.catchup import SavedState, catch_up, read_saved_state
|
||||
from userbot.modules.client import PyroClient
|
||||
from userbot.modules.jobs import JobConsumer
|
||||
from userbot.modules.uptime import repository as uptime
|
||||
from utils.env import env
|
||||
from utils.jobs import enqueue
|
||||
from utils.listen import Listener
|
||||
from utils.logging import logger, setup_logging
|
||||
from utils.read.accounts import (
|
||||
ACCOUNTS_CHANGED_CHANNEL,
|
||||
@@ -31,14 +28,12 @@ from utils.storage import ContentAddressedStorage
|
||||
setup_logging()
|
||||
|
||||
SESSION_FLUSH_INTERVAL = 60
|
||||
SESSION_RETRY_INTERVAL = 60
|
||||
UPTIME_INTERVAL = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunningAccount:
|
||||
client: PyroClient
|
||||
tasks: list[asyncio.Task]
|
||||
consumer_task: asyncio.Task
|
||||
device_model: str | None
|
||||
|
||||
|
||||
@@ -59,7 +54,6 @@ class AccountRegistry:
|
||||
self._pool = pool
|
||||
self._storage = storage
|
||||
self._running: dict[str, RunningAccount] = {}
|
||||
self._saved: dict[str, SavedState] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
@@ -92,8 +86,6 @@ class AccountRegistry:
|
||||
|
||||
async def _start(self, path: Path, device_model: str | None) -> None:
|
||||
session_name = path.stem
|
||||
if session_name not in self._saved:
|
||||
self._saved[session_name] = await asyncio.to_thread(read_saved_state, path)
|
||||
client = PyroClient(
|
||||
session_name, workdir=str(path.parent), device_model=device_model
|
||||
)
|
||||
@@ -108,20 +100,14 @@ class AccountRegistry:
|
||||
client, self._pool, self._storage, account_id
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"[red]Failed to start session:[/] {session_name}, "
|
||||
f"retrying in {SESSION_RETRY_INTERVAL}s"
|
||||
)
|
||||
logger.exception(f"[red]Failed to start session:[/] {session_name}")
|
||||
with contextlib.suppress(Exception):
|
||||
await client.stop()
|
||||
return
|
||||
consumer = JobConsumer(client, self._pool, account_id)
|
||||
saved = self._saved.pop(session_name)
|
||||
tasks = [
|
||||
asyncio.create_task(consumer.run()),
|
||||
asyncio.create_task(catch_up(client, saved)),
|
||||
]
|
||||
self._running[session_name] = RunningAccount(client, tasks, device_model)
|
||||
self._running[session_name] = RunningAccount(
|
||||
client, asyncio.create_task(consumer.run()), device_model
|
||||
)
|
||||
logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})")
|
||||
await enqueue(self._pool, account_id, "sync_dialogs", {})
|
||||
await enqueue(self._pool, account_id, "sync_contacts", {})
|
||||
@@ -130,8 +116,7 @@ class AccountRegistry:
|
||||
account = self._running.pop(session_name, None)
|
||||
if account is None:
|
||||
return
|
||||
for task in account.tasks:
|
||||
await _cancel(task)
|
||||
await _cancel(account.consumer_task)
|
||||
with contextlib.suppress(Exception):
|
||||
await account.client.stop()
|
||||
logger.info(f"[yellow]Client stopped:[/] {session_name}")
|
||||
@@ -139,8 +124,7 @@ class AccountRegistry:
|
||||
async def _log_out(self, path: Path) -> None:
|
||||
account = self._running.pop(path.stem, None)
|
||||
if account is not None:
|
||||
for task in account.tasks:
|
||||
await _cancel(task)
|
||||
await _cancel(account.consumer_task)
|
||||
client = account.client
|
||||
else:
|
||||
client = PyroClient(
|
||||
@@ -164,25 +148,9 @@ async def _flush_sessions(registry: AccountRegistry) -> None:
|
||||
await client.storage.save()
|
||||
|
||||
|
||||
async def _retry_sessions(registry: AccountRegistry) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(SESSION_RETRY_INTERVAL)
|
||||
try:
|
||||
await registry.sync()
|
||||
except Exception:
|
||||
logger.exception("[red]Session sync failed[/]")
|
||||
|
||||
|
||||
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:
|
||||
async def _listen_changes(
|
||||
registry: AccountRegistry, tasks: set[asyncio.Task]
|
||||
) -> asyncpg.Connection:
|
||||
def spawn(coro: Coroutine[Any, Any, None]) -> None:
|
||||
task = asyncio.create_task(coro)
|
||||
tasks.add(task)
|
||||
@@ -195,29 +163,26 @@ def _change_listener(registry: AccountRegistry, tasks: set[asyncio.Task]) -> Lis
|
||||
if client.capture is not None:
|
||||
spawn(make_coro(client.capture))
|
||||
|
||||
def on_policy(_payload: str) -> None:
|
||||
def on_policy(
|
||||
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
||||
) -> None:
|
||||
reload(lambda capture: capture.reload_policies())
|
||||
|
||||
def on_watch(_payload: str) -> None:
|
||||
def on_watch(
|
||||
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
||||
) -> None:
|
||||
reload(lambda capture: capture.watches.refresh())
|
||||
|
||||
def on_accounts(_payload: str) -> None:
|
||||
def on_accounts(
|
||||
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
||||
) -> None:
|
||||
spawn(registry.sync())
|
||||
|
||||
def on_reconnect() -> None:
|
||||
on_policy("")
|
||||
on_watch("")
|
||||
on_accounts("")
|
||||
|
||||
return Listener(
|
||||
"userbot",
|
||||
{
|
||||
"policy_changed": on_policy,
|
||||
WATCHES_CHANGED_CHANNEL: on_watch,
|
||||
ACCOUNTS_CHANGED_CHANNEL: on_accounts,
|
||||
},
|
||||
on_reconnect,
|
||||
)
|
||||
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||
await conn.add_listener("policy_changed", on_policy)
|
||||
await conn.add_listener(WATCHES_CHANGED_CHANNEL, on_watch)
|
||||
await conn.add_listener(ACCOUNTS_CHANGED_CHANNEL, on_accounts)
|
||||
return conn
|
||||
|
||||
|
||||
async def runner() -> None:
|
||||
@@ -226,24 +191,22 @@ async def runner() -> None:
|
||||
|
||||
registry = AccountRegistry(pool, storage)
|
||||
tasks: set[asyncio.Task] = set()
|
||||
listener = _change_listener(registry, tasks)
|
||||
listen_conn: asyncpg.Connection | None = None
|
||||
stop = asyncio.Event()
|
||||
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
||||
flush = asyncio.create_task(_flush_sessions(registry))
|
||||
retry = asyncio.create_task(_retry_sessions(registry))
|
||||
beat = asyncio.create_task(_keep_uptime(registry, pool))
|
||||
try:
|
||||
await registry.sync()
|
||||
if not registry.clients:
|
||||
logger.warning("[yellow]No sessions yet. Add an account in the web UI.[/]")
|
||||
await listener.start()
|
||||
listen_conn = await _listen_changes(registry, tasks)
|
||||
logger.info("[green]Userbot running.[/]")
|
||||
await stop.wait()
|
||||
finally:
|
||||
await _cancel(flush)
|
||||
await _cancel(retry)
|
||||
await _cancel(beat)
|
||||
await listener.stop()
|
||||
if listen_conn is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await listen_conn.close()
|
||||
await registry.close()
|
||||
await container.close()
|
||||
|
||||
|
||||
@@ -203,9 +203,6 @@ class Job(SQLModel, table=True):
|
||||
finished_at: datetime | None = Field(
|
||||
default=None, sa_column=Column(DateTime(timezone=True))
|
||||
)
|
||||
run_after: datetime | None = Field(
|
||||
default=None, sa_column=Column(DateTime(timezone=True))
|
||||
)
|
||||
|
||||
|
||||
class CapturePolicy(SQLModel, table=True):
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Literal
|
||||
import asyncpg
|
||||
|
||||
BG_EVENTS_CHANNEL = "bg_events"
|
||||
MESSAGE_IDS_PER_EVENT = 500
|
||||
|
||||
EventKind = Literal["message", "edit", "delete", "reaction", "presence", "receipt"]
|
||||
|
||||
@@ -23,15 +22,8 @@ async def notify_bg_event( # noqa: PLR0913
|
||||
payload["chat_id"] = chat_id
|
||||
if message_id is not None:
|
||||
payload["message_id"] = message_id
|
||||
if message_ids is None:
|
||||
await _notify(pool, payload)
|
||||
return
|
||||
for start in range(0, len(message_ids), MESSAGE_IDS_PER_EVENT):
|
||||
chunk = message_ids[start : start + MESSAGE_IDS_PER_EVENT]
|
||||
await _notify(pool, {**payload, "message_ids": chunk})
|
||||
|
||||
|
||||
async def _notify(pool: asyncpg.Pool, payload: dict[str, object]) -> None:
|
||||
if message_ids is not None:
|
||||
payload["message_ids"] = message_ids
|
||||
await pool.execute(
|
||||
"SELECT pg_notify($1, $2)", BG_EVENTS_CHANNEL, json.dumps(payload)
|
||||
)
|
||||
@@ -1,87 +0,0 @@
|
||||
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)
|
||||
@@ -4,31 +4,23 @@ import asyncpg
|
||||
|
||||
from utils.read.accounts import self_user_id
|
||||
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(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int, tz: str
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int = 90
|
||||
) -> list[VolumeBucket]:
|
||||
self_id = await self_user_id(pool, account_id)
|
||||
since, _ = await period_bounds(pool, days, tz)
|
||||
rows = await pool.fetch(_VOLUME, account_id, chat_id, self_id, since, tz)
|
||||
rows = await pool.fetch(
|
||||
"SELECT date_trunc('day', date) AS bucket, 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 bucket ORDER BY bucket",
|
||||
account_id,
|
||||
chat_id,
|
||||
self_id,
|
||||
datetime.now(UTC) - timedelta(days=days),
|
||||
)
|
||||
return [
|
||||
VolumeBucket(
|
||||
bucket=row["bucket"],
|
||||
|
||||
@@ -10,7 +10,7 @@ from utils.read.models import (
|
||||
MessageView,
|
||||
Page,
|
||||
)
|
||||
from utils.read.read_receipts import read_times, read_up_to
|
||||
from utils.read.read_receipts import read_up_to
|
||||
|
||||
_MESSAGE_COLS = (
|
||||
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
|
||||
@@ -285,18 +285,12 @@ async def _apply_read_status(
|
||||
self_id = await self_user_id(pool, account_id)
|
||||
if self_id is None:
|
||||
return
|
||||
own = [view for view in views if view.sender_id == self_id]
|
||||
if not own:
|
||||
return
|
||||
marker = await read_up_to(pool, account_id, chat_id)
|
||||
times = await read_times(
|
||||
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
|
||||
)
|
||||
if marker is None:
|
||||
return
|
||||
for view in views:
|
||||
if view.sender_id == self_id and view.message_id <= marker:
|
||||
view.read = True
|
||||
|
||||
|
||||
def _build_album(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import date, datetime
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -203,7 +203,6 @@ class MessageView(BaseModel):
|
||||
is_sticker: bool = False
|
||||
is_animated_emoji: bool = False
|
||||
read: bool = False
|
||||
read_at: datetime | None = None
|
||||
|
||||
|
||||
class MessageVersionView(BaseModel):
|
||||
@@ -362,61 +361,8 @@ class PresenceHourly(BaseModel):
|
||||
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):
|
||||
bucket: date
|
||||
bucket: datetime
|
||||
total: int
|
||||
outgoing: int
|
||||
incoming: int
|
||||
@@ -441,29 +387,6 @@ class PeerView(BaseModel):
|
||||
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):
|
||||
observed_at: datetime
|
||||
first_name: str | None
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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"]
|
||||
@@ -1,289 +0,0 @@
|
||||
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]
|
||||
@@ -1,89 +1,5 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
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:
|
||||
return await pool.fetchval(
|
||||
@@ -92,97 +8,3 @@ async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int |
|
||||
account_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,
|
||||
)
|
||||
@@ -167,7 +167,6 @@ class BotRegistry:
|
||||
await client.stop()
|
||||
return
|
||||
|
||||
try:
|
||||
account_id = await accounts.sync_account(
|
||||
self._pool, me, str(bot_id), is_bot=True
|
||||
)
|
||||
@@ -176,24 +175,9 @@ class BotRegistry:
|
||||
)
|
||||
await bots.link_account(self._pool, bot_id, account_id)
|
||||
await bots.set_status(
|
||||
self._pool,
|
||||
bot_id,
|
||||
bots.STATUS_OK,
|
||||
username=me.username,
|
||||
title=me.full_name,
|
||||
self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name
|
||||
)
|
||||
await accounts.notify_accounts_changed(self._pool)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
f"[red]Bot {bot_id} failed to start,[/] retrying on the next sync"
|
||||
)
|
||||
with contextlib.suppress(Exception):
|
||||
await bots.set_status(
|
||||
self._pool, bot_id, bots.STATUS_ERROR, detail=str(exc)
|
||||
)
|
||||
with contextlib.suppress(Exception):
|
||||
await client.stop()
|
||||
return
|
||||
|
||||
consumer = JobConsumer(client, self._pool, account_id)
|
||||
self._running[bot_id] = RunningBot(
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncpg
|
||||
import uvloop
|
||||
|
||||
from dependencies.container import container
|
||||
from utils.listen import Listener
|
||||
from utils.env import env
|
||||
from utils.logging import logger, setup_logging
|
||||
from utils.read.bots import BOTS_CHANGED_CHANNEL
|
||||
from utils.storage import ContentAddressedStorage
|
||||
@@ -20,19 +20,23 @@ RETRY_INTERVAL = 300
|
||||
async def _retry_loop(registry: BotRegistry) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(RETRY_INTERVAL)
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
await registry.sync()
|
||||
except Exception:
|
||||
logger.exception("[red]Bot sync failed[/]")
|
||||
|
||||
|
||||
def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener:
|
||||
def on_change(_payload: str) -> None:
|
||||
async def _listen_changes(
|
||||
registry: BotRegistry, tasks: set[asyncio.Task]
|
||||
) -> asyncpg.Connection:
|
||||
def on_change(
|
||||
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
||||
) -> None:
|
||||
task = asyncio.create_task(registry.sync())
|
||||
tasks.add(task)
|
||||
task.add_done_callback(tasks.discard)
|
||||
|
||||
return Listener("watcher", {BOTS_CHANGED_CHANNEL: on_change}, lambda: on_change(""))
|
||||
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||
await conn.add_listener(BOTS_CHANGED_CHANNEL, on_change)
|
||||
return conn
|
||||
|
||||
|
||||
async def runner() -> None:
|
||||
@@ -40,7 +44,7 @@ async def runner() -> None:
|
||||
storage = await container.get(ContentAddressedStorage)
|
||||
registry = BotRegistry(pool, storage)
|
||||
tasks: set[asyncio.Task] = set()
|
||||
listener = _change_listener(registry, tasks)
|
||||
listen_conn: asyncpg.Connection | None = None
|
||||
stop = asyncio.Event()
|
||||
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
||||
retry = asyncio.create_task(_retry_loop(registry))
|
||||
@@ -48,14 +52,16 @@ async def runner() -> None:
|
||||
await registry.sync()
|
||||
if not registry.running:
|
||||
logger.warning("[yellow]No bots yet. Add a bot token in the web UI.[/]")
|
||||
await listener.start()
|
||||
listen_conn = await _listen_changes(registry, tasks)
|
||||
logger.info("[green]Bot watcher running.[/]")
|
||||
await stop.wait()
|
||||
finally:
|
||||
retry.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await retry
|
||||
await listener.stop()
|
||||
if listen_conn is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await listen_conn.close()
|
||||
await registry.close()
|
||||
await container.close()
|
||||
|
||||
|
||||
Generated
+7
-6
@@ -411,7 +411,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.143.0"
|
||||
version = "0.143.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -421,9 +421,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/d5/b0aeb0b303650ac87b0411de4aaf3f901b06042dcc3fb00f30b97b7ff585/fastapi-0.143.3.tar.gz", hash = "sha256:b9ab567ee57afb4454004ce04c889f8f0160ac47946b99795887cd6e88484848", size = 469834, upload-time = "2026-10-16T14:14:13.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1a/03d246befcf307219bb2161c97d8c5d314f84125bf7dd245730a28589828/fastapi-0.143.3-py3-none-any.whl", hash = "sha256:75e8bd68e2f52f77d29b2879749c257d64de2d85282d25270caf2bfccce5efe5", size = 144849, upload-time = "2026-10-16T14:14:11.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1080,16 +1080,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.15.0"
|
||||
version = "2.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/3b/a5d2294799b53b448319978cfb5bd139d5a9d45e862af91661614f14c922/pydantic_settings-2.16.0.tar.gz", hash = "sha256:5b6c578049ede4db0e2ef3b4eaa4ad4069cfa9211f83fb38df899dfade50a614", size = 301031, upload-time = "2026-10-14T12:44:09.998Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl", hash = "sha256:7e73acf7f61936a15e5a3b6eedaea29f133357faf7272f2607ba479b049dd7f2", size = 80364, upload-time = "2026-10-14T12:44:08.233Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-10
@@ -1,18 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta content="scale" name="text-scale" />
|
||||
<meta content="#ffffff" media="(prefers-color-scheme: light)" name="theme-color" />
|
||||
<meta content="#212121" media="(prefers-color-scheme: dark)" name="theme-color" />
|
||||
<meta content="yes" name="mobile-web-app-capable" />
|
||||
<meta content="black-translucent" name="apple-mobile-web-app-status-bar-style" />
|
||||
<meta content="Beavergram" name="apple-mobile-web-app-title" />
|
||||
<link href="/icon.svg" rel="icon" type="image/svg+xml" />
|
||||
<link href="/apple-touch-icon.png" rel="apple-touch-icon" />
|
||||
<link href="/manifest.webmanifest" rel="manifest" />
|
||||
<title>Beavergram</title>
|
||||
<script>
|
||||
(() => {
|
||||
const stored = localStorage.getItem("bg.theme");
|
||||
|
||||
@@ -4,27 +4,13 @@ import { PUBLIC_API_BASE_URL } from "$app/env/public";
|
||||
|
||||
const BASE = PUBLIC_API_BASE_URL;
|
||||
const RETRY_DELAY = 2500;
|
||||
const MISSING_TTL = 120_000;
|
||||
|
||||
export type AvatarKind = "peer" | "chat";
|
||||
|
||||
const MAX_CACHED = 240;
|
||||
|
||||
const ready = new Map<string, string>();
|
||||
const missing = new 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 missing = new Set<string>();
|
||||
const inflight = new Map<string, Promise<string | null>>();
|
||||
|
||||
function cacheKey(account: number, kind: AvatarKind, id: number): string {
|
||||
@@ -74,15 +60,15 @@ async function fetchAvatar(
|
||||
await delay(RETRY_DELAY);
|
||||
return fetchAvatar(account, kind, id, key, false);
|
||||
}
|
||||
missing.set(key, Date.now());
|
||||
missing.add(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadAvatar(
|
||||
kind: AvatarKind,
|
||||
id: number,
|
||||
account: number | null = accounts.selectedId
|
||||
id: number
|
||||
): Promise<string | null> {
|
||||
const account = accounts.selectedId;
|
||||
if (account === null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
@@ -91,7 +77,7 @@ export function loadAvatar(
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
if (isMissing(key)) {
|
||||
if (missing.has(key)) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const existing = inflight.get(key);
|
||||
@@ -124,7 +110,7 @@ async function fetchVariant(
|
||||
await delay(RETRY_DELAY);
|
||||
return fetchVariant(account, kind, id, uniqueId, key, false);
|
||||
}
|
||||
missing.set(key, Date.now());
|
||||
missing.add(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -142,7 +128,7 @@ export function loadAvatarVariant(
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
if (isMissing(key)) {
|
||||
if (missing.has(key)) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const existing = inflight.get(key);
|
||||
|
||||
@@ -28,11 +28,9 @@ import type {
|
||||
PolicyRecord,
|
||||
PolicyRule,
|
||||
PolicyScope,
|
||||
PresenceHourly,
|
||||
PresenceSample,
|
||||
PresenceSession,
|
||||
PresenceStats,
|
||||
ReactionView,
|
||||
ReadInfo,
|
||||
ResponseStats,
|
||||
ScopeTarget,
|
||||
SearchHit,
|
||||
@@ -207,38 +205,30 @@ export function getCurrentPresence(
|
||||
});
|
||||
}
|
||||
|
||||
export function getPresenceStats(
|
||||
export function getPresenceHistory(
|
||||
peerId: number,
|
||||
days: number,
|
||||
tz: string,
|
||||
page: Page = {}
|
||||
): Promise<PresenceStats> {
|
||||
return request<PresenceStats>("/presence/stats", {
|
||||
): Promise<PresenceSample[]> {
|
||||
return request<PresenceSample[]>("/presence", {
|
||||
account: true,
|
||||
query: { days, peer_id: peerId, tz, ...page },
|
||||
query: { peer_id: peerId, ...page },
|
||||
});
|
||||
}
|
||||
|
||||
export function getPresenceSessions(
|
||||
peerId: number,
|
||||
days: number,
|
||||
tz: string,
|
||||
page: Page = {}
|
||||
): Promise<PresenceSession[]> {
|
||||
return request<PresenceSession[]>("/presence/sessions", {
|
||||
export function getPresenceHourly(peerId: number): Promise<PresenceHourly[]> {
|
||||
return request<PresenceHourly[]>("/presence/hourly", {
|
||||
account: true,
|
||||
query: { days, peer_id: peerId, tz, ...page },
|
||||
query: { peer_id: peerId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getMessageVolume(
|
||||
chatId: number,
|
||||
days: number,
|
||||
tz: string
|
||||
days = 90
|
||||
): Promise<VolumeBucket[]> {
|
||||
return request<VolumeBucket[]>("/analytics/volume", {
|
||||
account: true,
|
||||
query: { chat_id: chatId, days, tz },
|
||||
query: { chat_id: chatId, days },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,25 +304,6 @@ 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(
|
||||
chatId: number,
|
||||
messageId: number
|
||||
|
||||
@@ -204,7 +204,6 @@ export interface MessageView {
|
||||
quote: string | null;
|
||||
reactions: ReactionCount[];
|
||||
read: boolean;
|
||||
read_at: string | null;
|
||||
reply: ReplyView | null;
|
||||
sender_id: number | null;
|
||||
service: ServiceView | null;
|
||||
@@ -336,57 +335,12 @@ export interface PresenceSample {
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export interface PresenceSession {
|
||||
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;
|
||||
export interface PresenceHourly {
|
||||
bucket: string;
|
||||
last_seen: string | null;
|
||||
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;
|
||||
online_samples: number;
|
||||
peer_id: number;
|
||||
sessions: PresenceSession[];
|
||||
since: string;
|
||||
summary: PresenceSummary;
|
||||
tz: string;
|
||||
until: string;
|
||||
week: PresenceHour[];
|
||||
samples: number;
|
||||
}
|
||||
|
||||
export interface VolumeBucket {
|
||||
@@ -415,40 +369,6 @@ export interface PeerView {
|
||||
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 {
|
||||
first_name: string | null;
|
||||
is_deleted_account: boolean;
|
||||
@@ -598,7 +518,6 @@ export interface LivePresenceEvent {
|
||||
|
||||
export interface LiveReceiptEvent {
|
||||
chat_id: number;
|
||||
read_at: string;
|
||||
read_up_to: number;
|
||||
type: "receipt";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { enqueueBackfill, getPeer } from "#lib/api/endpoints.js";
|
||||
import type { PeerView } from "#lib/api/types.js";
|
||||
import {
|
||||
enqueueBackfill,
|
||||
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 Button from "#lib/components/ui/Button.svelte";
|
||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||
@@ -12,7 +16,6 @@
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { discover } from "#lib/stores/discover.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 { ui } from "#lib/stores/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -23,10 +26,13 @@
|
||||
|
||||
let { chatId }: Props = $props();
|
||||
|
||||
const PRESENCE_INTERVAL = 30_000;
|
||||
|
||||
const isDm = $derived(chatId > 0);
|
||||
const chat = $derived(chats.byId(chatId));
|
||||
const discovered = $derived(discover.get(chatId));
|
||||
let peer = $state<PeerView | null>(null);
|
||||
let presence = $state<PresenceSample | null>(null);
|
||||
let backfilling = $state(false);
|
||||
|
||||
async function backfill() {
|
||||
@@ -75,8 +81,34 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
presence.follow(accounts.selectedId, isDm ? chatId : null);
|
||||
return () => presence.follow(null, null);
|
||||
if (accounts.selectedId === null || !isDm) {
|
||||
presence = 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(
|
||||
@@ -87,8 +119,8 @@
|
||||
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
|
||||
const subtitle = $derived.by(() => {
|
||||
if (isDm) {
|
||||
if (presence.sample) {
|
||||
return formatPresence(presence.sample, presence.now);
|
||||
if (presence) {
|
||||
return formatPresence(presence);
|
||||
}
|
||||
if (peer?.username) {
|
||||
return `@${peer.username}`;
|
||||
@@ -107,10 +139,6 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title} · Beavergram</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="chat-header">
|
||||
<div class="back">
|
||||
<Button
|
||||
@@ -144,12 +172,7 @@
|
||||
/>
|
||||
<div class="info">
|
||||
<h2 class="title">{title}</h2>
|
||||
<span
|
||||
class={[
|
||||
"subtitle",
|
||||
{ online: isDm && isOnline(presence.sample, presence.now) },
|
||||
]}
|
||||
>
|
||||
<span class="subtitle" class:online={isDm && isOnline(presence)}>
|
||||
{subtitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
<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,7 +11,6 @@
|
||||
import Poll from "#lib/components/Poll.svelte";
|
||||
import Reactions from "#lib/components/Reactions.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 ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||
@@ -248,9 +247,6 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
{#if own}
|
||||
<MessageReadItem {message} />
|
||||
{/if}
|
||||
<ContextMenuItem icon="note" onselect={openAnnotations}
|
||||
>Заметки</ContextMenuItem
|
||||
>
|
||||
|
||||
@@ -363,13 +363,13 @@
|
||||
);
|
||||
}
|
||||
|
||||
function applyReceipt(readUpTo: number, readAt: string) {
|
||||
function applyReceipt(readUpTo: number) {
|
||||
if (ownId === null) {
|
||||
return;
|
||||
}
|
||||
messages = messages.map((m) =>
|
||||
m.sender_id === ownId && m.message_id <= readUpTo && !m.read
|
||||
? { ...m, read: true, read_at: readAt }
|
||||
? { ...m, read: true }
|
||||
: m
|
||||
);
|
||||
}
|
||||
@@ -388,7 +388,7 @@
|
||||
) {
|
||||
applyDelete(event.message_ids);
|
||||
} else if (event.type === "receipt" && event.chat_id === chatId) {
|
||||
applyReceipt(event.read_up_to, event.read_at);
|
||||
applyReceipt(event.read_up_to);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
import TgsSticker from "#lib/components/media/TgsSticker.svelte";
|
||||
import VideoNote from "#lib/components/media/VideoNote.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 ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
@@ -223,9 +222,6 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
{#if own}
|
||||
<MessageReadItem {message} />
|
||||
{/if}
|
||||
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
|
||||
>Открыть на весь экран</ContextMenuItem
|
||||
>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { MessageView } from "#lib/api/types.js";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { formatTime } from "#lib/format/datetime.js";
|
||||
import { readTitle } from "#lib/format/read.js";
|
||||
|
||||
interface Props {
|
||||
message: MessageView;
|
||||
@@ -26,9 +25,11 @@
|
||||
{/if}
|
||||
<span class="time">{formatTime(message.date)}</span>
|
||||
{#if own}
|
||||
<span class="ticks" title={readTitle(message)}>
|
||||
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
|
||||
</span>
|
||||
<Icon
|
||||
class="ticks"
|
||||
name={message.read ? "message-read" : "check"}
|
||||
size="1rem"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
@@ -51,10 +52,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticks {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.edited {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
|
||||
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.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 SharesPanel from "#lib/components/shares/SharesPanel.svelte";
|
||||
import CallbacksPanel from "#lib/components/social/CallbacksPanel.svelte";
|
||||
@@ -19,14 +18,13 @@
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.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 { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||
import { storageUi } from "#lib/stores/storage.svelte.js";
|
||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||
|
||||
const titles: Record<RightPanel, string> = $derived({
|
||||
const titles: Record<RightPanel, string> = {
|
||||
alerts: "Алерты",
|
||||
annotations: "Заметки",
|
||||
bots: "Боты",
|
||||
@@ -37,7 +35,6 @@
|
||||
presence: "Аналитика",
|
||||
profile: "Профиль",
|
||||
reactions: "Реакции",
|
||||
readers: t("read.readers.title"),
|
||||
search: "Поиск",
|
||||
shares: "Файлы по ссылке",
|
||||
storage: "Хранилище",
|
||||
@@ -47,7 +44,7 @@
|
||||
"stories-all": "Все сторис",
|
||||
versions: "Версии",
|
||||
watches: "Отслеживания",
|
||||
});
|
||||
};
|
||||
|
||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||
"storage-chat": "storage",
|
||||
@@ -107,8 +104,6 @@
|
||||
<ChatSearchPanel />
|
||||
{:else if ui.rightPanel === "reactions"}
|
||||
<ReactionsPanel />
|
||||
{:else if ui.rightPanel === "readers"}
|
||||
<ReadersPanel />
|
||||
{:else if ui.rightPanel === "callbacks"}
|
||||
<CallbacksPanel />
|
||||
{:else if ui.rightPanel === "links"}
|
||||
|
||||
@@ -1,295 +1,193 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
getCurrentPresence,
|
||||
getMessageVolume,
|
||||
getPresenceStats,
|
||||
getPresenceHistory,
|
||||
getPresenceHourly,
|
||||
getResponseStats,
|
||||
} from "#lib/api/endpoints.js";
|
||||
import type {
|
||||
PresenceStats,
|
||||
PresenceHourly,
|
||||
PresenceSample,
|
||||
ResponseStats,
|
||||
VolumeBucket,
|
||||
} from "#lib/api/types.js";
|
||||
import OnlineDays from "#lib/components/presence/OnlineDays.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 Button from "#lib/components/ui/Button.svelte";
|
||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||
import { browserTimeZone, timeZoneLabel } from "#lib/format/datetime.js";
|
||||
import {
|
||||
formatClock,
|
||||
formatLongSpan,
|
||||
formatPresence,
|
||||
formatSpan,
|
||||
isOnline,
|
||||
} from "#lib/format/presence.js";
|
||||
import { formatFull } from "#lib/format/datetime.js";
|
||||
import { formatPresence } from "#lib/format/presence.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { presence } from "#lib/stores/presence.svelte.js";
|
||||
import { events } from "#lib/stores/events.svelte.js";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const TEXT = {
|
||||
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 HISTORY_LIMIT = 200;
|
||||
const SECONDS_IN_MINUTE = 60;
|
||||
const SECONDS_IN_HOUR = 3600;
|
||||
const MS_IN_SECOND = 1000;
|
||||
|
||||
const tz = browserTimeZone();
|
||||
const zone = timeZoneLabel();
|
||||
const SECONDS_IN_DAY = 86_400;
|
||||
|
||||
const chatId = $derived(
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
const isDm = $derived(chatId !== null && chatId > 0);
|
||||
|
||||
let days = $state(DEFAULT_PERIOD);
|
||||
let stats = $state<PresenceStats | null>(null);
|
||||
let volume = $state<VolumeBucket[] | null>(null);
|
||||
let current = $state<PresenceSample | null>(null);
|
||||
let hourly = $state<PresenceHourly[]>([]);
|
||||
let history = $state<PresenceSample[]>([]);
|
||||
let volume = $state<VolumeBucket[]>([]);
|
||||
let response = $state<ResponseStats | null>(null);
|
||||
let shownDays = $state(DEFAULT_PERIOD);
|
||||
let failed = $state(false);
|
||||
let shownChat: number | null = null;
|
||||
let token = 0;
|
||||
let loading = $state(false);
|
||||
|
||||
const sample = $derived(
|
||||
presence.sample?.peer_id === chatId
|
||||
? presence.sample
|
||||
: (stats?.current ?? null)
|
||||
);
|
||||
const online = $derived(isOnline(sample, presence.now));
|
||||
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 changes = $derived.by(() => {
|
||||
const result: PresenceSample[] = [];
|
||||
let last: string | null = null;
|
||||
for (const sample of history) {
|
||||
if (sample.status !== last) {
|
||||
result.push(sample);
|
||||
last = sample.status;
|
||||
}
|
||||
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;
|
||||
});
|
||||
|
||||
function reply(seconds: number | null): string {
|
||||
return seconds === null ? TEXT.noData : formatSpan(seconds);
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds === null) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
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_MINUTE) {
|
||||
return `${Math.round(seconds)} с`;
|
||||
}
|
||||
try {
|
||||
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_HOUR) {
|
||||
return `${Math.round(seconds / SECONDS_IN_MINUTE)} мин`;
|
||||
}
|
||||
if (seconds < SECONDS_IN_DAY) {
|
||||
return `${Math.round(seconds / SECONDS_IN_HOUR)} ч`;
|
||||
}
|
||||
return `${Math.round(seconds / SECONDS_IN_DAY)} д`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (accounts.selectedId !== null && chatId !== null) {
|
||||
load(chatId, days);
|
||||
if (accounts.selectedId === null || chatId === null) {
|
||||
return;
|
||||
}
|
||||
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>
|
||||
|
||||
{#if chatId === null}
|
||||
<EmptyState description={TEXT.openChat} title={TEXT.title} />
|
||||
<EmptyState description="Откройте чат" title="Аналитика" />
|
||||
{:else}
|
||||
<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>
|
||||
<dl class="rows">
|
||||
{#each rows as row (row.label)}
|
||||
<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>
|
||||
<h3>Объём сообщений (90 дней)</h3>
|
||||
{#if volume.length === 0}
|
||||
<p class="muted">{loading ? "Загрузка…" : "Нет сообщений"}</p>
|
||||
{:else}
|
||||
<VolumeChart {volume} />
|
||||
{/if}
|
||||
{#if response && response.mine_count + response.their_count > 0}
|
||||
<dl class="rows replies">
|
||||
<div class="row">
|
||||
<dt>{TEXT.mine}</dt>
|
||||
<dd>{reply(response.mine_median_seconds)}</dd>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Время ответа</h3>
|
||||
{#if response}
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="value"
|
||||
>{formatDuration(response.mine_median_seconds)}</span
|
||||
>
|
||||
<span class="caption">я отвечаю ({response.mine_count})</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<dt>{TEXT.theirs}</dt>
|
||||
<dd>{reply(response.their_median_seconds)}</dd>
|
||||
<div class="stat">
|
||||
<span class="value"
|
||||
>{formatDuration(response.their_median_seconds)}</span
|
||||
>
|
||||
<span class="caption">мне отвечают ({response.their_count})</span>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">{loading ? "Загрузка…" : "Нет данных"}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if isDm && stats && tracked && !stats.hidden}
|
||||
{#if isDm}
|
||||
<section>
|
||||
<div class="section-title"><h3>{TEXT.sessions}</h3></div>
|
||||
{#key `${chatId}:${shownDays}:${stats.until}`}
|
||||
<PresenceSessions
|
||||
days={shownDays}
|
||||
first={stats.sessions}
|
||||
pageSize={SESSION_PAGE}
|
||||
peerId={stats.peer_id}
|
||||
{tz}
|
||||
/>
|
||||
{/key}
|
||||
<div class="status" class:online={current?.status === "online"}>
|
||||
{current ? formatPresence(current) : "нет данных"}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Когда в сети</h3>
|
||||
{#if hourly.length === 0}
|
||||
<p class="muted">{loading ? "Загрузка…" : "Пока нет данных"}</p>
|
||||
{:else}
|
||||
<PresenceHeatmap {hourly} />
|
||||
{/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}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -298,89 +196,48 @@
|
||||
.analytics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 0.5rem 0;
|
||||
|
||||
& + section {
|
||||
border-top: 1px solid var(--color-borders);
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 0.75rem 1rem 0.875rem;
|
||||
}
|
||||
|
||||
.total-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 0.625rem;
|
||||
align-items: baseline;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-size: 1.875rem;
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 0.875rem;
|
||||
.stats {
|
||||
display: flex;
|
||||
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);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -389,80 +246,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 0.375rem 1rem 0.625rem;
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.state {
|
||||
.changes {
|
||||
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);
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.changes li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-text-secondary);
|
||||
|
||||
&.online {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,144 +0,0 @@
|
||||
<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,217 +1,116 @@
|
||||
<script lang="ts">
|
||||
import type { PresenceHour } from "#lib/api/types.js";
|
||||
import { formatSpan } from "#lib/format/presence.js";
|
||||
import type { PresenceHourly } from "#lib/api/types.js";
|
||||
|
||||
interface Props {
|
||||
week: PresenceHour[];
|
||||
hourly: PresenceHourly[];
|
||||
}
|
||||
|
||||
const { week }: Props = $props();
|
||||
const { hourly }: Props = $props();
|
||||
|
||||
const TEXT = {
|
||||
cell: (day: string, cell: PresenceHour) => {
|
||||
const slot = `${day}, ${cell.hour}:00`;
|
||||
if (cell.hours === 0) {
|
||||
return slot;
|
||||
const DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
|
||||
const HOURS = Array.from({ length: 24 }, (_, hour) => hour);
|
||||
const HOUR_TICKS = new Set([0, 6, 12, 18]);
|
||||
|
||||
interface Cell {
|
||||
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 HOURS_IN_DAY = 24;
|
||||
const HOUR_TICKS = [0, 6, 12, 18];
|
||||
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)
|
||||
)
|
||||
const grid = $derived.by(() => {
|
||||
const cells: Cell[][] = DAYS.map(() =>
|
||||
HOURS.map(() => ({ online: 0, total: 0 }))
|
||||
);
|
||||
const peak = $derived(
|
||||
Math.max(0, ...week.map((cell) => cell.online_minutes ?? 0))
|
||||
);
|
||||
const hasGaps = $derived(week.some(missing));
|
||||
for (const bucket of hourly) {
|
||||
const date = new Date(bucket.bucket);
|
||||
const day = (date.getDay() + 6) % 7;
|
||||
const cell = cells[day]?.[date.getHours()];
|
||||
if (cell) {
|
||||
cell.online += bucket.online_samples;
|
||||
cell.total += bucket.samples;
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
});
|
||||
|
||||
function missing(cell: PresenceHour): boolean {
|
||||
return cell.hours > 0 && cell.online_minutes === null;
|
||||
function intensity(cell: Cell): number {
|
||||
return cell.total > 0 ? cell.online / cell.total : 0;
|
||||
}
|
||||
|
||||
function shade(minutes: number | null): number {
|
||||
if (minutes === null || minutes === 0 || peak === 0) {
|
||||
return 0;
|
||||
}
|
||||
return MIN_SHADE + (1 - MIN_SHADE) * (minutes / peak);
|
||||
function cellTitle(day: number, hour: number, cell: Cell): string {
|
||||
const pct = cell.total > 0 ? Math.round(intensity(cell) * 100) : 0;
|
||||
return `${DAYS[day]} ${hour}:00 — ${pct}% online`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="heatmap">
|
||||
{#each rows as cells, weekday (weekday)}
|
||||
<div class="row">
|
||||
<span class="day">{TEXT.days[weekday]}</span>
|
||||
{#each grid as row, day (day)}
|
||||
<div class="heat-row">
|
||||
<span class="day-label">{DAYS[day]}</span>
|
||||
<div class="cells">
|
||||
{#each cells as cell (cell.hour)}
|
||||
{#each row as cell, hour (hour)}
|
||||
<span
|
||||
class={[
|
||||
"cell",
|
||||
{
|
||||
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}%"
|
||||
class="cell"
|
||||
title={cellTitle(day, hour, cell)}
|
||||
style:--i={intensity(cell)}
|
||||
class:empty={cell.total === 0}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="row">
|
||||
<span class="day"></span>
|
||||
<div class="ticks">
|
||||
{#each HOUR_TICKS as hour (hour)}
|
||||
<span style:left="{(hour / HOURS_IN_DAY) * 100}%">{hour}</span>
|
||||
<div class="heat-row axis">
|
||||
<span class="day-label"></span>
|
||||
<div class="cells">
|
||||
{#each HOURS as hour (hour)}
|
||||
<span class="tick">{HOUR_TICKS.has(hour) ? hour : ""}</span>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
<style lang="scss">
|
||||
.heatmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 1rem;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.row {
|
||||
.heat-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.day {
|
||||
.day-label {
|
||||
width: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
width: 1.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cells {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: repeat(24, minmax(0, 1fr));
|
||||
gap: 2px;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 2px;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) var(--shade),
|
||||
transparent
|
||||
);
|
||||
|
||||
&.zero {
|
||||
background-color: rgba(var(--color-text-secondary-rgb), 0.12);
|
||||
}
|
||||
|
||||
&.outside {
|
||||
background: none;
|
||||
}
|
||||
background-color: var(--color-primary);
|
||||
opacity: calc(0.12 + 0.88 * var(--i));
|
||||
|
||||
&.empty {
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
|
||||
transparent 1px 3px
|
||||
);
|
||||
background-color: var(--color-text-secondary);
|
||||
opacity: 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 1rem;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
top: 0.125rem;
|
||||
font-size: 0.6875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
.tick {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.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
|
||||
);
|
||||
}
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -1,205 +0,0 @@
|
||||
<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,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { VolumeBucket } from "#lib/api/types.js";
|
||||
import { formatDay, parseDay } from "#lib/format/datetime.js";
|
||||
|
||||
interface Props {
|
||||
volume: VolumeBucket[];
|
||||
@@ -8,104 +7,47 @@
|
||||
|
||||
const { volume }: Props = $props();
|
||||
|
||||
const TEXT = {
|
||||
bar: (day: string, bucket: VolumeBucket) =>
|
||||
bucket.total === 0
|
||||
? `${day}, нет сообщений`
|
||||
: `${day}, всего ${bucket.total}: входящих ${bucket.incoming}, моих ${bucket.outgoing}`,
|
||||
incoming: "входящие",
|
||||
outgoing: "мои",
|
||||
today: "сегодня",
|
||||
};
|
||||
const max = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
|
||||
|
||||
const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
|
||||
const first = $derived(volume[0]);
|
||||
|
||||
function label(day: string): string {
|
||||
return formatDay(parseDay(day).toISOString());
|
||||
function barTitle(bucket: VolumeBucket): string {
|
||||
return `${bucket.bucket.slice(0, 10)} — всего ${bucket.total} (вх ${bucket.incoming} / исх ${bucket.outgoing})`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="volume">
|
||||
<div class="chart">
|
||||
<span class="peak">{peak}</span>
|
||||
<div class={["bars", { dense: volume.length > 45 }]}>
|
||||
<div class="bars">
|
||||
{#each volume as bucket (bucket.bucket)}
|
||||
<div class="slot" title={TEXT.bar(label(bucket.bucket), bucket)}>
|
||||
<div
|
||||
class={["bar", { zero: bucket.total === 0 }]}
|
||||
style:height="{(bucket.total / peak) * 100}%"
|
||||
class="bar"
|
||||
title={barTitle(bucket)}
|
||||
style:height={`${(bucket.total / max) * 100}%`}
|
||||
>
|
||||
<div class="seg in" style:flex-grow={bucket.incoming}></div>
|
||||
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
|
||||
</div>
|
||||
<div class="seg in" style:flex-grow={bucket.incoming}></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if first}
|
||||
<div class="axis">
|
||||
<span>{label(first.bucket)}</span>
|
||||
<span>{TEXT.today}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="legend">
|
||||
<span class="key"><span class="swatch in"></span>{TEXT.incoming}</span>
|
||||
<span class="key"><span class="swatch out"></span>{TEXT.outgoing}</span>
|
||||
<span class="key"><span class="swatch out"></span>исходящие</span>
|
||||
<span class="key"><span class="swatch in"></span>входящие</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: flex-end;
|
||||
height: 6rem;
|
||||
|
||||
&.dense {
|
||||
gap: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.slot {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
height: 7.5rem;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
min-height: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 2px 2px 0 0;
|
||||
|
||||
&.zero {
|
||||
background-color: rgba(var(--color-text-secondary-rgb), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.seg.out {
|
||||
@@ -113,18 +55,8 @@
|
||||
}
|
||||
|
||||
.seg.in {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
.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);
|
||||
background-color: var(--color-text-secondary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.legend {
|
||||
@@ -137,8 +69,8 @@
|
||||
|
||||
.key {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
@@ -151,11 +83,8 @@
|
||||
}
|
||||
|
||||
&.in {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 40%,
|
||||
transparent
|
||||
);
|
||||
background-color: var(--color-text-secondary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,104 +0,0 @@
|
||||
<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>
|
||||
@@ -1,124 +0,0 @@
|
||||
<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,20 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
|
||||
const appVersion = "0.0.1";
|
||||
</script>
|
||||
|
||||
<div class="about">
|
||||
<div class="title">Beavergram</div>
|
||||
<div class="line">{t("settings.about.version", { version: appVersion })}</div>
|
||||
<div class="line">{t("settings.about.license")}</div>
|
||||
<div class="line">Версия {appVersion}</div>
|
||||
<div class="line">Лицензия GPL-3.0</div>
|
||||
<a
|
||||
class="line link"
|
||||
href="https://github.com/Ajaxy/telegram-tt"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{t("settings.about.port")}
|
||||
UI портирован из Telegram A
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<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,17 +1,70 @@
|
||||
<script lang="ts">
|
||||
import RadioList from "#lib/components/ui/RadioList.svelte";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { ripple } from "#lib/actions/ripple.js";
|
||||
import { theme } from "#lib/stores/theme.svelte.js";
|
||||
|
||||
const options = $derived([
|
||||
{ label: t("settings.theme.light"), value: "light" as const },
|
||||
{ label: t("settings.theme.dark"), value: "dark" as const },
|
||||
{ label: t("settings.theme.system"), value: "system" as const },
|
||||
]);
|
||||
const options = [
|
||||
{ label: "Светлая", value: "light" },
|
||||
{ label: "Тёмная", value: "dark" },
|
||||
{ label: "Системная", value: "system" },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<RadioList
|
||||
onselect={(value) => theme.set(value)}
|
||||
{options}
|
||||
selected={theme.preference}
|
||||
/>
|
||||
<div class="appearance">
|
||||
{#each options as option (option.value)}
|
||||
<button
|
||||
class="theme-row"
|
||||
onclick={() => theme.set(option.value)}
|
||||
type="button"
|
||||
use:ripple
|
||||
>
|
||||
<span class="dot" class:on={theme.preference === option.value}></span>
|
||||
<span class="label">{option.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.theme-row {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
|
||||
width: 100%;
|
||||
min-height: 3.25rem;
|
||||
padding: 0 1.25rem;
|
||||
border: 0;
|
||||
|
||||
font-size: 1rem;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid var(--color-borders);
|
||||
border-radius: 50%;
|
||||
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&.on {
|
||||
border-width: 0.4375rem;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +0,0 @@
|
||||
<script lang="ts">
|
||||
import RadioList from "#lib/components/ui/RadioList.svelte";
|
||||
import { locale, t } from "#lib/i18n/locale.svelte.js";
|
||||
|
||||
const options = $derived([
|
||||
{ label: t("settings.language.system"), value: "system" as const },
|
||||
{ label: "Русский", value: "ru" as const },
|
||||
{ label: "English", value: "en" as const },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<RadioList
|
||||
onselect={(value) => locale.set(value)}
|
||||
{options}
|
||||
selected={locale.preference}
|
||||
/>
|
||||
@@ -2,14 +2,14 @@
|
||||
import { Dialog } from "bits-ui";
|
||||
import { ripple } from "#lib/actions/ripple.js";
|
||||
import { ApiError } from "#lib/api/client.js";
|
||||
import AccountAvatar from "#lib/components/settings/AccountAvatar.svelte";
|
||||
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
|
||||
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { accountName } from "#lib/format/peer.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||
|
||||
const current = $derived(accounts.selected);
|
||||
const others = $derived(
|
||||
@@ -26,13 +26,11 @@
|
||||
let renaming = $state(false);
|
||||
let deviceName = $state("");
|
||||
let busy = $state(false);
|
||||
let failure = $state<string | null>(null);
|
||||
|
||||
const deviceLabel = $derived(current?.device_model ?? DEFAULT_DEVICE);
|
||||
|
||||
function openRename() {
|
||||
deviceName = deviceLabel;
|
||||
failure = null;
|
||||
renaming = true;
|
||||
}
|
||||
|
||||
@@ -43,13 +41,14 @@
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
failure = null;
|
||||
try {
|
||||
await accounts.renameDevice(current.account_id, value);
|
||||
renaming = false;
|
||||
toasts.success("Имя устройства обновлено");
|
||||
} catch (error) {
|
||||
failure =
|
||||
error instanceof ApiError ? error.detail : t("account.renameFailed");
|
||||
toasts.error(
|
||||
error instanceof ApiError ? error.detail : "Не удалось переименовать"
|
||||
);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -60,12 +59,12 @@
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
failure = null;
|
||||
try {
|
||||
await accounts.logout(current.account_id);
|
||||
confirming = false;
|
||||
toasts.success("Сессия завершена");
|
||||
} catch {
|
||||
failure = t("account.logoutFailed");
|
||||
toasts.error("Не удалось выйти из аккаунта");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -75,13 +74,17 @@
|
||||
<div class="my-account">
|
||||
{#if current}
|
||||
<div class="profile">
|
||||
<AccountAvatar account={current} size={5} />
|
||||
<Avatar
|
||||
colorKey={current.account_id}
|
||||
name={accountName(current)}
|
||||
size={5}
|
||||
/>
|
||||
<div class="name">{accountName(current)}</div>
|
||||
{#if current.phone}
|
||||
<div class="phone">+{current.phone}</div>
|
||||
{/if}
|
||||
{#if !current.is_active}
|
||||
<div class="inactive">{t("account.inactive")}</div>
|
||||
<div class="inactive">Сессия завершена, доступен только архив</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -91,14 +94,18 @@
|
||||
{#each others as account (account.account_id)}
|
||||
<button
|
||||
class="switch-row"
|
||||
onclick={() => accounts.switchTo(account.account_id)}
|
||||
onclick={() => accounts.select(account.account_id)}
|
||||
type="button"
|
||||
use:ripple
|
||||
>
|
||||
<AccountAvatar {account} size={2.25} />
|
||||
<Avatar
|
||||
colorKey={account.account_id}
|
||||
name={accountName(account)}
|
||||
size={2.25}
|
||||
/>
|
||||
<span>{accountName(account)}</span>
|
||||
{#if !account.is_active}
|
||||
<span class="tag">{t("menu.archived")}</span>
|
||||
<span class="tag">архив</span>
|
||||
{/if}
|
||||
<Icon class="chevron" name="arrow-right" size="1rem" />
|
||||
</button>
|
||||
@@ -110,14 +117,14 @@
|
||||
{#if current?.is_active}
|
||||
<SettingsItem
|
||||
icon="active-sessions"
|
||||
label={t("account.device")}
|
||||
label="Имя устройства"
|
||||
onclick={openRename}
|
||||
value={deviceLabel}
|
||||
/>
|
||||
{/if}
|
||||
<SettingsItem
|
||||
icon="add-user"
|
||||
label={t("menu.addAccount")}
|
||||
label="Добавить аккаунт"
|
||||
onclick={() => {
|
||||
adding = true;
|
||||
}}
|
||||
@@ -125,9 +132,8 @@
|
||||
{#if current?.is_active}
|
||||
<SettingsItem
|
||||
icon="logout"
|
||||
label={t("account.logout")}
|
||||
label="Выйти из аккаунта"
|
||||
onclick={() => {
|
||||
failure = null;
|
||||
confirming = true;
|
||||
}}
|
||||
/>
|
||||
@@ -142,14 +148,18 @@
|
||||
<Dialog.Overlay class="dialog-overlay" />
|
||||
<Dialog.Content class="dialog-content">
|
||||
<header class="dialog-head">
|
||||
<Dialog.Title class="dialog-title">{t("account.device")}</Dialog.Title>
|
||||
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
||||
<Dialog.Title class="dialog-title">Имя устройства</Dialog.Title>
|
||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
||||
<Icon name="close" size="1.25rem" />
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<form onsubmit={rename}>
|
||||
<div class="dialog-body">
|
||||
<p class="confirm-text">{t("account.deviceHint")}</p>
|
||||
<p class="confirm-text">
|
||||
Так сессия называется в списке устройств Telegram. Чтобы имя
|
||||
применилось, соединение переподключится — сбор сообщений прервётся
|
||||
на пару секунд.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<input
|
||||
class="form-control"
|
||||
@@ -159,11 +169,8 @@
|
||||
type="text"
|
||||
bind:value={deviceName}
|
||||
>
|
||||
<label for="device-name">{t("account.deviceName")}</label>
|
||||
<label for="device-name">Название</label>
|
||||
</div>
|
||||
{#if failure}
|
||||
<p class="failure">{failure}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<Button
|
||||
@@ -172,7 +179,7 @@
|
||||
pill
|
||||
type="submit"
|
||||
>
|
||||
{t("common.save")}
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -185,22 +192,17 @@
|
||||
<Dialog.Overlay class="dialog-overlay" />
|
||||
<Dialog.Content class="dialog-content">
|
||||
<header class="dialog-head">
|
||||
<Dialog.Title class="dialog-title">
|
||||
{t("account.logoutTitle")}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
||||
<Dialog.Title class="dialog-title">Выйти из аккаунта?</Dialog.Title>
|
||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
||||
<Icon name="close" size="1.25rem" />
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<div class="dialog-body">
|
||||
<p class="confirm-text">
|
||||
{t("account.logoutText", {
|
||||
name: current ? accountName(current) : "",
|
||||
})}
|
||||
Сессия {current ? accountName(current) : ""} будет завершена в
|
||||
Telegram, новые сообщения перестанут собираться. Уже собранный архив
|
||||
останется доступным.
|
||||
</p>
|
||||
{#if failure}
|
||||
<p class="failure">{failure}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<Button
|
||||
@@ -210,10 +212,10 @@
|
||||
pill
|
||||
variant="text"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
Отмена
|
||||
</Button>
|
||||
<Button loading={busy} onclick={logout} pill variant="danger">
|
||||
{t("account.logoutAction")}
|
||||
Выйти
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
@@ -290,12 +292,6 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.failure {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
:global(.switch-row .chevron) {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-icon-secondary);
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<script lang="ts">
|
||||
import About from "#lib/components/settings/About.svelte";
|
||||
import Appearance from "#lib/components/settings/Appearance.svelte";
|
||||
import Language from "#lib/components/settings/Language.svelte";
|
||||
import MyAccount from "#lib/components/settings/MyAccount.svelte";
|
||||
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { ui } from "#lib/stores/ui.svelte.js";
|
||||
|
||||
const features = [
|
||||
{ icon: "search", label: "Поиск" },
|
||||
{ icon: "folder", label: "Папки" },
|
||||
{ icon: "stats", label: "Presence и аналитика" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="settings">
|
||||
<header class="settings-header">
|
||||
<Button
|
||||
aria-label={t("common.back")}
|
||||
aria-label="Назад"
|
||||
onclick={() => ui.closeSettings()}
|
||||
round
|
||||
smaller
|
||||
@@ -21,63 +25,61 @@
|
||||
>
|
||||
<Icon name="arrow-left" />
|
||||
</Button>
|
||||
<h2>{t("settings.title")}</h2>
|
||||
<h2>Настройки</h2>
|
||||
</header>
|
||||
|
||||
<div class="settings-body custom-scroll">
|
||||
<MyAccount />
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">{t("settings.appearance")}</div>
|
||||
<div class="section-title">Внешний вид</div>
|
||||
<Appearance />
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">{t("settings.language")}</div>
|
||||
<Language />
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<SettingsItem
|
||||
icon="settings"
|
||||
label={t("settings.policy")}
|
||||
label="Политика захвата"
|
||||
onclick={() => ui.openPanel("policy")}
|
||||
/>
|
||||
<SettingsItem
|
||||
icon="cloud-download"
|
||||
label={t("settings.jobs")}
|
||||
label="Бэкфилл и задачи"
|
||||
onclick={() => ui.openPanel("jobs")}
|
||||
/>
|
||||
<SettingsItem
|
||||
icon="data"
|
||||
label={t("settings.storage")}
|
||||
label="Хранилище"
|
||||
onclick={() => ui.openPanel("storage")}
|
||||
/>
|
||||
<SettingsItem
|
||||
icon="play-story"
|
||||
label={t("settings.stories")}
|
||||
label="Сторис"
|
||||
onclick={() => ui.openPanel("stories-all")}
|
||||
/>
|
||||
<SettingsItem
|
||||
icon="allow-share"
|
||||
label={t("settings.shares")}
|
||||
label="Файлы по ссылке"
|
||||
onclick={() => ui.openPanel("shares")}
|
||||
/>
|
||||
<SettingsItem
|
||||
icon="bots"
|
||||
label={t("settings.bots")}
|
||||
label="Боты"
|
||||
onclick={() => ui.openPanel("bots")}
|
||||
/>
|
||||
<SettingsItem
|
||||
icon="eye"
|
||||
label={t("settings.watches")}
|
||||
label="Отслеживания"
|
||||
onclick={() => ui.openPanel("watches")}
|
||||
/>
|
||||
<SettingsItem
|
||||
icon="unmute"
|
||||
label={t("settings.alerts")}
|
||||
label="Алерты"
|
||||
onclick={() => ui.openPanel("alerts")}
|
||||
/>
|
||||
{#each features as feature (feature.label)}
|
||||
<SettingsItem icon={feature.icon} label={feature.label} soon />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<About />
|
||||
|
||||
@@ -6,19 +6,29 @@
|
||||
icon: string;
|
||||
label: string;
|
||||
onclick?: () => void;
|
||||
soon?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let { icon, label, value, onclick }: Props = $props();
|
||||
let { icon, label, value, soon = false, onclick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button class="settings-item" {onclick} type="button" use:ripple>
|
||||
<button
|
||||
class="settings-item"
|
||||
disabled={soon}
|
||||
{onclick}
|
||||
type="button"
|
||||
class:soon
|
||||
use:ripple
|
||||
>
|
||||
<Icon class="leading" name={icon} size="1.5rem" />
|
||||
<span class="label">{label}</span>
|
||||
{#if value}
|
||||
{#if soon}
|
||||
<span class="soon-tag">скоро</span>
|
||||
{:else if value}
|
||||
<span class="value">{value}</span>
|
||||
{/if}
|
||||
{#if onclick}
|
||||
{#if onclick && !soon}
|
||||
<Icon class="chevron" name="arrow-right" size="1rem" />
|
||||
{/if}
|
||||
</button>
|
||||
@@ -43,9 +53,13 @@
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.settings-item .leading) {
|
||||
@@ -64,6 +78,11 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.soon-tag {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
:global(.settings-item .chevron) {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-icon-secondary);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { initials as toInitials } from "#lib/format/peer.js";
|
||||
|
||||
interface Props {
|
||||
avatar?: { account?: number; kind: AvatarKind; id: number } | null;
|
||||
avatar?: { kind: AvatarKind; id: number } | null;
|
||||
colorKey?: number;
|
||||
deleted?: boolean;
|
||||
hasAvatar?: boolean;
|
||||
@@ -41,7 +41,7 @@
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
loadAvatar(avatar.kind, avatar.id, avatar.account).then((resolved) => {
|
||||
loadAvatar(avatar.kind, avatar.id).then((resolved) => {
|
||||
if (active) {
|
||||
url = resolved;
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<script generics="T extends string" lang="ts">
|
||||
import { ripple } from "#lib/actions/ripple.js";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onselect: (value: T) => void;
|
||||
options: Option[];
|
||||
selected: T;
|
||||
}
|
||||
|
||||
let { options, selected, onselect }: Props = $props();
|
||||
const name = $props.id();
|
||||
</script>
|
||||
|
||||
<div class="radio-list">
|
||||
{#each options as option (option.value)}
|
||||
<label class="radio-row" use:ripple>
|
||||
<input
|
||||
checked={selected === option.value}
|
||||
class="input"
|
||||
{name}
|
||||
onchange={() => onselect(option.value)}
|
||||
type="radio"
|
||||
value={option.value}
|
||||
>
|
||||
<span class="dot" class:on={selected === option.value}></span>
|
||||
<span class="label">{option.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.radio-row {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
|
||||
width: 100%;
|
||||
min-height: 3.25rem;
|
||||
padding: 0 1.25rem;
|
||||
border: 0;
|
||||
|
||||
font-size: 1rem;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
|
||||
&:has(.input:focus-visible) {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid var(--color-borders);
|
||||
border-radius: 50%;
|
||||
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&.on {
|
||||
border-width: 0.4375rem;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +1,29 @@
|
||||
import { locale } from "#lib/i18n/locale.svelte.js";
|
||||
|
||||
const OPTIONS = {
|
||||
day: { day: "numeric", month: "short" },
|
||||
full: {
|
||||
const time = new Intl.DateTimeFormat(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const day = new Intl.DateTimeFormat(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
const full = new Intl.DateTimeFormat(undefined, {
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
},
|
||||
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 {
|
||||
return format("time", new Date(iso));
|
||||
return time.format(new Date(iso));
|
||||
}
|
||||
|
||||
export function formatDay(iso: string): string {
|
||||
return format("day", new Date(iso));
|
||||
return day.format(new Date(iso));
|
||||
}
|
||||
|
||||
export function formatFull(iso: string): string {
|
||||
return format("full", new Date(iso));
|
||||
return full.format(new Date(iso));
|
||||
}
|
||||
|
||||
export function formatListDate(iso: string | null): string {
|
||||
@@ -49,28 +33,7 @@ export function formatListDate(iso: string | null): string {
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return format("time", date);
|
||||
return time.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()
|
||||
);
|
||||
return day.format(date);
|
||||
}
|
||||
@@ -1,117 +1,37 @@
|
||||
import type { PresenceSample } from "#lib/api/types.js";
|
||||
import { formatDay, formatTime } from "#lib/format/datetime.js";
|
||||
import { plural } from "#lib/format/shares.js";
|
||||
import { formatListDate } from "#lib/format/datetime.js";
|
||||
|
||||
const SECONDS_IN_MINUTE = 60;
|
||||
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) {
|
||||
export function isOnline(sample: PresenceSample | null): boolean {
|
||||
if (sample === null || sample.status !== "online") {
|
||||
return false;
|
||||
}
|
||||
return new Date(sample.next_offline_date).getTime() > now;
|
||||
if (sample.next_offline_date === null) {
|
||||
return false;
|
||||
}
|
||||
return new Date(sample.next_offline_date).getTime() > Date.now();
|
||||
}
|
||||
|
||||
function lastSeen(iso: string, now: number): string {
|
||||
const date = new Date(iso);
|
||||
const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE);
|
||||
if (minutes < 1) {
|
||||
return TEXT.justNow;
|
||||
}
|
||||
if (minutes < MINUTES_IN_HOUR) {
|
||||
return TEXT.ago(minutes);
|
||||
}
|
||||
const time = formatTime(iso);
|
||||
const today = new Date(now);
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return TEXT.today(time);
|
||||
}
|
||||
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;
|
||||
}
|
||||
export function formatPresence(sample: PresenceSample): string {
|
||||
switch (sample.status) {
|
||||
case "online":
|
||||
return isOnline(sample)
|
||||
? "online"
|
||||
: lastSeen(sample.last_online_date ?? sample.ts);
|
||||
case "recently":
|
||||
return "last seen recently";
|
||||
case "last_week":
|
||||
return "last seen within a week";
|
||||
case "last_month":
|
||||
return "last seen within a month";
|
||||
case "long_time_ago":
|
||||
return "last seen a long time ago";
|
||||
default:
|
||||
return sample.last_online_date
|
||||
? lastSeen(sample.last_online_date, now)
|
||||
: TEXT.offline;
|
||||
? lastSeen(sample.last_online_date)
|
||||
: "offline";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSpan(seconds: number): string {
|
||||
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)}`;
|
||||
function lastSeen(date: string): string {
|
||||
return `last seen ${formatListDate(date)}`;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
@@ -1,49 +0,0 @@
|
||||
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;
|
||||
@@ -1,25 +0,0 @@
|
||||
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;
|
||||
@@ -1,9 +0,0 @@
|
||||
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,21 +5,8 @@ import {
|
||||
} from "#lib/api/endpoints.js";
|
||||
import type { Account } from "#lib/api/types.js";
|
||||
import { browser } from "$app/env";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
const STORAGE_KEY = "bg.account";
|
||||
const LAST_CHAT_KEY = "bg.lastChat";
|
||||
|
||||
function readLastChats(): Record<string, number> {
|
||||
if (!browser) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LAST_CHAT_KEY) ?? "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readSelected(): number | null {
|
||||
if (!browser) {
|
||||
@@ -33,7 +20,6 @@ function createAccounts() {
|
||||
let list = $state<Account[]>([]);
|
||||
let selectedId = $state<number | null>(readSelected());
|
||||
let loaded = $state(false);
|
||||
const lastChats = readLastChats();
|
||||
|
||||
const selected = $derived(
|
||||
list.find((account) => account.account_id === selectedId) ?? null
|
||||
@@ -81,17 +67,6 @@ function createAccounts() {
|
||||
}
|
||||
await load();
|
||||
},
|
||||
rememberChat(chatId: number | null) {
|
||||
if (selectedId === null || !browser) {
|
||||
return;
|
||||
}
|
||||
if (chatId === null) {
|
||||
delete lastChats[selectedId];
|
||||
} else {
|
||||
lastChats[selectedId] = chatId;
|
||||
}
|
||||
localStorage.setItem(LAST_CHAT_KEY, JSON.stringify(lastChats));
|
||||
},
|
||||
async renameDevice(id: number, deviceModel: string) {
|
||||
const updated = await renameAccountDevice(id, deviceModel);
|
||||
list = list.map((account) =>
|
||||
@@ -105,14 +80,6 @@ function createAccounts() {
|
||||
get selectedId() {
|
||||
return selectedId;
|
||||
},
|
||||
switchTo(id: number) {
|
||||
if (id === selectedId) {
|
||||
return;
|
||||
}
|
||||
select(id);
|
||||
const chatId = lastChats[id];
|
||||
goto(chatId === undefined ? "/app" : `/app/${chatId}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -54,9 +54,6 @@ function createChats() {
|
||||
}
|
||||
|
||||
function active(): Bucket | undefined {
|
||||
if (account !== accounts.selectedId) {
|
||||
return;
|
||||
}
|
||||
return buckets[activeKey()];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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();
|
||||
@@ -1,77 +0,0 @@
|
||||
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();
|
||||
@@ -3,7 +3,6 @@ export type RightPanel =
|
||||
| "search"
|
||||
| "versions"
|
||||
| "reactions"
|
||||
| "readers"
|
||||
| "callbacks"
|
||||
| "links"
|
||||
| "annotations"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import "#lib/styles/icons.css";
|
||||
import "#lib/styles/global.scss";
|
||||
import ToastHost from "#lib/components/ui/ToastHost.svelte";
|
||||
import { locale } from "#lib/i18n/locale.svelte.js";
|
||||
import { auth } from "#lib/stores/auth.svelte.js";
|
||||
import { theme } from "#lib/stores/theme.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -17,10 +16,6 @@
|
||||
|
||||
$effect(() => theme.watchSystem());
|
||||
|
||||
$effect(() => {
|
||||
document.documentElement.lang = locale.current;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const next = theme.resolved;
|
||||
const root = document.documentElement;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { fly } from "svelte/transition";
|
||||
import ChatList from "#lib/components/ChatList.svelte";
|
||||
import FolderTabs from "#lib/components/FolderTabs.svelte";
|
||||
import MainMenu from "#lib/components/MainMenu.svelte";
|
||||
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
|
||||
import RightColumn from "#lib/components/RightColumn.svelte";
|
||||
import SearchInput from "#lib/components/search/SearchInput.svelte";
|
||||
@@ -13,7 +11,6 @@
|
||||
import ShareDialog from "#lib/components/shares/ShareDialog.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { auth } from "#lib/stores/auth.svelte.js";
|
||||
import { events } from "#lib/stores/events.svelte.js";
|
||||
@@ -30,32 +27,13 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!accounts.loaded) {
|
||||
accounts.load().catch(() => toasts.error(t("menu.accountsFailed")));
|
||||
accounts.load().catch(() => toasts.error("Failed to load accounts"));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
events.open(auth.token === null ? null : accounts.selectedId);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const chatId = page.params.chatId ? Number(page.params.chatId) : null;
|
||||
untrack(() => accounts.rememberChat(chatId));
|
||||
});
|
||||
|
||||
let shownAccount = accounts.selectedId;
|
||||
|
||||
$effect(() => {
|
||||
const id = accounts.selectedId;
|
||||
if (id === shownAccount) {
|
||||
return;
|
||||
}
|
||||
shownAccount = id;
|
||||
untrack(() => {
|
||||
ui.closePanel();
|
||||
search.close();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -79,7 +57,7 @@
|
||||
<header class="left-header">
|
||||
{#if search.active}
|
||||
<Button
|
||||
aria-label={t("common.back")}
|
||||
aria-label="Назад"
|
||||
onclick={() => search.close()}
|
||||
round
|
||||
smaller
|
||||
@@ -88,7 +66,15 @@
|
||||
<Icon name="arrow-left" />
|
||||
</Button>
|
||||
{:else}
|
||||
<MainMenu />
|
||||
<Button
|
||||
aria-label="Меню"
|
||||
onclick={() => ui.openSettings()}
|
||||
round
|
||||
smaller
|
||||
variant="translucent"
|
||||
>
|
||||
<Icon name="menu" />
|
||||
</Button>
|
||||
{/if}
|
||||
<SearchInput />
|
||||
</header>
|
||||
@@ -154,7 +140,7 @@
|
||||
|
||||
#RightColumn {
|
||||
position: relative;
|
||||
z-index: var(--z-right-column);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#MiddleColumn {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
</script>
|
||||
|
||||
<div class="placeholder">
|
||||
<span class="pill">{t("common.selectChat")}</span>
|
||||
<span class="pill">Select a chat to view its archive</span>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user