Compare commits

..
41 Commits
Author SHA1 Message Date
hh 0dd14c5d5e fix(backend): person overview reads live in their own module, no import cycle with read receipts 2026-10-19 02:57:49 +00:00
hh 1abb396c36 feat(frontend): sender avatar and name in groups open the person panel without leaving the chat 2026-10-19 02:57:49 +00:00
hh 143408cd7f feat(frontend): profile panel about one person with real changes, shared groups, joins and leaves and deleted messages 2026-10-19 02:57:49 +00:00
hh c8d485fe0e fix(backend): migration vacuums profile history so change reads stay index only 2026-10-19 02:57:49 +00:00
hh b86e2627d8 feat(backend): mcp get_person returns everything the archive knows about one person 2026-10-19 02:57:49 +00:00
hh 8f9ceb6af8 feat(backend): person overview with real profile changes, shared groups, joins and leaves, deleted count and group title changes 2026-10-19 02:57:49 +00:00
hh 0c9c848fb1 fix(backend): profile history gets a row only when a field really changed, partial snapshots keep the known phone and username 2026-10-19 02:57:49 +00:00
hh aa41bbbc74 fix(backend): group title changes found by enrichment are recorded, enrichment and syncs skip unchanged profiles 2026-10-19 02:57:49 +00:00
hh efc431c969 fix(backend): watched bot enriches a group at most once in six hours 2026-10-19 02:57:49 +00:00
hh 7d7cf3cbbb feat(backend): history keys cover the profile fields, group snapshots keyed by time, indexes for memberships and job lookups 2026-10-19 02:57:49 +00:00
hh 97ccfbbffd fix(backend): watch times stay in utc for naive telegram dates, webhook outbox survives a database hiccup at start 2026-10-19 02:57:27 +00:00
hh 2c56934133 feat(frontend): live unseen alerts badge on the menu button and in settings 2026-10-19 02:57:27 +00:00
hh ea3a7c0a61 feat(frontend): watches, webhooks and alerts panels read humanly, with chat picker, url filter, test send and delivery log 2026-10-19 02:57:27 +00:00
hh 6a4fd6ad47 feat(backend): watches report alert counts, alerts carry watch params and time away, webhooks their last delivery 2026-10-19 02:57:27 +00:00
hh 9da0c06ac1 feat(backend): webhooks deliver signed alerts from an outbox with retries and a test send 2026-10-19 02:57:27 +00:00
hh 2bb8ca45fb feat(backend): alerts come enriched and paged with an unseen count and a live alert frame 2026-10-19 02:57:27 +00:00
hh 4469938f47 feat(backend): watches fire on new, edited and deleted messages and on coming online, legacy keyword included 2026-10-19 02:57:27 +00:00
hh 8ed9ca1951 fix(frontend): chat list shows its chats on the first load again 2026-10-19 02:57:17 +00:00
hh 73b8903de1 feat(frontend): analytics shows minutes online by hour of week and by day, sessions with start and end, says when last seen is hidden, chat status is live and in russian 2026-10-19 02:41:39 +00:00
hh f44597b2dc feat(backend): presence stats count time online from sessions in the browser time zone, capture uptime marks hours without data, presence reaches every stream of the account, message volume keeps empty days 2026-10-19 02:41:39 +00:00
hh bb38e23f29 feat(frontend): my message menu shows when it was read or listened, group readers open in a right panel 2026-10-19 02:40:49 +00:00
hh f03d7c2890 fix(frontend): right panel on phones stays above the sticky dates of the chat 2026-10-19 02:40:49 +00:00
hh d8dd08d7de feat(backend): read time of my messages from the archive, exact dates and group readers asked from telegram on demand 2026-10-19 02:40:49 +00:00
hh f27943aae6 feat(backend): supergroup read receipts are captured like basic groups 2026-10-19 02:40:30 +00:00
hh d8ad62fb59 fix(frontend): switching accounts never shows the previous account's chats for a moment 2026-10-19 02:33:44 +00:00
hh f5a8649855 perf(backend): message writes look up the stored copy by its date and touch one chunk 2026-10-19 02:29:19 +00:00
hh 23c1504001 feat(userbot): after a restart the userbot catches up on what it missed while down, from a saved cursor and within bounds 2026-10-19 02:29:19 +00:00
hh 92e5e737d2 fix(userbot): presence, story and profile history toggles of the capture policy take effect for each user peer 2026-10-19 02:29:19 +00:00
hh f32b772ddc fix(userbot): a deletion that arrives before its message is applied once the message is stored 2026-10-19 02:29:19 +00:00
hh 516b8e307f fix(backend): a re-captured or bot-edited message keeps its earlier text as a version, an older copy never overwrites a newer edit 2026-10-19 02:29:19 +00:00
hh 33ceea3dfc fix(backend): a bulk deletion is announced in chunks that fit the notify payload limit 2026-10-19 02:29:19 +00:00
hh 4ce9c5d340 fix(backend): a session or bot that fails to start is retried and the failure is logged 2026-10-19 02:29:19 +00:00
hh 42469eb9f1 fix(userbot): job queue survives a consumer failure, a flood wait defers its job instead of holding the queue 2026-10-19 02:29:19 +00:00
hh 3373c31a02 fix(backend): listen connections reconnect after a dropped connection or a postgres restart and catch up on what they missed 2026-10-19 02:29:19 +00:00
hh 17e27beb59 fix(userbot): repeated link rows no longer abort the message insert 2026-10-19 02:29:19 +00:00
hh 54424d0aa0 feat(frontend): browser tab shows the open chat, empty state speaks the chosen language 2026-10-19 02:06:13 +00:00
hh 50922ef69a feat(frontend): installable on the phone with its own icon, title and manifest, missing avatars are asked again later 2026-10-19 02:05:41 +00:00
hh a1d3552519 feat(frontend): main menu lists accounts with their avatars, switching opens the last chat of that account 2026-10-19 02:02:31 +00:00
hh 3460246749 feat(frontend): russian and english interface with a language choice in settings, soon stubs removed 2026-10-19 01:54:34 +00:00
renovate Bot 3d2f670e87 chore(deps): update ghcr.io/astral-sh/uv:python3.13-trixie-slim docker digest to 7dd72af (main) (#20)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-18 04:09:11 +00:00
renovate Bot 305de9f871 chore(deps): pin ghcr.io/astral-sh/uv docker tag to 40215b6 (main) (#15)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-14 04:07:55 +00:00
149 changed files with 11551 additions and 1831 deletions

No files matched your search

+1 -1
View File
@@ -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`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be.
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `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
View File
@@ -25,6 +25,7 @@ BOTS__BACKFILL_PER_CHAT=0
API__HOST=0.0.0.0
API__PORT=8080
API__PUBLIC_URL=
AUTH__TOKEN=
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:7dd72af08bdef012fdf433082190d2b5133a96b6a43aca0bb71c2aa793e47e20
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,51 @@
"""message reads
Revision ID: d7a3c5e9f2b1
Revises: f8c2d6e1a4b7
Create Date: 2026-10-19 01:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d7a3c5e9f2b1"
down_revision: str | None = "f8c2d6e1a4b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_READS = """
CREATE TABLE message_reads (
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
reader_id bigint NOT NULL,
read_at timestamptz NOT NULL,
source text NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, chat_id, message_id, reader_id)
)
"""
_FETCHES = """
CREATE TABLE read_fetches (
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
outcome text NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now(),
retry_at timestamptz NOT NULL,
PRIMARY KEY (account_id, chat_id, message_id)
)
"""
def upgrade() -> None:
op.execute(_READS)
op.execute(_FETCHES)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS read_fetches")
op.execute("DROP TABLE IF EXISTS message_reads")
@@ -0,0 +1,86 @@
"""watch names and webhooks
Revision ID: d8e3b5a1c7f4
Revises: d8f4a1c6e2b7
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d8e3b5a1c7f4"
down_revision: str | None = "d8f4a1c6e2b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_WEBHOOKS = """
CREATE TABLE webhooks (
id serial PRIMARY KEY,
account_id integer NOT NULL,
url text NOT NULL,
secret text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
watch_ids integer[],
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
"""
_DELIVERIES = """
CREATE TABLE webhook_deliveries (
id bigserial PRIMARY KEY,
webhook_id integer NOT NULL REFERENCES webhooks (id) ON DELETE CASCADE,
alert_id integer REFERENCES alerts (id) ON DELETE CASCADE,
event text NOT NULL,
status text NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
response_code integer,
last_error text,
body text,
created_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz
)
"""
_LEGACY_KEYWORD = """
UPDATE watches
SET params = (params - 'keyword') || jsonb_build_object('pattern', params->'keyword')
WHERE kind = 'keyword' AND params ? 'keyword' AND NOT params ? 'pattern'
"""
def upgrade() -> None:
op.execute("ALTER TABLE watches ADD COLUMN name text")
op.execute(_LEGACY_KEYWORD)
op.execute("ALTER TABLE alerts DROP CONSTRAINT alerts_watch_id_fkey")
op.execute(
"ALTER TABLE alerts ADD CONSTRAINT alerts_watch_id_fkey "
"FOREIGN KEY (watch_id) REFERENCES watches (id) ON DELETE CASCADE"
)
op.execute("CREATE INDEX ix_alerts_account_id ON alerts (account_id, id DESC)")
op.execute(_WEBHOOKS)
op.execute("CREATE INDEX ix_webhooks_account ON webhooks (account_id)")
op.execute(_DELIVERIES)
op.execute(
"CREATE INDEX ix_webhook_deliveries_due ON webhook_deliveries "
"(next_attempt_at) WHERE status = 'pending'"
)
op.execute(
"CREATE INDEX ix_webhook_deliveries_webhook ON webhook_deliveries "
"(webhook_id, id DESC)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS webhook_deliveries")
op.execute("DROP TABLE IF EXISTS webhooks")
op.execute("DROP INDEX IF EXISTS ix_alerts_account_id")
op.execute("ALTER TABLE alerts DROP CONSTRAINT alerts_watch_id_fkey")
op.execute(
"ALTER TABLE alerts ADD CONSTRAINT alerts_watch_id_fkey "
"FOREIGN KEY (watch_id) REFERENCES watches (id)"
)
op.execute("ALTER TABLE watches DROP COLUMN name")
@@ -0,0 +1,50 @@
"""capture uptime
Revision ID: d8f4a1c6e2b7
Revises: d7a3c5e9f2b1
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d8f4a1c6e2b7"
down_revision: str | None = "d7a3c5e9f2b1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_UPTIME = """
CREATE TABLE capture_uptime (
account_id integer NOT NULL,
started_at timestamptz NOT NULL,
seen_at timestamptz NOT NULL,
PRIMARY KEY (account_id, started_at)
)
"""
_BACKFILL = """
INSERT INTO capture_uptime (account_id, started_at, seen_at)
SELECT account_id, min(ts), max(ts)
FROM (
SELECT account_id, ts,
count(*) FILTER (WHERE gap > interval '30 minutes')
OVER (PARTITION BY account_id ORDER BY ts) AS stretch
FROM (
SELECT account_id, ts,
ts - lag(ts) OVER (PARTITION BY account_id ORDER BY ts) AS gap
FROM presence
) gaps
) stretches
GROUP BY account_id, stretch
"""
def upgrade() -> None:
op.execute(_UPTIME)
op.execute(_BACKFILL)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS capture_uptime")
@@ -0,0 +1,54 @@
"""profile history reads
Revision ID: e8b3f1a6c2d4
Revises: d8e3b5a1c7f4
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "e8b3f1a6c2d4"
down_revision: str | None = "d8e3b5a1c7f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_PEER_STATE = (
"first_name, last_name, username, phone, photo_unique_id, is_deleted_account"
)
def upgrade() -> None:
op.execute(
"ALTER TABLE peer_history DROP CONSTRAINT peer_history_pkey, "
"ADD CONSTRAINT peer_history_pkey "
f"PRIMARY KEY (account_id, peer_id, observed_at) INCLUDE ({_PEER_STATE})"
)
op.execute(
"ALTER TABLE chat_history DROP CONSTRAINT chat_history_pkey, "
"ADD CONSTRAINT chat_history_pkey "
"PRIMARY KEY (account_id, chat_id, message_id, ts)"
)
op.execute(
"CREATE INDEX ix_memberships_user ON memberships (account_id, user_id, ts DESC)"
)
op.execute(
"CREATE INDEX ix_jobs_kind_created ON jobs (account_id, kind, created_at DESC)"
)
with op.get_context().autocommit_block():
op.execute("VACUUM (ANALYZE) peer_history")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_jobs_kind_created")
op.execute("DROP INDEX IF EXISTS ix_memberships_user")
op.execute(
"ALTER TABLE chat_history DROP CONSTRAINT chat_history_pkey, "
"ADD CONSTRAINT chat_history_pkey PRIMARY KEY (account_id, chat_id, message_id)"
)
op.execute(
"ALTER TABLE peer_history DROP CONSTRAINT peer_history_pkey, "
"ADD CONSTRAINT peer_history_pkey PRIMARY KEY (account_id, peer_id, observed_at)"
)
@@ -0,0 +1,39 @@
"""capture reliability
Revision ID: f8c2d6e1a4b7
Revises: c9e2a7f4b1d8
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "f8c2d6e1a4b7"
down_revision: str | None = "c9e2a7f4b1d8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"jobs", sa.Column("run_after", sa.DateTime(timezone=True), nullable=True)
)
op.execute(
"ALTER TABLE accounts "
"ADD COLUMN catchup_pts integer, "
"ADD COLUMN catchup_qts integer, "
"ADD COLUMN catchup_date integer"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE accounts "
"DROP COLUMN IF EXISTS catchup_pts, "
"DROP COLUMN IF EXISTS catchup_qts, "
"DROP COLUMN IF EXISTS catchup_date"
)
op.drop_column("jobs", "run_after")
+1
View File
@@ -20,6 +20,7 @@ dependencies = [
"dishka>=1.4.0",
"greenlet>=3.1.0",
"fastmcp>=3.3",
"httpx2>=2.13.1",
]
[build-system]
+6
View File
@@ -38,7 +38,9 @@ from api.routers import (
storage,
stories,
watches,
webhooks,
)
from api.webhooks import outbox
from dependencies.container import container
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
from utils.env import env
@@ -58,11 +60,14 @@ mcp_app = mcp.http_app(path="/")
@asynccontextmanager
async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
pool = await container.get(asyncpg.Pool)
hub.alert_listeners.append(outbox.wake)
await hub.start(pool)
purger = asyncio.create_task(
purge_loop(pool, await container.get(ContentAddressedStorage))
)
deliverer = asyncio.create_task(outbox.run(pool))
yield
deliverer.cancel()
purger.cancel()
await hub.stop()
await app_.state.dishka_container.close()
@@ -103,6 +108,7 @@ app.include_router(peers.router)
app.include_router(discover.router)
app.include_router(annotations.router)
app.include_router(watches.router)
app.include_router(webhooks.router)
app.include_router(shares.router)
app.include_router(storage.router)
app.include_router(files.router)
+124 -13
View File
@@ -20,12 +20,25 @@ 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, social, watches
from utils.read import (
annotations,
chats,
media,
peers,
people,
presence,
presence_stats,
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
from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize_params
INSTRUCTIONS = """\
beavergram archives Telegram data (chats, messages, media, presence, stories,
@@ -58,6 +71,8 @@ Typical flows:
as private notes from the user to you, not as chat content.
- Find something: `search_messages_tool` (full-text over message text and STT
transcripts; supports chat/sender/date filters and regex).
- People: `get_person` gives one person in full: profile changes, shared groups,
joins and leaves, deleted message count, the private chat.
- Forensics: `get_deleted_messages` and `get_message_versions` recover content
removed or edited in Telegram but kept in the archive.
- Files: `download_media` gets any message attachment (photo, video, voice,
@@ -65,8 +80,10 @@ Typical flows:
returns a public link valid 24h that needs no auth (`curl -O <link>`), plus
the content itself for small images and text files. `get_media` returns
metadata only.
- Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and
`list_alerts` reads what those rules fired.
- Monitoring: `set_watch` creates a local rule (kinds `keyword`,
`new_message`, `message_deleted`, `message_edited`, `peer_online`; params in
its description), `list_watches` lists rules, and `list_alerts` reads what
those rules fired, with chat, message preview and a web client `path`.
Watched bots:
- a bot whose token the user added is just another archived account: it has its
@@ -295,6 +312,23 @@ 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,
@@ -317,12 +351,58 @@ 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."""
return _dump(await peers.get_peer_history(await _pool(), account_id, peer_id))
@mcp.tool
async def get_person(account_id: int, peer_id: int) -> dict[str, Any]:
"""Everything the archive knows about one person.
Current profile, name/username/phone changes with times (newest first),
avatar history, groups shared in the archive with their message counts and
last message, joins and leaves with who added or removed them, the number
of their deleted messages, and the private chat if there is one.
"""
pool = await _pool()
overview = await people.get_overview(pool, account_id, peer_id)
groups = await people.get_groups(pool, account_id, peer_id)
return {**overview.model_dump(mode="json"), **groups.model_dump(mode="json")}
@mcp.tool
async def get_stories(
account_id: int,
@@ -361,13 +441,37 @@ async def get_annotations(
@mcp.tool
async def set_watch(
account_id: int,
kind: str,
kind: WatchKind,
params: dict[str, Any] | None = None,
name: str | None = None,
enabled: bool = True,
) -> dict[str, Any]:
"""Create a local watch rule (the only local write MCP is allowed)."""
"""Create a local watch rule (the only local write MCP is allowed).
A watch fires an alert into the web client (live counter and list) and to
the user's webhooks. The user's own messages never fire. Kinds and params:
- `keyword`: `{"pattern": str, "regex": bool = false, "chat_id"?: int,
"sender_id"?: int}`. Case-insensitive substring, or a Python regex when
`regex` is true (at most 200 characters, no nested repeats like
`(a+)+`). Checked on new messages, on edits (the new text) and on voice
transcripts. Without `chat_id` it watches every chat.
- `new_message`: `{"chat_id"?: int, "sender_id"?: int}`, at least one: any
new message in that chat, or from that person anywhere, or both.
- `message_deleted`: `{"chat_id"?: int}`. A stored message deleted in that
chat; without `chat_id`, in any private chat.
- `message_edited`: `{"chat_id"?: int}`. Same scopes as `message_deleted`.
- `peer_online`: `{"peer_id": int}`. The person comes online after at
least 15 minutes away.
`name` is an optional human label shown in the list and in alerts.
"""
try:
clean = normalize_params(kind, params or {})
except WatchParamsError as exc:
raise ToolError(str(exc)) from exc
watch = await watches.create_watch(
await _pool(), account_id, kind, params or {}, enabled=enabled
await _pool(), account_id, kind, clean, name=clean_name(name), enabled=enabled
)
return watch.model_dump(mode="json")
@@ -382,12 +486,19 @@ async def list_watches(account_id: int) -> list[dict[str, Any]]:
async def list_alerts(
account_id: int,
seen: bool | None = None,
before: int | None = None,
limit: int = DEFAULT_LIMIT,
offset: int = 0,
) -> list[dict[str, Any]]:
"""List fired alerts from watch rules."""
return _dump(
await watches.list_alerts(
await _pool(), account_id, Page(limit=limit, offset=offset), seen=seen
)
) -> dict[str, Any]:
"""List fired alerts from watch rules, newest first.
Each alert names its watch (id, kind, name), the event (`message`, `edit`,
`transcript`, `delete`, `online`), the chat, the message preview with its
sender and deleted/edited flags, the peer for `peer_online`, and `path`,
the web client link. `seen=false` keeps unseen ones. Page older with
`before` set to `next_before` from the previous call; `unseen` is the
account's unseen count.
"""
page = await watches.list_alerts(
await _pool(), account_id, seen=seen, before=before, limit=limit
)
return page.model_dump(mode="json")
+54 -19
View File
@@ -1,14 +1,19 @@
import asyncio
import json
import logging
from typing import Any
from datetime import UTC, datetime
from typing import TYPE_CHECKING, 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
from utils.read import watches as watches_read
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
@@ -26,8 +31,15 @@ class EventHub:
def __init__(self) -> None:
self._subscribers: set[Subscriber] = set()
self._pool: asyncpg.Pool | None = None
self._conn: asyncpg.Connection | None = None
self._listener = Listener(
"realtime",
{
BG_EVENTS_CHANNEL: self._on_notify,
watches_read.ALERTS_CHANGED_CHANNEL: self._on_alert,
},
)
self._tasks: set[asyncio.Task] = set()
self.alert_listeners: list[Callable[[], None]] = []
def subscribe(self, account_id: int, chat_id: int | None) -> Subscriber:
sub = Subscriber(account_id, chat_id)
@@ -39,25 +51,51 @@ class EventHub:
async def start(self, pool: asyncpg.Pool) -> None:
self._pool = pool
conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener(BG_EVENTS_CHANNEL, self._on_notify)
self._conn = conn
logger.info("Realtime hub listening on %s", BG_EVENTS_CHANNEL)
await self._listener.start()
async def stop(self) -> None:
for task in self._tasks:
task.cancel()
if self._conn is not None:
await self._conn.close()
self._conn = None
await self._listener.stop()
def _on_notify(
self, _conn: asyncpg.Connection, _pid: int, _channel: str, payload: str
) -> None:
def _on_notify(self, payload: str) -> None:
task = asyncio.create_task(self._dispatch(payload))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
def _on_alert(self, payload: str) -> None:
for listener in self.alert_listeners:
listener()
task = asyncio.create_task(self._dispatch_alert(payload))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _dispatch_alert(self, payload: str) -> None:
try:
event = json.loads(payload)
except json.JSONDecodeError:
return
account_id = event.get("account_id")
targets = [
sub
for sub in self._subscribers
if sub.account_id == account_id and sub.chat_id is None
]
if not targets or self._pool is None:
return
alert_id = event.get("alert_id")
item = await watches_read.get_alert(self._pool, alert_id) if alert_id else None
frame = {
"type": "alert",
"alert": item.model_dump(mode="json") if item else None,
"unseen": await watches_read.unseen_count(self._pool, account_id),
}
for sub in targets:
try:
sub.queue.put_nowait(frame)
except asyncio.QueueFull:
logger.warning("Dropping alert for slow subscriber")
async def _dispatch(self, payload: str) -> None:
try:
event = json.loads(payload)
@@ -65,16 +103,12 @@ class EventHub:
return
account_id = event.get("account_id")
chat_id = event.get("chat_id")
scoped = event.get("kind") == "presence"
account_wide = event.get("kind") == "presence"
targets = [
sub
for sub in self._subscribers
if sub.account_id == account_id
and (
sub.chat_id == chat_id
if scoped
else sub.chat_id is None or sub.chat_id == chat_id
)
and (account_wide or sub.chat_id is None or sub.chat_id == chat_id)
]
if not targets:
return
@@ -121,6 +155,7 @@ class EventHub:
"type": "receipt",
"chat_id": event["chat_id"],
"read_up_to": event["message_id"],
"read_at": datetime.now(UTC).isoformat(),
}
return None
+7 -2
View File
@@ -4,8 +4,10 @@ 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)
@@ -18,9 +20,12 @@ async def volume(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
chat_id: ChatId,
days: Annotated[int, Query()] = 90,
days: Annotated[int, Query(ge=1, le=MAX_DAYS)] = 90,
tz: Annotated[str, Query()] = DEFAULT_TZ,
) -> list[VolumeBucket]:
return await analytics.message_volume(pool, account_id, chat_id, days=days)
return await analytics.message_volume(
pool, account_id, chat_id, days=days, tz=await checked_tz(pool, tz)
)
@router.get("/response-time")
+29 -2
View File
@@ -4,8 +4,14 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from utils.read import peers
from utils.read.models import PeerHistoryView, PeerView
from utils.read import peers, people
from utils.read.models import (
PeerGroups,
PeerHistoryView,
PeerOverview,
PeerView,
ProfileChange,
)
router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute)
@@ -35,3 +41,24 @@ async def peer_history(
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
) -> list[PeerHistoryView]:
return await peers.get_peer_history(pool, account_id, peer_id)
@router.get("/peers/{peer_id}/overview")
async def peer_overview(
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
) -> PeerOverview:
return await people.get_overview(pool, account_id, peer_id)
@router.get("/peers/{peer_id}/groups")
async def peer_groups(
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
) -> PeerGroups:
return await people.get_groups(pool, account_id, peer_id)
@router.get("/chats/{chat_id}/changes")
async def chat_changes(
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
) -> list[ProfileChange]:
return await people.get_chat_changes(pool, account_id, chat_id)
+53 -2
View File
@@ -5,8 +5,17 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from utils.read import presence
from utils.read.models import DEFAULT_LIMIT, Page, PresenceHourly, PresenceSample
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
router = APIRouter(prefix="/api/presence", tags=["presence"], route_class=DishkaRoute)
@@ -14,6 +23,8 @@ 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("")
@@ -54,3 +65,43 @@ 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),
)
+35 -2
View File
@@ -2,10 +2,13 @@ from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from utils.jobs import enqueue
from utils.read import social
from utils.read.models import CallbackView, LinkView, ReactionView
from utils.read.models import CallbackView, LinkView, ReactionView, ReadInfoView
from utils.read.read_receipts import read_info
router = APIRouter(
prefix="/api/messages/{chat_id}/{message_id}",
@@ -16,6 +19,10 @@ 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
@@ -35,3 +42,29 @@ 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}
+69 -21
View File
@@ -6,55 +6,88 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from utils.read import watches
from utils.read.models import DEFAULT_LIMIT, AlertView, Page, WatchView
from utils.read.models import AlertPage, WatchItem, WatchView
from utils.watch_rules import WatchParamsError, clean_name, normalize_params
router = APIRouter(prefix="/api", tags=["watches"], route_class=DishkaRoute)
class WatchCreate(BaseModel):
class WatchUpdate(BaseModel):
kind: str | None = None
params: dict[str, Any] = {}
name: str | None = None
enabled: bool = True
class WatchCreate(WatchUpdate):
account_id: int
kind: str
params: dict[str, Any] = {}
enabled: bool = True
class WatchUpdate(BaseModel):
params: dict[str, Any] = {}
enabled: bool = True
class SeenAll(BaseModel):
account_id: int
up_to: int | None = None
def _params(kind: str, params: dict[str, Any]) -> dict[str, Any]:
try:
return normalize_params(kind, params)
except WatchParamsError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
async def _item(pool: asyncpg.Pool, watch: WatchView) -> WatchItem:
return (await watches.describe_watches(pool, watch.account_id, [watch]))[0]
@router.get("/watches")
async def list_watches(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[WatchView]:
return await watches.list_watches(pool, account_id)
) -> list[WatchItem]:
rows = await watches.list_watches(pool, account_id)
return await watches.describe_watches(pool, account_id, rows)
@router.post("/watches", status_code=201)
async def create_watch(pool: FromDishka[asyncpg.Pool], body: WatchCreate) -> WatchView:
return await watches.create_watch(
pool, body.account_id, body.kind, body.params, enabled=body.enabled
async def create_watch(pool: FromDishka[asyncpg.Pool], body: WatchCreate) -> WatchItem:
watch = await watches.create_watch(
pool,
body.account_id,
body.kind,
_params(body.kind, body.params),
name=clean_name(body.name),
enabled=body.enabled,
)
return await _item(pool, watch)
@router.get("/watches/{watch_id}")
async def get_watch(pool: FromDishka[asyncpg.Pool], watch_id: int) -> WatchView:
async def get_watch(pool: FromDishka[asyncpg.Pool], watch_id: int) -> WatchItem:
watch = await watches.get_watch(pool, watch_id)
if watch is None:
raise HTTPException(status_code=404, detail="watch not found")
return watch
return await _item(pool, watch)
@router.put("/watches/{watch_id}")
async def update_watch(
pool: FromDishka[asyncpg.Pool], watch_id: int, body: WatchUpdate
) -> WatchView:
) -> WatchItem:
current = await watches.get_watch(pool, watch_id)
if current is None:
raise HTTPException(status_code=404, detail="watch not found")
kind = body.kind or current.kind
watch = await watches.update_watch(
pool, watch_id, body.params, enabled=body.enabled
pool,
watch_id,
kind,
_params(kind, body.params),
name=clean_name(body.name),
enabled=body.enabled,
)
if watch is None:
raise HTTPException(status_code=404, detail="watch not found")
return watch
return await _item(pool, watch)
@router.delete("/watches/{watch_id}", status_code=204)
@@ -68,14 +101,29 @@ async def list_alerts(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
seen: Annotated[bool | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[AlertView]:
before: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = watches.ALERT_PAGE,
) -> AlertPage:
return await watches.list_alerts(
pool, account_id, Page(limit=limit, offset=offset), seen=seen
pool, account_id, seen=seen, before=before, limit=limit
)
@router.get("/alerts/unseen")
async def unseen_alerts(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> dict[str, int]:
return {"unseen": await watches.unseen_count(pool, account_id)}
@router.post("/alerts/seen")
async def mark_all_seen(
pool: FromDishka[asyncpg.Pool], body: SeenAll
) -> dict[str, int]:
marked = await watches.mark_all_seen(pool, body.account_id, body.up_to)
return {"marked": marked}
@router.post("/alerts/{alert_id}/seen", status_code=204)
async def mark_alert_seen(pool: FromDishka[asyncpg.Pool], alert_id: int) -> None:
if not await watches.mark_alert_seen(pool, alert_id):
+121
View File
@@ -0,0 +1,121 @@
from typing import Annotated
from urllib.parse import urlsplit
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from api.webhooks import send_test
from utils.read import webhooks
from utils.read.models import DeliveryView, WebhookView
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"], route_class=DishkaRoute)
MAX_URL = 2048
class WebhookUpdate(BaseModel):
url: str
watch_ids: list[int] | None = None
enabled: bool = True
class WebhookCreate(WebhookUpdate):
account_id: int
def _url(value: str) -> str:
url = value.strip()
parts = urlsplit(url)
if (
len(url) > MAX_URL
or parts.scheme not in {"http", "https"}
or not parts.hostname
):
raise HTTPException(
status_code=422, detail="url must be an http or https address"
)
return url
def _watch_ids(ids: list[int] | None) -> list[int] | None:
return sorted(set(ids)) if ids else None
async def _webhook(pool: asyncpg.Pool, webhook_id: int) -> WebhookView:
webhook = await webhooks.get_webhook(pool, webhook_id)
if webhook is None:
raise HTTPException(status_code=404, detail="webhook not found")
return webhook
@router.get("")
async def list_webhooks(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[WebhookView]:
return await webhooks.list_webhooks(pool, account_id)
@router.post("", status_code=201)
async def create_webhook(
pool: FromDishka[asyncpg.Pool], body: WebhookCreate
) -> WebhookView:
webhook = await webhooks.create_webhook(
pool,
body.account_id,
_url(body.url),
_watch_ids(body.watch_ids),
enabled=body.enabled,
)
if webhook is None:
raise HTTPException(status_code=500, detail="webhook was not saved")
return webhook
@router.put("/{webhook_id}")
async def update_webhook(
pool: FromDishka[asyncpg.Pool], webhook_id: int, body: WebhookUpdate
) -> WebhookView:
webhook = await webhooks.update_webhook(
pool,
webhook_id,
_url(body.url),
_watch_ids(body.watch_ids),
enabled=body.enabled,
)
if webhook is None:
raise HTTPException(status_code=404, detail="webhook not found")
return webhook
@router.delete("/{webhook_id}", status_code=204)
async def delete_webhook(pool: FromDishka[asyncpg.Pool], webhook_id: int) -> None:
if not await webhooks.delete_webhook(pool, webhook_id):
raise HTTPException(status_code=404, detail="webhook not found")
@router.post("/{webhook_id}/secret")
async def rotate_secret(pool: FromDishka[asyncpg.Pool], webhook_id: int) -> WebhookView:
webhook = await webhooks.rotate_secret(pool, webhook_id)
if webhook is None:
raise HTTPException(status_code=404, detail="webhook not found")
return webhook
@router.post("/{webhook_id}/test")
async def test_webhook(pool: FromDishka[asyncpg.Pool], webhook_id: int) -> DeliveryView:
delivery = await send_test(pool, await _webhook(pool, webhook_id))
if delivery is None:
raise HTTPException(status_code=500, detail="test delivery was not recorded")
return delivery
@router.get("/{webhook_id}/deliveries")
async def list_deliveries(
pool: FromDishka[asyncpg.Pool],
webhook_id: int,
limit: Annotated[int, Query(ge=1, le=100)] = webhooks.DELIVERY_LOG,
) -> list[DeliveryView]:
await _webhook(pool, webhook_id)
return await webhooks.list_deliveries(pool, webhook_id, limit)
+10
View File
@@ -0,0 +1,10 @@
import asyncpg
from fastapi import HTTPException
from utils.read.period import known_timezone
async def checked_tz(pool: asyncpg.Pool, tz: str) -> str:
if not await known_timezone(pool, tz):
raise HTTPException(status_code=422, detail=f"Unknown time zone: {tz}")
return tz
+191
View File
@@ -0,0 +1,191 @@
import asyncio
import contextlib
import hashlib
import hmac
import json
import logging
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
import asyncpg
import httpx2
from utils.env import env
from utils.read import watches
from utils.read import webhooks as webhooks_read
from utils.read.models import AlertItem, DeliveryView, WebhookView
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 10
SCAN_SECONDS = 30
BATCH = 20
RETRY_DELAYS = (30, 90, 270, 810, 2430)
ERROR_CHARS = 300
USER_AGENT = "beavergram-webhooks"
def sign(secret: str, body: bytes) -> str:
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
def _encode(body: dict[str, Any]) -> str:
return json.dumps(body, ensure_ascii=False, separators=(",", ":"))
def _with_url(body: dict[str, Any], path: str) -> dict[str, Any]:
if env.api.public_url:
body["url"] = env.api.public_url.rstrip("/") + path
return body
def alert_body(item: AlertItem) -> dict[str, Any]:
data = item.model_dump(mode="json", exclude={"id", "seen"})
return _with_url({"type": "alert", "alert_id": item.id, **data}, item.path)
def test_body(webhook: WebhookView) -> dict[str, Any]:
body = {
"type": "test",
"alert_id": None,
"account_id": webhook.account_id,
"ts": datetime.now(UTC).isoformat(),
"event": "test",
"watch": None,
"chat": None,
"message": None,
"peer": None,
"path": "/app",
}
return _with_url(body, "/app")
@dataclass(frozen=True, slots=True)
class Target:
delivery_id: int
event: str
url: str
secret: str
async def post(
client: httpx2.AsyncClient, target: Target, body: str
) -> tuple[int | None, str | None]:
raw = body.encode()
headers = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"X-Beavergram-Event": target.event,
"X-Beavergram-Delivery": str(target.delivery_id),
"X-Beavergram-Signature": sign(target.secret, raw),
}
try:
response = await client.post(target.url, content=raw, headers=headers)
except httpx2.HTTPError as exc:
return None, (str(exc) or type(exc).__name__)[:ERROR_CHARS]
if response.is_success:
return response.status_code, None
reason = response.text.strip()[:ERROR_CHARS] or response.reason_phrase
return response.status_code, reason or f"HTTP {response.status_code}"
def new_client() -> httpx2.AsyncClient:
return httpx2.AsyncClient(timeout=TIMEOUT_SECONDS, follow_redirects=False)
async def _body(pool: asyncpg.Pool, row: asyncpg.Record) -> str | None:
if row["body"] is not None:
return row["body"]
if row["alert_id"] is None:
return None
item = await watches.get_alert(pool, row["alert_id"])
if item is None:
return None
body = _encode(alert_body(item))
await webhooks_read.save_body(pool, row["id"], body)
return body
async def _deliver(
pool: asyncpg.Pool, client: httpx2.AsyncClient, row: asyncpg.Record
) -> None:
body = await _body(pool, row)
if body is None:
await webhooks_read.finish(
pool, row["id"], webhooks_read.STATUS_FAILED, None, "alert is gone"
)
return
target = Target(row["id"], row["event"], row["url"], row["secret"])
code, error = await post(client, target, body)
if error is None:
await webhooks_read.finish(
pool, row["id"], webhooks_read.STATUS_DELIVERED, code, None
)
return
attempt = row["attempts"]
if attempt > len(RETRY_DELAYS):
await webhooks_read.finish(
pool, row["id"], webhooks_read.STATUS_FAILED, code, error
)
return
retry_at = datetime.now(UTC) + timedelta(seconds=RETRY_DELAYS[attempt - 1])
await webhooks_read.finish(
pool, row["id"], webhooks_read.STATUS_PENDING, code, error, retry_at
)
async def send_test(pool: asyncpg.Pool, webhook: WebhookView) -> DeliveryView | None:
body = _encode(test_body(webhook))
delivery_id = await webhooks_read.start_test(pool, webhook.id, body)
async with new_client() as client:
target = Target(delivery_id, "test", webhook.url, webhook.secret)
code, error = await post(client, target, body)
status = webhooks_read.STATUS_FAILED if error else webhooks_read.STATUS_DELIVERED
await webhooks_read.finish(pool, delivery_id, status, code, error)
return await webhooks_read.get_delivery(pool, delivery_id)
class Outbox:
def __init__(self) -> None:
self._wake = asyncio.Event()
def wake(self) -> None:
self._wake.set()
async def run(self, pool: asyncpg.Pool) -> None:
released = False
async with new_client() as client:
while True:
self._wake.clear()
try:
if not released:
await webhooks_read.release_stuck(pool)
released = True
await self._drain(pool, client)
delay = await self._delay(pool)
except Exception:
logger.exception("Webhook delivery failed")
delay = SCAN_SECONDS
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._wake.wait(), timeout=delay)
async def _drain(self, pool: asyncpg.Pool, client: httpx2.AsyncClient) -> None:
while rows := await webhooks_read.claim_due(pool, BATCH):
results = await asyncio.gather(
*(_deliver(pool, client, row) for row in rows), return_exceptions=True
)
for result in results:
if isinstance(result, Exception):
logger.error("Webhook delivery crashed: %r", result)
async def _delay(self, pool: asyncpg.Pool) -> float:
due = await webhooks_read.next_due(pool)
if due is None:
return SCAN_SECONDS
wait = (due - datetime.now(UTC)).total_seconds()
return min(SCAN_SECONDS, max(wait, 1.0))
outbox = Outbox()
+5
View File
@@ -20,11 +20,16 @@ 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 ctx.watches.on_deleted(None, box)
await notify_bg_event(ctx.pool, "delete", ctx.account_id, message_ids=box)
for chat_id, ids in channels.items():
await repository.mark_deleted_channel(ctx.pool, ctx.account_id, chat_id, ids)
await ctx.watches.on_deleted(chat_id, ids)
await notify_bg_event(
ctx.pool, "delete", ctx.account_id, chat_id=chat_id, message_ids=ids
)
+5 -15
View File
@@ -2,10 +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 sender_id
from userbot.modules.capture.message import message_row
from userbot.modules.client import PyroClient
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
from userbot.modules.raw_json import raw_json
from userbot.modules.media import capture_media
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event
@@ -23,22 +23,12 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
chat = message.chat
chat_id = chat.id or 0
meta = meta_from_chat(chat, ctx.contacts.ids)
await ctx.watches.on_edit(seen_message(chat_id, message))
toggles = ctx.resolve(meta)
if not toggles.track_edits_deletes:
return
changed = await repository.add_version(
ctx.pool,
ctx.account_id,
chat_id,
message.id,
message.date,
sender_id(message),
message.text or message.caption,
raw_json(message),
message.edit_date,
media_unique_id(message),
has_media=message.media is not None,
is_self_destruct=self_destruct_ttl(message) is not None,
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
)
if not changed:
return
+2 -1
View File
@@ -5,6 +5,7 @@ from userbot.modules.capture.chat_meta import meta_from_chat
from userbot.modules.client import PyroClient
from userbot.modules.stt import is_transcribable
from userbot.modules.stt.gate import safe_transcribe
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event
@@ -20,7 +21,7 @@ async def on_message(client: PyroClient, message: Message) -> None:
):
return
meta = meta_from_chat(message.chat, ctx.contacts.ids)
await ctx.watches.on_text(meta.chat_id, message.id, message.text or message.caption)
await ctx.watches.on_message(seen_message(meta.chat_id, message))
toggles = ctx.resolve(meta)
if not toggles.messages:
return
+15 -12
View File
@@ -13,18 +13,21 @@ async def on_user_status(client: PyroClient, user: User) -> None:
ctx = client.capture
if ctx is None or user.status is None:
return
await repository.insert_status(
ctx.pool,
ctx.account_id,
user.id,
datetime.now(UTC),
user.status.name.lower(),
user.last_online_date,
user.next_offline_date,
raw_json(user.raw),
)
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)
at = datetime.now(UTC)
status = user.status.name.lower()
if ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).presence:
await repository.insert_status(
ctx.pool,
ctx.account_id,
user.id,
at,
status,
user.last_online_date,
user.next_offline_date,
raw_json(user.raw),
)
await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id)
await ctx.watches.on_status(user.id, status, at, user.last_online_date)
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
+15 -2
View File
@@ -25,8 +25,14 @@ async def _handle_user(
current = await get_peer(ctx.pool, ctx.account_id, update.user_id)
if current == fields:
return
is_bot = bool(getattr(raw_user, "bot", False))
await write_profile(
ctx.pool, ctx.account_id, update.user_id, fields, raw_json(raw_user)
ctx.pool,
ctx.account_id,
update.user_id,
fields,
raw_json(raw_user),
history=ctx.resolve_peer(update.user_id, is_bot=is_bot).profile_history,
)
changed_photo = current is None or current.photo_unique_id != photo_unique_id
if photo_file_id and photo_unique_id and changed_photo:
@@ -51,7 +57,14 @@ async def _merge_partial(
merged = replace(current or ProfileFields(), **overrides)
if merged == current:
return
await write_profile(ctx.pool, ctx.account_id, peer_id, merged, raw_str)
await write_profile(
ctx.pool,
ctx.account_id,
peer_id,
merged,
raw_str,
history=ctx.resolve_peer(peer_id).profile_history,
)
async def handle(
@@ -7,25 +7,35 @@ 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,)
HANDLES = (raw.types.UpdateReadHistoryOutbox, raw.types.UpdateReadChannelOutbox)
def _watermark(update: raw.base.Update) -> tuple[int, int] | None:
if isinstance(update, raw.types.UpdateReadHistoryOutbox):
return utils.get_peer_id(update.peer), update.max_id
if isinstance(update, raw.types.UpdateReadChannelOutbox):
peer = raw.types.PeerChannel(channel_id=update.channel_id)
return utils.get_peer_id(peer), update.max_id
return None
async def handle(
client: PyroClient, update: raw.base.Update, _users: dict, _chats: dict
) -> None:
ctx = client.capture
if ctx is None or not isinstance(update, raw.types.UpdateReadHistoryOutbox):
watermark = _watermark(update)
if ctx is None or watermark is None:
return
chat_id = utils.get_peer_id(update.peer)
chat_id, max_id = watermark
await repository.insert_read(
ctx.pool,
ctx.account_id,
chat_id,
chat_id,
datetime.now(UTC),
update.max_id,
max_id,
raw_json(update),
)
await notify_bg_event(
ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=update.max_id
ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=max_id
)
@@ -20,4 +20,4 @@ async def handle(
await repository.set_extracted_text(
ctx.pool, ctx.account_id, meta.chat_id, update.msg_id, update.text
)
await ctx.watches.on_text(meta.chat_id, update.msg_id, update.text)
await ctx.watches.on_transcript(meta.chat_id, update.msg_id, update.text)
+8 -3
View File
@@ -1,14 +1,19 @@
from pyrogram.types import Story
from userbot.modules.client import PyroClient
from userbot.modules.stories.service import save_story
from userbot.modules.stories.service import save_story, story_peer_id
@PyroClient.on_story()
async def on_story(client: PyroClient, story: Story) -> None:
if client.capture is None:
ctx = client.capture
if ctx is None:
return
await save_story(client, client.capture, story)
peer_id = story_peer_id(story)
is_bot = bool(story.from_user and story.from_user.is_bot)
if peer_id > 0 and not ctx.resolve_peer(peer_id, is_bot=is_bot).stories:
return
await save_story(client, ctx, story)
handlers = on_story.handlers # ty: ignore[unresolved-attribute]
+13 -1
View File
@@ -2,10 +2,11 @@ import asyncpg
from pyrogram import Client
from userbot.modules.capture.identity import ChatMetaCache, PeerIdentityCache
from userbot.modules.capture.pending import PendingDeletes
from userbot.modules.contacts import ContactCache
from userbot.modules.folders import FolderCache
from userbot.modules.watches import WatchCache
from utils.policy.models import CaptureToggles, ChatMeta, PolicySet
from utils.policy.models import CaptureToggles, ChatKind, ChatMeta, PolicySet
from utils.policy.repository import load_policy_set
from utils.policy.resolver import resolve
from utils.storage import ContentAddressedStorage
@@ -28,6 +29,7 @@ class CaptureContext:
self.watches = WatchCache(pool, account_id)
self.peer_identity = PeerIdentityCache()
self.chat_meta = ChatMetaCache()
self.pending_deletes = PendingDeletes()
self.policies = PolicySet()
async def reload_policies(self) -> None:
@@ -36,6 +38,16 @@ class CaptureContext:
def resolve(self, chat: ChatMeta) -> CaptureToggles:
return resolve(chat, self.folders.folders, self.policies)
def resolve_peer(self, peer_id: int, *, is_bot: bool = False) -> CaptureToggles:
return self.resolve(
ChatMeta(
chat_id=peer_id,
kind=ChatKind.DM,
is_bot=is_bot,
is_contact=peer_id in self.contacts.ids,
)
)
async def build_capture_context(
client: Client,
@@ -58,7 +58,14 @@ async def _capture_peer(message: Message, ctx: CaptureContext) -> None:
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields):
return
await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
await write_profile(
ctx.pool,
ctx.account_id,
user.id,
fields,
raw_json(user),
history=ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).profile_history,
)
if photo_file_id and photo_unique_id:
from userbot.modules.avatars import note_avatar # noqa: PLC0415
@@ -82,17 +89,15 @@ async def _capture_chat(message: Message, ctx: CaptureContext) -> None:
meta = (chat.title, photo_unique_id)
if not await ctx.chat_meta.changed(ctx.pool, ctx.account_id, chat.id, meta):
return
from userbot.modules.groups.repository import insert_chat_history # noqa: PLC0415
from userbot.modules.groups.repository import record_chat_meta # noqa: PLC0415
await insert_chat_history(
await record_chat_meta(
ctx.pool,
ctx.account_id,
chat.id,
message.id,
"meta",
chat.title,
photo_unique_id,
None,
message.date,
raw_json(message),
)
+27 -12
View File
@@ -1,11 +1,14 @@
from datetime import datetime
from pyrogram import Client
from pyrogram.types import Message
from userbot.modules.capture import repository
from userbot.modules.capture.context import CaptureContext
from userbot.modules.capture.repository import MessageRow
from userbot.modules.links import extract_links
from userbot.modules.links import repository as links_repository
from userbot.modules.media import capture_media, self_destruct_ttl
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
from userbot.modules.raw_json import raw_json
from utils.policy.models import CaptureToggles
@@ -34,27 +37,39 @@ def callbacks(message: Message) -> list[tuple[int, str | None, bytes | None]]:
return buttons
def message_row(message: Message, chat_id: int, date: datetime) -> MessageRow:
return MessageRow(
chat_id=chat_id,
message_id=message.id,
date=date,
sender_id=sender_id(message),
text=message.text or message.caption,
raw=raw_json(message),
has_media=message.media is not None,
is_self_destruct=self_destruct_ttl(message) is not None,
edit_date=message.edit_date,
media_unique_id=media_unique_id(message),
)
async def capture_message(
client: Client, message: Message, ctx: CaptureContext, toggles: CaptureToggles
) -> None:
if message.empty or message.chat is None or message.date is None:
return
chat_id = message.chat.id or 0
await repository.upsert_message(
ctx.pool,
ctx.account_id,
chat_id,
message.id,
message.date,
sender_id(message),
message.text or message.caption,
raw_json(message),
has_media=message.media is not None,
is_self_destruct=self_destruct_ttl(message) is not None,
current = await repository.upsert_message(
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
)
if ctx.pending_deletes.take(chat_id, message.id):
await repository.mark_deleted_late(
ctx.pool, ctx.account_id, chat_id, message.id, message.date
)
from userbot.modules.capture.identity import capture_identity # noqa: PLC0415
await capture_identity(message, ctx)
if not current:
return
await capture_media(client, message, ctx, chat_id, message.id, toggles)
buttons = callbacks(message)
if buttons:
@@ -0,0 +1,36 @@
import time
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
PENDING_DELETE_SECONDS = 120.0
PENDING_DELETE_LIMIT = 10_000
DeleteKey = tuple[int | None, int]
def _key(chat_id: int | None, message_id: int) -> DeleteKey:
if chat_id is None or chat_id > CHANNEL_ID_THRESHOLD:
return None, message_id
return chat_id, message_id
class PendingDeletes:
def __init__(self) -> None:
self._expires: dict[DeleteKey, float] = {}
def remember(self, chat_id: int | None, message_ids: list[int]) -> None:
now = time.monotonic()
while self._expires:
oldest = next(iter(self._expires))
fresh = self._expires[oldest] > now
if fresh and len(self._expires) < PENDING_DELETE_LIMIT:
break
del self._expires[oldest]
for message_id in message_ids:
key = _key(chat_id, message_id)
self._expires.pop(key, None)
self._expires[key] = now + PENDING_DELETE_SECONDS
def take(self, chat_id: int, message_id: int) -> bool:
expires = self._expires.pop(_key(chat_id, message_id), None)
return expires is not None and expires > time.monotonic()
+160 -67
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import datetime
import asyncpg
@@ -21,13 +22,19 @@ _TOUCH_EDITED = """
INSERT INTO messages
(account_id, chat_id, message_id, date, sender_id, text, raw,
has_media, is_self_destruct, edited_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, now())
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, CASE WHEN $10 THEN now() END)
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
text = EXCLUDED.text,
raw = EXCLUDED.raw,
has_media = EXCLUDED.has_media,
is_self_destruct = EXCLUDED.is_self_destruct,
edited_at = now()
edited_at = coalesce(EXCLUDED.edited_at, messages.edited_at)
"""
_LOCK_MESSAGE = """
SELECT pg_advisory_xact_lock(
hashtextextended(concat_ws(':', 'message', $1::int, $2::bigint, $3::bigint), 0)
)
"""
_FIND_DELETED = """
@@ -40,6 +47,12 @@ UPDATE messages SET deleted_at = now()
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4
"""
_MARK_DELETED_LATE = """
UPDATE messages SET deleted_at = now()
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4
AND deleted_at IS NULL
"""
_INSERT_VERSION = """
INSERT INTO message_versions
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
@@ -52,7 +65,7 @@ INSERT INTO message_versions
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
SELECT account_id, chat_id, message_id, clock_timestamp(), NULL, text, raw
FROM messages m
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
AND NOT EXISTS (
SELECT 1 FROM message_versions v
WHERE v.account_id = m.account_id AND v.chat_id = m.chat_id
@@ -61,15 +74,37 @@ WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
ON CONFLICT DO NOTHING
"""
_INSERT_PAST_VERSION = """
INSERT INTO message_versions
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
SELECT $1::int, $2::bigint, $3::bigint,
coalesce(
(SELECT min(v.observed_at) - interval '1 microsecond'
FROM message_versions v
WHERE v.account_id = $1 AND v.chat_id = $2 AND v.message_id = $3
AND v.edit_date > $4::timestamptz),
$4::timestamptz,
$5::timestamptz
),
$4::timestamptz, $6::text, $7::jsonb
WHERE NOT EXISTS (
SELECT 1 FROM message_versions v
WHERE v.account_id = $1 AND v.chat_id = $2 AND v.message_id = $3
AND v.edit_date IS NOT DISTINCT FROM $4::timestamptz
AND v.text IS NOT DISTINCT FROM $6::text
)
ON CONFLICT DO NOTHING
"""
_CURRENT_CONTENT = """
SELECT
m.text AS text,
m.raw->>'edit_date' AS edit_date,
(SELECT d.unique_id FROM media d
WHERE d.account_id = m.account_id AND d.chat_id = m.chat_id
AND d.message_id = m.message_id) AS media_unique_id
FROM messages m
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
ORDER BY m.date DESC LIMIT 1
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
"""
_CURRENT_MEDIA = """
@@ -121,33 +156,110 @@ WHERE evicted_key = $2 OR (unique_id = $1 AND evicted_at IS NOT NULL)
"""
async def upsert_message( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
message_id: int,
date: datetime,
sender_id: int | None,
text: str | None,
raw: str,
*,
has_media: bool,
is_self_destruct: bool,
) -> None:
await pool.execute(
_UPSERT_MESSAGE,
account_id,
chat_id,
message_id,
date,
sender_id,
text,
raw,
has_media,
is_self_destruct,
@dataclass(frozen=True, slots=True)
class MessageRow:
chat_id: int
message_id: int
date: datetime
sender_id: int | None
text: str | None
raw: str
has_media: bool
is_self_destruct: bool
edit_date: datetime | None
media_unique_id: str | None
@property
def columns(self) -> tuple:
return (
self.chat_id,
self.message_id,
self.date,
self.sender_id,
self.text,
self.raw,
self.has_media,
self.is_self_destruct,
)
async def _lock_current(
conn: asyncpg.Connection, account_id: int, row: MessageRow
) -> asyncpg.Record | None:
await conn.execute(_LOCK_MESSAGE, account_id, row.chat_id, row.message_id)
return await conn.fetchrow(
_CURRENT_CONTENT, account_id, row.chat_id, row.message_id, row.date
)
def _is_stale(current: asyncpg.Record, row: MessageRow) -> bool:
stored = current["edit_date"]
if stored is None:
return False
if row.edit_date is None:
return True
try:
return row.edit_date < datetime.fromisoformat(stored)
except (TypeError, ValueError):
return False
async def _record_past(
conn: asyncpg.Connection, account_id: int, row: MessageRow
) -> None:
await conn.execute(
_INSERT_PAST_VERSION,
account_id,
row.chat_id,
row.message_id,
row.edit_date,
row.date,
row.text,
row.raw,
)
async def _record_edit(
conn: asyncpg.Connection, account_id: int, row: MessageRow, *, mark_edited: bool
) -> None:
await conn.execute(
_SNAPSHOT_ORIGINAL, account_id, row.chat_id, row.message_id, row.date
)
await conn.execute(_TOUCH_EDITED, account_id, *row.columns, mark_edited)
await conn.execute(
_INSERT_VERSION,
account_id,
row.chat_id,
row.message_id,
row.edit_date,
row.text,
row.raw,
)
async def upsert_message(pool: asyncpg.Pool, account_id: int, row: MessageRow) -> bool:
async with pool.acquire() as conn, conn.transaction():
current = await _lock_current(conn, account_id, row)
if current is None:
await conn.execute(_UPSERT_MESSAGE, account_id, *row.columns)
return True
stored_media = current["media_unique_id"]
changed = current["text"] != row.text or (
stored_media is not None and stored_media != row.media_unique_id
)
if _is_stale(current, row):
if changed:
await _record_past(conn, account_id, row)
return False
if changed:
await _record_edit(
conn, account_id, row, mark_edited=row.edit_date is not None
)
else:
await conn.execute(_UPSERT_MESSAGE, account_id, *row.columns)
return True
async def max_message_id(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> int | None:
@@ -188,48 +300,29 @@ async def mark_deleted_channel(
await _mark_deleted(pool, account_id, rows)
async def add_version( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
message_id: int,
date: datetime,
sender_id: int | None,
text: str | None,
raw: str,
edit_date: datetime | None,
media_unique_id: str | None,
*,
has_media: bool,
is_self_destruct: bool,
) -> bool:
async def add_version(pool: asyncpg.Pool, account_id: int, row: MessageRow) -> bool:
async with pool.acquire() as conn, conn.transaction():
current = await conn.fetchrow(_CURRENT_CONTENT, account_id, chat_id, message_id)
text_changed = current is None or current["text"] != text
media_changed = (
current is not None and current["media_unique_id"] != media_unique_id
)
if not (text_changed or media_changed):
return False
await conn.execute(_SNAPSHOT_ORIGINAL, account_id, chat_id, message_id)
await conn.execute(
_TOUCH_EDITED,
account_id,
chat_id,
message_id,
date,
sender_id,
text,
raw,
has_media,
is_self_destruct,
)
await conn.execute(
_INSERT_VERSION, account_id, chat_id, message_id, edit_date, text, raw
)
current = await _lock_current(conn, account_id, row)
if current is not None:
changed = (
current["text"] != row.text
or current["media_unique_id"] != row.media_unique_id
)
if not changed:
return False
if _is_stale(current, row):
await _record_past(conn, account_id, row)
return False
await _record_edit(conn, account_id, row, mark_edited=True)
return True
async def mark_deleted_late(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int, date: datetime
) -> None:
await pool.execute(_MARK_DELETED_LATE, account_id, chat_id, message_id, date)
async def current_media(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> asyncpg.Record | None:
@@ -0,0 +1,3 @@
from userbot.modules.catchup.service import SavedState, catch_up, read_saved_state
__all__ = ["SavedState", "catch_up", "read_saved_state"]
@@ -0,0 +1,41 @@
from dataclasses import dataclass
import asyncpg
_LOAD = """
SELECT catchup_pts, catchup_qts, catchup_date FROM accounts WHERE account_id = $1
"""
_SAVE = """
UPDATE accounts SET catchup_pts = $2, catchup_qts = $3, catchup_date = $4
WHERE account_id = $1
"""
_CLEAR = """
UPDATE accounts SET catchup_pts = NULL, catchup_qts = NULL, catchup_date = NULL
WHERE account_id = $1
"""
@dataclass(frozen=True)
class UpdateCursor:
pts: int
qts: int
date: int
async def load_cursor(pool: asyncpg.Pool, account_id: int) -> UpdateCursor | None:
row = await pool.fetchrow(_LOAD, account_id)
if row is None or row["catchup_pts"] is None:
return None
return UpdateCursor(row["catchup_pts"], row["catchup_qts"], row["catchup_date"])
async def save_cursor(
pool: asyncpg.Pool, account_id: int, cursor: UpdateCursor
) -> None:
await pool.execute(_SAVE, account_id, cursor.pts, cursor.qts, cursor.date)
async def clear_cursor(pool: asyncpg.Pool, account_id: int) -> None:
await pool.execute(_CLEAR, account_id)
@@ -0,0 +1,255 @@
import asyncio
import sqlite3
import time
from contextlib import closing
from dataclasses import dataclass, field
from pathlib import Path
from pyrogram import raw, utils
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from userbot.modules.capture.chat_meta import meta_from_peer
from userbot.modules.capture.context import CaptureContext
from userbot.modules.catchup import repository
from userbot.modules.catchup.repository import UpdateCursor
from userbot.modules.client import PyroClient
from utils.logging import logger
from utils.policy.models import ChatKind
BOX_SLICES = 50
CHANNEL_SLICES = 10
CHANNEL_LIMIT = 100
MAX_CHANNELS = 50
QUEUE_HIGH_WATER = 200
QUEUE_WAIT_SECONDS = 0.5
NO_QTS = -1
MAX_AGE_SECONDS = 3 * 24 * 3600
@dataclass(frozen=True)
class SavedState:
box: UpdateCursor | None = None
channels: dict[int, int] = field(default_factory=dict)
@dataclass
class BoxWalk:
cursor: UpdateCursor
done: bool = False
updates: int = 0
channels: dict[int, raw.base.Chat] = field(default_factory=dict)
def read_saved_state(path: Path) -> SavedState:
try:
uri = f"{path.resolve().as_uri()}?mode=ro"
with closing(sqlite3.connect(uri, uri=True)) as conn:
rows = conn.execute(
"SELECT id, pts, qts, date FROM update_state"
).fetchall()
except Exception as exc:
logger.warning(f"[yellow]Session {path.stem}: no saved update state ({exc})[/]")
return SavedState()
box = None
channels: dict[int, int] = {}
for state_id, pts, qts, date in rows:
if pts is None:
continue
if state_id == 0:
box = UpdateCursor(
pts, NO_QTS if qts is None else qts, date or int(time.time())
)
else:
channels[state_id] = pts
return SavedState(box, channels)
async def _dispatch(
client: PyroClient,
new_messages: list,
other_updates: list,
users: list,
chats: list,
) -> int:
await client.fetch_peers(users)
await client.fetch_peers(chats)
user_map = {user.id: user for user in users}
chat_map = {chat.id: chat for chat in chats}
queue = client.dispatcher.updates_queue
for message in new_messages:
update = raw.types.UpdateNewMessage(message=message, pts=0, pts_count=0)
queue.put_nowait((update, user_map, chat_map))
for update in other_updates:
queue.put_nowait((update, user_map, chat_map))
while queue.qsize() > QUEUE_HIGH_WATER: # noqa: ASYNC110
await asyncio.sleep(QUEUE_WAIT_SECONDS)
return len(new_messages) + len(other_updates)
def _channel_hints(other_updates: list, chats: list) -> dict[int, raw.base.Chat]:
by_id = {chat.id: chat for chat in chats}
return {
utils.get_channel_id(update.channel_id): by_id[update.channel_id]
for update in other_updates
if isinstance(update, raw.types.UpdateChannelTooLong)
and update.channel_id in by_id
}
async def _walk_box(
client: PyroClient, ctx: CaptureContext, cursor: UpdateCursor
) -> BoxWalk:
walk = BoxWalk(cursor)
for _ in range(BOX_SLICES):
diff = await client.invoke(
raw.functions.updates.GetDifference(
pts=walk.cursor.pts, date=walk.cursor.date, qts=walk.cursor.qts
)
)
if isinstance(diff, raw.types.updates.DifferenceEmpty):
walk.done = True
return walk
if isinstance(diff, raw.types.updates.DifferenceTooLong):
logger.warning(
f"[yellow]Account {ctx.account_id}: too much missed to catch up "
f"(pts {walk.cursor.pts} to {diff.pts}), that gap stays[/]"
)
walk.done = True
return walk
walk.updates += await _dispatch(
client, diff.new_messages, diff.other_updates, diff.users, diff.chats
)
walk.channels.update(_channel_hints(diff.other_updates, diff.chats))
final = isinstance(diff, raw.types.updates.Difference)
state = diff.state if final else diff.intermediate_state
moved = UpdateCursor(state.pts, state.qts, state.date or walk.cursor.date)
if moved == walk.cursor and not final:
logger.warning(
f"[yellow]Account {ctx.account_id}: catch-up stopped advancing "
f"at pts={moved.pts}[/]"
)
return walk
walk.cursor = moved
await repository.save_cursor(ctx.pool, ctx.account_id, moved)
if final:
walk.done = True
return walk
return walk
async def _walk_channel(client: PyroClient, peer_id: int, pts: int) -> int:
peer = await client.resolve_peer(peer_id)
if not isinstance(peer, raw.types.InputPeerChannel):
return 0
channel = raw.types.InputChannel(
channel_id=peer.channel_id, access_hash=peer.access_hash
)
updates = 0
for _ in range(CHANNEL_SLICES):
diff = await client.invoke(
raw.functions.updates.GetChannelDifference(
channel=channel,
filter=raw.types.ChannelMessagesFilterEmpty(),
pts=pts,
limit=CHANNEL_LIMIT,
)
)
if isinstance(diff, raw.types.updates.ChannelDifferenceEmpty):
return updates
if isinstance(diff, raw.types.updates.ChannelDifferenceTooLong):
logger.info(f"Channel {peer_id}: too much missed to catch up")
return updates
updates += await _dispatch(
client, diff.new_messages, diff.other_updates, diff.users, diff.chats
)
if diff.final:
return updates
pts = diff.pts
return updates
def _captured_channels(
ctx: CaptureContext, hints: dict[int, raw.base.Chat], saved: dict[int, int]
) -> list[int]:
wanted: list[tuple[bool, int]] = []
for peer_id, chat in hints.items():
if peer_id not in saved:
continue
meta = meta_from_peer(
raw.types.PeerChannel(channel_id=chat.id),
{chat.id: chat},
{},
ctx.contacts.ids,
)
toggles = ctx.resolve(meta)
if toggles.messages or toggles.track_edits_deletes:
wanted.append((meta.kind is ChatKind.CHANNEL, peer_id))
return [peer_id for _, peer_id in sorted(wanted)[:MAX_CHANNELS]]
async def _walk_channels(
client: PyroClient, ctx: CaptureContext, walk: BoxWalk, saved: dict[int, int]
) -> int:
peer_ids = _captured_channels(ctx, walk.channels, saved)
updates = 0
for peer_id in peer_ids:
try:
updates += await _walk_channel(client, peer_id, saved[peer_id])
except (FloodWait, FloodPremiumWait) as exc:
logger.warning(
f"[yellow]Account {ctx.account_id}: channel catch-up stopped "
f"by a flood wait:[/] {exc}"
)
break
except RPCError as exc:
logger.warning(
f"[yellow]Account {ctx.account_id}: channel {peer_id} "
f"catch-up failed:[/] {exc}"
)
skipped = len(walk.channels) - len(peer_ids)
if skipped:
logger.info(f"Account {ctx.account_id}: {skipped} channel gap(s) left as is")
return updates
async def catch_up(client: PyroClient, saved: SavedState) -> None:
ctx = client.capture
if ctx is None:
return
try:
pending = await repository.load_cursor(ctx.pool, ctx.account_id)
cursor = pending or saved.box
if cursor is None:
logger.info(f"Account {ctx.account_id}: no saved update state to catch up")
return
if time.time() - cursor.date > MAX_AGE_SECONDS:
logger.warning(
f"[yellow]Account {ctx.account_id}: last update seen too long ago "
f"(pts={cursor.pts}), not catching up[/]"
)
await repository.clear_cursor(ctx.pool, ctx.account_id)
return
await repository.save_cursor(ctx.pool, ctx.account_id, cursor)
logger.info(
f"Account {ctx.account_id}: catching up from pts={cursor.pts}"
f"{' (resumed)' if pending else ''}"
)
walk = await _walk_box(client, ctx, cursor)
if not walk.done:
logger.warning(
f"[yellow]Account {ctx.account_id}: catch-up unfinished at "
f"pts={walk.cursor.pts},[/] {walk.updates} updates replayed, "
f"resumes on the next start"
)
return
from_channels = await _walk_channels(client, ctx, walk, saved.channels)
await repository.clear_cursor(ctx.pool, ctx.account_id)
logger.info(
f"[green]Account {ctx.account_id}: caught up,[/] {walk.updates} updates "
f"from the main box, {from_channels} from channels"
)
except Exception:
logger.exception(
f"[red]Account {ctx.account_id}: catch-up failed,[/] "
f"resumes on the next start"
)
@@ -7,7 +7,23 @@ INSERT INTO chat_history
(account_id, chat_id, message_id, event, title, photo_unique_id, actor_id,
ts, raw)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
ON CONFLICT (account_id, chat_id, message_id) DO NOTHING
ON CONFLICT DO NOTHING
"""
_INSERT_CHAT_META = """
INSERT INTO chat_history
(account_id, chat_id, message_id, event, title, photo_unique_id, ts, raw)
SELECT $1, $2, $3, 'meta', $4::text, $5::text, $6, $7::jsonb
WHERE ($4::text, $5::text) IS DISTINCT FROM (
(SELECT title FROM chat_history
WHERE account_id = $1 AND chat_id = $2 AND event IN ('meta', 'title')
ORDER BY ts DESC LIMIT 1),
(SELECT photo_unique_id FROM chat_history
WHERE account_id = $1 AND chat_id = $2
AND event IN ('meta', 'photo', 'photo_deleted')
ORDER BY ts DESC LIMIT 1)
)
ON CONFLICT DO NOTHING
"""
_INSERT_MEMBERSHIP = """
@@ -19,13 +35,14 @@ ON CONFLICT (account_id, chat_id, message_id, user_id) DO NOTHING
_LATEST_TITLE = """
SELECT title FROM chat_history
WHERE account_id = $1 AND chat_id = $2 AND title IS NOT NULL
WHERE account_id = $1 AND chat_id = $2 AND event IN ('meta', 'title')
ORDER BY ts DESC LIMIT 1
"""
_LATEST_PHOTO = """
SELECT photo_unique_id FROM chat_history
WHERE account_id = $1 AND chat_id = $2 AND photo_unique_id IS NOT NULL
WHERE account_id = $1 AND chat_id = $2
AND event IN ('meta', 'photo', 'photo_deleted')
ORDER BY ts DESC LIMIT 1
"""
@@ -64,6 +81,29 @@ async def insert_chat_history( # noqa: PLR0913
)
async def record_chat_meta( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
message_id: int,
title: str | None,
photo_unique_id: str | None,
ts: datetime,
raw: str,
) -> bool:
status = await pool.execute(
_INSERT_CHAT_META,
account_id,
chat_id,
message_id,
title,
photo_unique_id,
ts,
raw,
)
return status == "INSERT 0 1"
async def insert_membership( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
+40 -26
View File
@@ -9,11 +9,12 @@ from userbot.modules.jobs import repository
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import JOB_HANDLERS, JobHandler
from utils.db.models import Job, JobStatus
from utils.env import env
from utils.listen import Listener
from utils.logging import logger
JOBS_CHANGED_CHANNEL = "jobs_changed"
POLL_INTERVAL_SECONDS = 60.0
RESTART_SECONDS = 10.0
class JobConsumer:
@@ -38,25 +39,20 @@ class JobConsumer:
self.pool, ctx.job_id, JobStatus.FAILED, f"no handler for {job.kind!r}"
)
return
while True:
try:
await handler(ctx)
except (FloodWait, FloodPremiumWait) as exc:
await repository.bump_flood_wait(self.pool, ctx.job_id)
wait = exc.value if isinstance(exc.value, int) else 1
logger.warning(
f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind})[/]"
)
await asyncio.sleep(wait)
except Exception as exc:
logger.exception(f"Job {ctx.job_id} ({job.kind}) failed")
await repository.finish(
self.pool, ctx.job_id, JobStatus.FAILED, str(exc)
)
return
else:
await repository.finish(self.pool, ctx.job_id, JobStatus.DONE)
return
try:
await handler(ctx)
except (FloodWait, FloodPremiumWait) as exc:
wait = exc.value if isinstance(exc.value, int) else 1
await repository.defer_flood_wait(self.pool, ctx.job_id, wait)
logger.warning(
f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind}), "
f"deferred[/]"
)
except Exception as exc:
logger.exception(f"Job {ctx.job_id} ({job.kind}) failed")
await repository.finish(self.pool, ctx.job_id, JobStatus.FAILED, str(exc))
else:
await repository.finish(self.pool, ctx.job_id, JobStatus.DONE)
async def drain(self) -> None:
while True:
@@ -66,23 +62,41 @@ class JobConsumer:
await self.execute(job)
async def run(self) -> None:
while True:
try:
await self._serve()
except Exception:
logger.exception(
f"[red]Job consumer for account {self.account_id} failed,[/] "
f"restarting in {RESTART_SECONDS:.0f}s"
)
await asyncio.sleep(RESTART_SECONDS)
async def _serve(self) -> None:
requeued = await repository.requeue_running(self.pool, self.account_id)
if requeued:
logger.info(f"[yellow]Requeued {requeued} stale running job(s).[/]")
pruned = await repository.prune_finished(self.pool, self.account_id)
if pruned:
logger.info(f"[yellow]Pruned {pruned} finished job(s).[/]")
conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener(JOBS_CHANGED_CHANNEL, lambda *_: self._wake.set())
listener = Listener(
f"jobs {self.account_id}",
{JOBS_CHANGED_CHANNEL: lambda _payload: self._wake.set()},
self._wake.set,
)
await listener.start()
logger.info(f"[green]Job consumer running for account {self.account_id}.[/]")
try:
while True:
await self.drain()
self._wake.clear()
await self.drain()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(
self._wake.wait(), timeout=POLL_INTERVAL_SECONDS
self._wake.wait(), timeout=await self._idle_seconds()
)
finally:
with contextlib.suppress(Exception):
await conn.close()
await listener.stop()
async def _idle_seconds(self) -> float:
due = await repository.seconds_to_next_due(self.pool, self.account_id)
return POLL_INTERVAL_SECONDS if due is None else min(due, POLL_INTERVAL_SECONDS)
@@ -5,6 +5,7 @@ from userbot.modules.jobs.handlers import (
fetch_avatar,
fetch_custom_emoji,
fetch_media,
fetch_read_info,
search_peers,
sync_contacts,
sync_dialogs,
@@ -18,6 +19,7 @@ __all__ = [
"fetch_avatar",
"fetch_custom_emoji",
"fetch_media",
"fetch_read_info",
"search_peers",
"sync_contacts",
"sync_dialogs",
@@ -1,17 +1,11 @@
from datetime import UTC, datetime
from pyrogram import Client
from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile
from userbot.modules.raw_json import raw_json
from userbot.modules.profiles.snapshots import save_group, save_user
MEMBER_CAP = 200
@@ -25,45 +19,17 @@ 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))
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
)
async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None:
chat = await client.get_chat(chat_id)
if chat is None:
return
photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
await insert_chat_history(
ctx.pool,
ctx.account_id,
chat_id,
0,
"meta",
chat.title,
photo_unique_id,
None,
datetime.now(UTC),
raw_json(chat),
)
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "chat", photo_unique_id, photo_file_id
)
if chat is not None:
await save_group(ctx, chat)
async def _enrich_members(client: Client, ctx: CaptureContext, chat_id: int) -> None:
try:
async for member in client.get_chat_members(chat_id, limit=MEMBER_CAP):
if isinstance(member.user, User):
await _save_user(ctx, member.user)
await save_user(ctx, member.user)
except (BadRequest, Forbidden):
return
@@ -89,7 +55,7 @@ async def _enrich_senders(client: Client, ctx: CaptureContext, chat_id: int) ->
if not ids:
return
for user in await _fetch_users(client, ids):
await _save_user(ctx, user)
await save_user(ctx, user)
@register("enrich_chat")
@@ -0,0 +1,124 @@
from datetime import UTC, datetime, timedelta
from pyrogram import Client, raw
from pyrogram.errors import (
ChannelInvalid,
ChannelPrivate,
ChatTooBig,
FloodPremiumWait,
FloodWait,
MessageIdInvalid,
MessageNotReadYet,
MessageTooOld,
MsgIdInvalid,
MsgTooOld,
PeerIdInvalid,
RPCError,
UserPrivacyRestricted,
YourPrivacyRestricted,
)
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.read_receipts.repository import save_fetch, save_reads
from utils.jobs import enqueue
from utils.logging import logger
from utils.policy.models import ChatKind
from utils.read.peers import get_peers
from utils.read.read_receipts import read_info
RETRY = timedelta(seconds=60)
LATER = timedelta(days=1)
Refusal = tuple[tuple[type[Exception], ...], str, timedelta]
REFUSALS: tuple[Refusal, ...] = (
((MessageNotReadYet,), "not_read_yet", RETRY),
((UserPrivacyRestricted,), "user_privacy", RETRY),
((YourPrivacyRestricted,), "your_privacy", LATER),
((ChatTooBig,), "chat_too_big", LATER),
((MsgTooOld, MessageTooOld), "too_old", LATER),
(
(
PeerIdInvalid,
ChannelInvalid,
ChannelPrivate,
MessageIdInvalid,
MsgIdInvalid,
KeyError,
ValueError,
),
"unavailable",
RETRY,
),
)
def _refusal(exc: Exception) -> tuple[str, timedelta]:
for kinds, outcome, retry in REFUSALS:
if isinstance(exc, kinds):
return outcome, retry
return "error", RETRY
def _at(timestamp: int) -> datetime:
return datetime.fromtimestamp(timestamp, UTC)
async def ask_telegram(
client: Client, chat_id: int, message_id: int, *, group: bool
) -> list[tuple[int, datetime]]:
peer = await client.resolve_peer(chat_id)
if peer is None:
msg = f"unknown peer {chat_id}"
raise ValueError(msg)
if group:
participants = await client.invoke(
raw.functions.messages.GetMessageReadParticipants(
peer=peer, msg_id=message_id
)
)
return [(item.user_id, _at(item.date)) for item in participants]
outbox = await client.invoke(
raw.functions.messages.GetOutboxReadDate(peer=peer, msg_id=message_id)
)
return [(chat_id, _at(outbox.date))]
async def _enrich_unknown(ctx: JobContext, chat_id: int, reader_ids: list[int]) -> None:
known = {
peer.peer_id for peer in await get_peers(ctx.pool, ctx.account_id, reader_ids)
}
if set(reader_ids) - known:
await enqueue(ctx.pool, ctx.account_id, "enrich_chat", {"chat_id": chat_id})
@register("fetch_read_info")
async def fetch_read_info(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
chat_id = ctx.job.params["chat_id"]
message_id = ctx.job.params["message_id"]
info = await read_info(ctx.pool, ctx.account_id, chat_id, message_id)
if info is None or not info.can_fetch:
return
group = info.chat_kind == ChatKind.GROUP
try:
reads = await ask_telegram(client, chat_id, message_id, group=group)
except (FloodWait, FloodPremiumWait) as exc:
wait = exc.value if isinstance(exc.value, int) else 0
retry = max(RETRY, timedelta(seconds=wait))
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, "flood", retry)
return
except (RPCError, KeyError, ValueError) as exc:
outcome, retry = _refusal(exc)
if outcome == "error":
logger.warning(f"[yellow]Read info {chat_id}/{message_id}: {exc!r}[/]")
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, outcome, retry)
return
source = "participants" if group else "outbox_date"
await save_reads(ctx.pool, ctx.account_id, chat_id, message_id, source, reads)
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, "ok", RETRY)
if group and reads:
await _enrich_unknown(ctx, chat_id, [reader_id for reader_id, _ in reads])
@@ -1,11 +1,8 @@
from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile
from userbot.modules.raw_json import raw_json
from userbot.modules.profiles.snapshots import save_user
@register("sync_contacts")
@@ -21,17 +18,7 @@ async def sync_contacts(ctx: JobContext) -> None:
for user in contacts:
if not isinstance(user, User):
continue
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
await write_profile(ctx.pool, ctx.account_id, user.id, fields, 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,
)
await save_user(capture, user)
processed += 1
await capture.contacts.refresh()
await ctx.report_progress({"processed": processed, "done": True})
@@ -2,14 +2,10 @@ from pyrogram import Client
from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile
from userbot.modules.profiles.snapshots import save_group, save_private
from userbot.modules.raw_json import raw_json
from userbot.modules.profiles.snapshots import save_group, save_private, save_user
from utils.logging import logger
SAVE_EVERY = 100
@@ -30,21 +26,8 @@ async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) ->
continue
users = result if isinstance(result, list) else [result]
for user in users:
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)
)
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,
)
if isinstance(user, User):
await save_user(ctx, user)
@register("sync_dialogs")
+14 -3
View File
@@ -32,6 +32,7 @@ async def claim_next(pool: asyncpg.Pool, account_id: int) -> Job | None:
"updated_at = now(), attempts = attempts + 1 "
"WHERE id = ("
" SELECT id FROM jobs WHERE account_id = $1 AND status = 'pending' "
" AND (run_after IS NULL OR run_after <= now()) "
" ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1"
") RETURNING *",
account_id,
@@ -76,11 +77,21 @@ async def report_progress(
)
async def bump_flood_wait(pool: asyncpg.Pool, job_id: int) -> None:
async def defer_flood_wait(pool: asyncpg.Pool, job_id: int, seconds: int) -> None:
await pool.execute(
"UPDATE jobs SET flood_waits = flood_waits + 1, updated_at = now() "
"WHERE id = $1",
"UPDATE jobs SET status = 'pending', flood_waits = flood_waits + 1, "
"run_after = now() + make_interval(secs => $2), updated_at = now() "
"WHERE id = $1 AND status = 'running'",
job_id,
seconds,
)
async def seconds_to_next_due(pool: asyncpg.Pool, account_id: int) -> float | None:
return await pool.fetchval(
"SELECT extract(epoch FROM min(run_after) - now())::float FROM jobs "
"WHERE account_id = $1 AND status = 'pending' AND run_after > now()",
account_id,
)
@@ -7,6 +7,14 @@ INSERT INTO links
(account_id, chat_id, message_id, position, url, kind, web_url, web_title,
web_description, web_site_name, raw)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
ON CONFLICT (account_id, chat_id, message_id, position) DO UPDATE SET
url = EXCLUDED.url,
kind = EXCLUDED.kind,
web_url = EXCLUDED.web_url,
web_title = EXCLUDED.web_title,
web_description = EXCLUDED.web_description,
web_site_name = EXCLUDED.web_site_name,
raw = EXCLUDED.raw
"""
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from pyrogram import Client, raw
from pyrogram.types import Chat, User
@@ -12,6 +12,11 @@ class ProfileFields:
phone: str | None = None
photo_unique_id: str | None = None
is_deleted_account: bool = False
unknown: frozenset[str] = field(default=frozenset(), compare=False)
MIN_UNKNOWN = frozenset({"username", "phone"})
CHAT_UNKNOWN = frozenset({"phone", "is_deleted_account"})
def active_username(usernames: list[raw.types.Username]) -> str | None:
@@ -35,6 +40,7 @@ async def snapshot_from_user(
phone=user.phone_number if user else None,
photo_unique_id=photo_unique_id,
is_deleted_account=bool(getattr(raw_user, "deleted", False)),
unknown=MIN_UNKNOWN if getattr(raw_user, "min", False) else frozenset(),
)
return fields, photo_file_id, photo_unique_id
@@ -52,6 +58,7 @@ def snapshot_from_high_level(
phone=user.phone_number,
photo_unique_id=photo_unique_id,
is_deleted_account=bool(user.is_deleted),
unknown=MIN_UNKNOWN if user.is_min else frozenset(),
)
return fields, photo_file_id, photo_unique_id
@@ -64,8 +71,7 @@ def snapshot_from_chat(chat: Chat) -> tuple[ProfileFields, str | None, str | Non
first_name=chat.first_name,
last_name=chat.last_name,
username=chat.username,
phone=None,
photo_unique_id=photo_unique_id,
is_deleted_account=False,
unknown=CHAT_UNKNOWN,
)
return fields, photo_file_id, photo_unique_id
@@ -9,26 +9,46 @@ WHERE account_id = $1 AND peer_id = $2
"""
_UPSERT_PEER = """
INSERT INTO peers
INSERT INTO peers AS p
(account_id, peer_id, first_name, last_name, username, phone,
photo_unique_id, is_deleted_account, raw, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, now())
ON CONFLICT (account_id, peer_id) DO UPDATE SET
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
username = EXCLUDED.username,
phone = EXCLUDED.phone,
username = CASE WHEN 'username' = ANY($10::text[])
THEN COALESCE(EXCLUDED.username, p.username) ELSE EXCLUDED.username END,
phone = CASE WHEN 'phone' = ANY($10::text[])
THEN COALESCE(EXCLUDED.phone, p.phone) ELSE EXCLUDED.phone END,
photo_unique_id = EXCLUDED.photo_unique_id,
is_deleted_account = EXCLUDED.is_deleted_account,
is_deleted_account = CASE WHEN 'is_deleted_account' = ANY($10::text[])
THEN p.is_deleted_account ELSE EXCLUDED.is_deleted_account END,
raw = EXCLUDED.raw,
updated_at = now()
"""
_INSERT_HISTORY = """
_INSERT_CHANGE = """
INSERT INTO peer_history
(account_id, peer_id, observed_at, first_name, last_name, username, phone,
photo_unique_id, is_deleted_account, raw)
VALUES ($1, $2, now(), $3, $4, $5, $6, $7, $8, $9::jsonb)
SELECT account_id, peer_id, clock_timestamp(), first_name, last_name, username,
phone, photo_unique_id, is_deleted_account, raw
FROM peers p
WHERE account_id = $1 AND peer_id = $2
AND NOT EXISTS (
SELECT 1 FROM (
SELECT first_name, last_name, username, phone, photo_unique_id,
is_deleted_account
FROM peer_history
WHERE account_id = $1 AND peer_id = $2
ORDER BY observed_at DESC LIMIT 1
) latest
WHERE (latest.first_name, latest.last_name, latest.username, latest.phone,
latest.photo_unique_id, latest.is_deleted_account)
IS NOT DISTINCT FROM
(p.first_name, p.last_name, p.username, p.phone,
p.photo_unique_id, p.is_deleted_account)
)
ON CONFLICT DO NOTHING
"""
@@ -49,18 +69,30 @@ async def get_peer(
)
async def write_profile(
pool: asyncpg.Pool, account_id: int, peer_id: int, fields: ProfileFields, raw: str
) -> None:
args = (
fields.first_name,
fields.last_name,
fields.username,
fields.phone,
fields.photo_unique_id,
fields.is_deleted_account,
raw,
)
async def write_profile( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
peer_id: int,
fields: ProfileFields,
raw: str,
*,
history: bool,
) -> bool:
async with pool.acquire() as conn, conn.transaction():
await conn.execute(_UPSERT_PEER, account_id, peer_id, *args)
await conn.execute(_INSERT_HISTORY, account_id, peer_id, *args)
await conn.execute(
_UPSERT_PEER,
account_id,
peer_id,
fields.first_name,
fields.last_name,
fields.username,
fields.phone,
fields.photo_unique_id,
fields.is_deleted_account,
raw,
sorted(fields.unknown),
)
if not history:
return False
status = await conn.execute(_INSERT_CHANGE, account_id, peer_id)
return status == "INSERT 0 1"
@@ -1,19 +1,46 @@
from datetime import UTC, datetime
from pyrogram.types import Chat
from pyrogram.enums import ChatType
from pyrogram.types import Chat, User
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history
from userbot.modules.profiles.parse import snapshot_from_chat
from userbot.modules.groups.repository import record_chat_meta
from userbot.modules.profiles.parse import snapshot_from_chat, snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile
from userbot.modules.raw_json import raw_json
async def save_user(ctx: CaptureContext, user: User) -> 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,
)
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
)
async def save_private(ctx: CaptureContext, chat: Chat) -> bool:
chat_id = chat.id or 0
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, raw_json(chat))
is_bot = chat.type is ChatType.BOT
await write_profile(
ctx.pool,
ctx.account_id,
chat_id,
fields,
raw_json(chat),
history=ctx.resolve_peer(chat_id, is_bot=is_bot).profile_history,
)
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
@@ -26,15 +53,13 @@ async def save_group(ctx: CaptureContext, chat: Chat) -> None:
photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
await insert_chat_history(
await record_chat_meta(
ctx.pool,
ctx.account_id,
chat_id,
0,
"meta",
chat.title,
photo_unique_id,
None,
datetime.now(UTC),
raw_json(chat),
)
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timedelta
import asyncpg
@@ -9,6 +9,26 @@ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (account_id, chat_id, message_id, ts) DO NOTHING
"""
_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,
@@ -34,3 +54,33 @@ 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.
@@ -0,0 +1,16 @@
import asyncpg
_HEARTBEAT = """
WITH extended AS (
UPDATE capture_uptime SET seen_at = now()
WHERE account_id = $1 AND seen_at >= now() - interval '150 seconds'
RETURNING account_id
)
INSERT INTO capture_uptime (account_id, started_at, seen_at)
SELECT $1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM extended)
"""
async def heartbeat(pool: asyncpg.Pool, account_id: int) -> None:
await pool.execute(_HEARTBEAT, account_id)
+156 -34
View File
@@ -1,60 +1,182 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from userbot.modules.watches import repository
from userbot.modules.watches.evaluator import (
KIND_KEYWORD,
KIND_PEER_ONLINE,
Rules,
Seen,
build_rules,
match_chat_scope,
match_keyword,
match_peer_online,
match_new_message,
)
from utils.logging import logger
from utils.read import watches as watches_read
from utils.read.accounts import self_user_id
if TYPE_CHECKING:
from collections.abc import Coroutine
import asyncpg
from utils.read.models import WatchView
ONLINE_QUIET = timedelta(minutes=15)
PREVIEW_CHARS = 500
def _message_payload(event: str, seen: Seen) -> dict[str, Any]:
payload: dict[str, Any] = {
"event": event,
"chat_id": seen.chat_id,
"message_id": seen.message_id,
"sender_id": seen.sender_id,
"text": (seen.text or "")[:PREVIEW_CHARS] or None,
}
if seen.date is not None:
payload["date"] = seen.date.isoformat()
return payload
class WatchCache:
def __init__(self, pool: asyncpg.Pool, account_id: int) -> None:
self._pool = pool
self._account_id = account_id
self.watches: list[WatchView] = []
self._online: set[int] = set()
self.rules = Rules()
self._self_id: int | None = None
self._last_seen: dict[int, datetime] = {}
async def refresh(self) -> None:
rows = await watches_read.list_watches(self._pool, self._account_id)
self.watches = [watch for watch in rows if watch.enabled]
logger.info(f"[green]Watches cached:[/] {len(self.watches)}")
self.rules = build_rules(rows)
self._self_id = await self_user_id(self._pool, self._account_id)
logger.info(f"[green]Watches cached:[/] {len(self.rules)}")
async def on_text(self, chat_id: int, message_id: int, text: str | None) -> None:
if not text:
return
for watch in self.watches:
if watch.kind == KIND_KEYWORD and match_keyword(
text, chat_id, watch.params
):
await watches_read.insert_alert(
self._pool,
self._account_id,
watch.id,
{"chat_id": chat_id, "message_id": message_id},
dedup_key=f"{chat_id}:{message_id}",
def _own(self, seen: Seen) -> bool:
return seen.outgoing or (
self._self_id is not None and seen.sender_id == self._self_id
)
async def _guard(self, work: Coroutine[Any, Any, None]) -> None:
try:
await work
except Exception:
logger.exception("[red]Watch evaluation failed[/]")
async def _fire(self, watch_id: int, payload: dict[str, Any], key: str) -> None:
await watches_read.insert_alert(
self._pool, self._account_id, watch_id, payload, dedup_key=key
)
async def _keyword(self, event: str, seen: Seen) -> None:
for rule in self.rules.keyword:
if match_keyword(rule, seen):
await self._fire(
rule.watch_id,
_message_payload(event, seen),
f"{seen.chat_id}:{seen.message_id}",
)
async def on_status(self, peer_id: int, *, is_online: bool) -> None:
if not is_online:
self._online.discard(peer_id)
async def on_message(self, seen: Seen) -> None:
if self._own(seen):
return
if peer_id in self._online:
return
self._online.add(peer_id)
for watch in self.watches:
if watch.kind == KIND_PEER_ONLINE and match_peer_online(
peer_id, watch.params
):
await watches_read.insert_alert(
self._pool, self._account_id, watch.id, {"peer_id": peer_id}
await self._guard(self._on_message(seen))
async def _on_message(self, seen: Seen) -> None:
await self._keyword("message", seen)
for watch_id, params in self.rules.new_message:
if match_new_message(params, seen):
await self._fire(
watch_id,
_message_payload("message", seen),
f"{seen.chat_id}:{seen.message_id}",
)
async def on_edit(self, seen: Seen) -> None:
if self._own(seen):
return
await self._guard(self._on_edit(seen))
async def _on_edit(self, seen: Seen) -> None:
await self._keyword("edit", seen)
if seen.edit_date is None:
return
stamp = int(seen.edit_date.timestamp())
for watch_id, params in self.rules.edited:
if match_chat_scope(params, seen):
await self._fire(
watch_id,
_message_payload("edit", seen),
f"{seen.chat_id}:{seen.message_id}:{stamp}",
)
async def on_transcript(self, chat_id: int, message_id: int, text: str) -> None:
if self.rules.keyword:
await self._guard(self._on_transcript(chat_id, message_id, text))
async def _on_transcript(self, chat_id: int, message_id: int, text: str) -> None:
sender_id = await repository.message_sender(
self._pool, self._account_id, chat_id, message_id
)
seen = Seen(chat_id, message_id, sender_id, text)
if not self._own(seen):
await self._keyword("transcript", seen)
async def on_deleted(self, chat_id: int | None, message_ids: list[int]) -> None:
if self.rules.deleted and message_ids:
await self._guard(self._on_deleted(chat_id, message_ids))
async def _on_deleted(self, chat_id: int | None, message_ids: list[int]) -> None:
rows = await repository.deleted_messages(
self._pool, self._account_id, chat_id, message_ids
)
for row in rows:
seen = Seen(
row["chat_id"],
row["message_id"],
row["sender_id"],
row["text"],
date=row["date"],
)
if self._own(seen):
continue
for watch_id, params in self.rules.deleted:
if match_chat_scope(params, seen):
await self._fire(
watch_id,
_message_payload("delete", seen),
f"{seen.chat_id}:{seen.message_id}",
)
async def on_status(
self, peer_id: int, status: str, at: datetime, last_online: datetime | None
) -> None:
watch_ids = [
watch_id
for watch_id, params in self.rules.online
if params.peer_id == peer_id
]
if not watch_ids:
return
if status != "online":
self._last_seen[peer_id] = (last_online or at).astimezone(UTC)
return
await self._guard(self._on_online(peer_id, at, watch_ids))
async def _on_online(
self, peer_id: int, at: datetime, watch_ids: list[int]
) -> None:
stored = await repository.last_seen_before(
self._pool, self._account_id, peer_id, at
)
known = [value for value in (stored, self._last_seen.get(peer_id)) if value]
previous = max(known, default=None)
self._last_seen[peer_id] = at
if previous is not None and at - previous < ONLINE_QUIET:
return
session = int((previous or at).timestamp())
payload: dict[str, Any] = {"event": "online", "peer_id": peer_id}
if previous is not None:
payload["since"] = previous.isoformat()
for watch_id in watch_ids:
await self._fire(watch_id, payload, f"{peer_id}:{session}")
@@ -1,24 +1,104 @@
import re
from typing import Any
from dataclasses import dataclass, field
from datetime import datetime
KIND_KEYWORD = "keyword"
KIND_PEER_ONLINE = "peer_online"
from utils.logging import logger
from utils.read.models import WatchView
from utils.watch_rules import (
MAX_TEXT,
DeletedParams,
EditedParams,
KeywordParams,
NewMessageParams,
PeerOnlineParams,
WatchParamsError,
compile_pattern,
parse_params,
)
def match_keyword(text: str, chat_id: int, params: dict[str, Any]) -> bool:
target_chat = params.get("chat_id")
if target_chat is not None and target_chat != chat_id:
return False
pattern = params.get("pattern")
if not pattern:
return False
if params.get("regex"):
@dataclass(frozen=True, slots=True)
class Seen:
chat_id: int
message_id: int
sender_id: int | None
text: str | None
outgoing: bool = False
date: datetime | None = None
edit_date: datetime | None = None
@dataclass(frozen=True, slots=True)
class KeywordRule:
watch_id: int
params: KeywordParams
regex: re.Pattern[str] | None
@dataclass(slots=True)
class Rules:
keyword: list[KeywordRule] = field(default_factory=list)
new_message: list[tuple[int, NewMessageParams]] = field(default_factory=list)
deleted: list[tuple[int, DeletedParams]] = field(default_factory=list)
edited: list[tuple[int, EditedParams]] = field(default_factory=list)
online: list[tuple[int, PeerOnlineParams]] = field(default_factory=list)
def __len__(self) -> int:
return (
len(self.keyword)
+ len(self.new_message)
+ len(self.deleted)
+ len(self.edited)
+ len(self.online)
)
def build_rules(watches: list[WatchView]) -> Rules:
rules = Rules()
for watch in watches:
if not watch.enabled:
continue
try:
return re.search(pattern, text) is not None
except re.error:
return False
return pattern.casefold() in text.casefold()
params = parse_params(watch.kind, watch.params)
except WatchParamsError as exc:
logger.warning(f"[yellow]Watch {watch.id} skipped:[/] {exc}")
continue
match params:
case KeywordParams():
regex = compile_pattern(params.pattern) if params.regex else None
rules.keyword.append(KeywordRule(watch.id, params, regex))
case NewMessageParams():
rules.new_message.append((watch.id, params))
case DeletedParams():
rules.deleted.append((watch.id, params))
case EditedParams():
rules.edited.append((watch.id, params))
case PeerOnlineParams():
rules.online.append((watch.id, params))
return rules
def match_peer_online(peer_id: int, params: dict[str, Any]) -> bool:
return params.get("peer_id") == peer_id
def _in_scope(chat_id: int | None, sender_id: int | None, seen: Seen) -> bool:
if chat_id is not None and chat_id != seen.chat_id:
return False
return sender_id is None or sender_id == seen.sender_id
def match_keyword(rule: KeywordRule, seen: Seen) -> bool:
params = rule.params
if not seen.text or not _in_scope(params.chat_id, params.sender_id, seen):
return False
sample = seen.text[:MAX_TEXT]
if rule.regex is not None:
return rule.regex.search(sample) is not None
return params.pattern.casefold() in sample.casefold()
def match_new_message(params: NewMessageParams, seen: Seen) -> bool:
return _in_scope(params.chat_id, params.sender_id, seen)
def match_chat_scope(params: DeletedParams | EditedParams, seen: Seen) -> bool:
if params.chat_id is None:
return seen.chat_id > 0
return params.chat_id == seen.chat_id
@@ -0,0 +1,48 @@
from datetime import datetime
import asyncpg
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
_DELETED = """
SELECT DISTINCT ON (chat_id, message_id) chat_id, message_id, sender_id, text, date
FROM messages
WHERE account_id = $1 AND message_id = ANY($2::bigint[])
AND deleted_at IS NOT NULL AND {scope}
ORDER BY chat_id, message_id, date DESC
"""
_SENDER = """
SELECT sender_id FROM messages
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
ORDER BY date DESC LIMIT 1
"""
_LAST_SEEN = """
SELECT CASE WHEN status = 'online' THEN ts ELSE coalesce(last_online_date, ts) END
FROM presence
WHERE account_id = $1 AND peer_id = $2 AND ts < $3
ORDER BY ts DESC LIMIT 1
"""
async def deleted_messages(
pool: asyncpg.Pool, account_id: int, chat_id: int | None, message_ids: list[int]
) -> list[asyncpg.Record]:
if chat_id is None:
query = _DELETED.format(scope="chat_id > $3")
return await pool.fetch(query, account_id, message_ids, CHANNEL_ID_THRESHOLD)
query = _DELETED.format(scope="chat_id = $3")
return await pool.fetch(query, account_id, message_ids, chat_id)
async def message_sender(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> int | None:
return await pool.fetchval(_SENDER, account_id, chat_id, message_id)
async def last_seen_before(
pool: asyncpg.Pool, account_id: int, peer_id: int, at: datetime
) -> datetime | None:
return await pool.fetchval(_LAST_SEEN, account_id, peer_id, at)
@@ -0,0 +1,22 @@
from datetime import UTC, datetime
from pyrogram.types import Message
from userbot.modules.capture.message import sender_id
from userbot.modules.watches.evaluator import Seen
def utc(moment: datetime | None) -> datetime | None:
return moment.astimezone(UTC) if moment else None
def seen_message(chat_id: int, message: Message) -> Seen:
return Seen(
chat_id=chat_id,
message_id=message.id,
sender_id=sender_id(message),
text=message.text or message.caption,
outgoing=bool(message.outgoing),
date=utc(message.date),
edit_date=utc(message.edit_date),
)
+66 -29
View File
@@ -11,10 +11,13 @@ 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,
@@ -28,12 +31,14 @@ from utils.storage import ContentAddressedStorage
setup_logging()
SESSION_FLUSH_INTERVAL = 60
SESSION_RETRY_INTERVAL = 60
UPTIME_INTERVAL = 60
@dataclass
class RunningAccount:
client: PyroClient
consumer_task: asyncio.Task
tasks: list[asyncio.Task]
device_model: str | None
@@ -54,6 +59,7 @@ class AccountRegistry:
self._pool = pool
self._storage = storage
self._running: dict[str, RunningAccount] = {}
self._saved: dict[str, SavedState] = {}
self._lock = asyncio.Lock()
@property
@@ -86,6 +92,8 @@ class AccountRegistry:
async def _start(self, path: Path, device_model: str | None) -> None:
session_name = path.stem
if session_name not in self._saved:
self._saved[session_name] = await asyncio.to_thread(read_saved_state, path)
client = PyroClient(
session_name, workdir=str(path.parent), device_model=device_model
)
@@ -100,14 +108,20 @@ class AccountRegistry:
client, self._pool, self._storage, account_id
)
except Exception:
logger.exception(f"[red]Failed to start session:[/] {session_name}")
logger.exception(
f"[red]Failed to start session:[/] {session_name}, "
f"retrying in {SESSION_RETRY_INTERVAL}s"
)
with contextlib.suppress(Exception):
await client.stop()
return
consumer = JobConsumer(client, self._pool, account_id)
self._running[session_name] = RunningAccount(
client, asyncio.create_task(consumer.run()), device_model
)
saved = self._saved.pop(session_name)
tasks = [
asyncio.create_task(consumer.run()),
asyncio.create_task(catch_up(client, saved)),
]
self._running[session_name] = RunningAccount(client, tasks, device_model)
logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})")
await enqueue(self._pool, account_id, "sync_dialogs", {})
await enqueue(self._pool, account_id, "sync_contacts", {})
@@ -116,7 +130,8 @@ class AccountRegistry:
account = self._running.pop(session_name, None)
if account is None:
return
await _cancel(account.consumer_task)
for task in account.tasks:
await _cancel(task)
with contextlib.suppress(Exception):
await account.client.stop()
logger.info(f"[yellow]Client stopped:[/] {session_name}")
@@ -124,7 +139,8 @@ class AccountRegistry:
async def _log_out(self, path: Path) -> None:
account = self._running.pop(path.stem, None)
if account is not None:
await _cancel(account.consumer_task)
for task in account.tasks:
await _cancel(task)
client = account.client
else:
client = PyroClient(
@@ -148,9 +164,25 @@ async def _flush_sessions(registry: AccountRegistry) -> None:
await client.storage.save()
async def _listen_changes(
registry: AccountRegistry, tasks: set[asyncio.Task]
) -> asyncpg.Connection:
async def _retry_sessions(registry: AccountRegistry) -> None:
while True:
await asyncio.sleep(SESSION_RETRY_INTERVAL)
try:
await registry.sync()
except Exception:
logger.exception("[red]Session sync failed[/]")
async def _keep_uptime(registry: AccountRegistry, pool: asyncpg.Pool) -> None:
while True:
for client in registry.clients:
if client.capture is not None and client.is_connected:
with contextlib.suppress(Exception):
await uptime.heartbeat(pool, client.capture.account_id)
await asyncio.sleep(UPTIME_INTERVAL)
def _change_listener(registry: AccountRegistry, tasks: set[asyncio.Task]) -> Listener:
def spawn(coro: Coroutine[Any, Any, None]) -> None:
task = asyncio.create_task(coro)
tasks.add(task)
@@ -163,26 +195,29 @@ async def _listen_changes(
if client.capture is not None:
spawn(make_coro(client.capture))
def on_policy(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
) -> None:
def on_policy(_payload: str) -> None:
reload(lambda capture: capture.reload_policies())
def on_watch(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
) -> None:
def on_watch(_payload: str) -> None:
reload(lambda capture: capture.watches.refresh())
def on_accounts(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
) -> None:
def on_accounts(_payload: str) -> None:
spawn(registry.sync())
conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener("policy_changed", on_policy)
await conn.add_listener(WATCHES_CHANGED_CHANNEL, on_watch)
await conn.add_listener(ACCOUNTS_CHANGED_CHANNEL, on_accounts)
return conn
def on_reconnect() -> None:
on_policy("")
on_watch("")
on_accounts("")
return Listener(
"userbot",
{
"policy_changed": on_policy,
WATCHES_CHANGED_CHANNEL: on_watch,
ACCOUNTS_CHANGED_CHANNEL: on_accounts,
},
on_reconnect,
)
async def runner() -> None:
@@ -191,22 +226,24 @@ async def runner() -> None:
registry = AccountRegistry(pool, storage)
tasks: set[asyncio.Task] = set()
listen_conn: asyncpg.Connection | None = None
listener = _change_listener(registry, tasks)
stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
flush = asyncio.create_task(_flush_sessions(registry))
retry = asyncio.create_task(_retry_sessions(registry))
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.[/]")
listen_conn = await _listen_changes(registry, tasks)
await listener.start()
logger.info("[green]Userbot running.[/]")
await stop.wait()
finally:
await _cancel(flush)
if listen_conn is not None:
with contextlib.suppress(Exception):
await listen_conn.close()
await _cancel(retry)
await _cancel(beat)
await listener.stop()
await registry.close()
await container.close()
+80 -4
View File
@@ -2,8 +2,17 @@ from datetime import datetime
from enum import StrEnum
from typing import Any
from sqlalchemy import BigInteger, Column, DateTime, LargeBinary, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import (
BigInteger,
Column,
DateTime,
ForeignKey,
Integer,
LargeBinary,
Text,
func,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlmodel import Field, SQLModel
@@ -203,6 +212,9 @@ class Job(SQLModel, table=True):
finished_at: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True))
)
run_after: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True))
)
class CapturePolicy(SQLModel, table=True):
@@ -344,7 +356,7 @@ class ChatHistory(SQLModel, table=True):
title: str | None = None
photo_unique_id: str | None = None
actor_id: int | None = Field(default=None, sa_column=Column(BigInteger))
ts: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False))
ts: datetime = Field(sa_column=Column(DateTime(timezone=True), primary_key=True))
raw: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False)
)
@@ -442,6 +454,7 @@ class Watch(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
account_id: int
kind: str
name: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
params: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False)
)
@@ -466,7 +479,11 @@ class Alert(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
account_id: int
watch_id: int = Field(foreign_key="watches.id")
watch_id: int = Field(
sa_column=Column(
Integer, ForeignKey("watches.id", ondelete="CASCADE"), nullable=False
)
)
ts: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False))
payload: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False)
@@ -543,3 +560,62 @@ class FileShareHit(SQLModel, table=True):
ip: str | None = None
user_agent: str | None = None
counted: bool = False
class Webhook(SQLModel, table=True):
__tablename__ = "webhooks"
id: int | None = Field(default=None, primary_key=True)
account_id: int
url: str = Field(sa_column=Column(Text, nullable=False))
secret: str = Field(sa_column=Column(Text, nullable=False))
enabled: bool = True
watch_ids: list[int] | None = Field(
default=None, sa_column=Column(ARRAY(Integer), nullable=True)
)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
class WebhookDelivery(SQLModel, table=True):
__tablename__ = "webhook_deliveries"
id: int | None = Field(default=None, sa_column=Column(BigInteger, primary_key=True))
webhook_id: int = Field(
sa_column=Column(
Integer, ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False
)
)
alert_id: int | None = Field(
default=None,
sa_column=Column(
Integer, ForeignKey("alerts.id", ondelete="CASCADE"), nullable=True
),
)
event: str = Field(sa_column=Column(Text, nullable=False))
status: str = Field(default="pending", sa_column=Column(Text, nullable=False))
attempts: int = 0
next_attempt_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
response_code: int | None = None
last_error: str | None = Field(default=None, sa_column=Column(Text))
body: str | None = Field(default=None, sa_column=Column(Text))
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
finished_at: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True))
)
+1
View File
@@ -41,6 +41,7 @@ class ApiSettings(BaseSettings):
host: str = "0.0.0.0" # noqa: S104
port: int = 8080
static_dir: str = "static"
public_url: str | None = None
class AuthSettings(BaseSettings):
+10 -2
View File
@@ -4,6 +4,7 @@ from typing import Literal
import asyncpg
BG_EVENTS_CHANNEL = "bg_events"
MESSAGE_IDS_PER_EVENT = 500
EventKind = Literal["message", "edit", "delete", "reaction", "presence", "receipt"]
@@ -22,8 +23,15 @@ async def notify_bg_event( # noqa: PLR0913
payload["chat_id"] = chat_id
if message_id is not None:
payload["message_id"] = message_id
if message_ids is not None:
payload["message_ids"] = message_ids
if message_ids is None:
await _notify(pool, payload)
return
for start in range(0, len(message_ids), MESSAGE_IDS_PER_EVENT):
chunk = message_ids[start : start + MESSAGE_IDS_PER_EVENT]
await _notify(pool, {**payload, "message_ids": chunk})
async def _notify(pool: asyncpg.Pool, payload: dict[str, object]) -> None:
await pool.execute(
"SELECT pg_notify($1, $2)", BG_EVENTS_CHANNEL, json.dumps(payload)
)
+10 -2
View File
@@ -1,4 +1,5 @@
import json
from datetime import timedelta
from typing import Any
import asyncpg
@@ -7,15 +8,22 @@ JOBS_CHANGED_CHANNEL = "jobs_changed"
async def enqueue(
pool: asyncpg.Pool, account_id: int, kind: str, params: dict[str, Any]
pool: asyncpg.Pool,
account_id: int,
kind: str,
params: dict[str, Any],
*,
cooldown: timedelta | None = None,
) -> int:
params_json = json.dumps(params)
existing = await pool.fetchval(
"SELECT id FROM jobs WHERE account_id = $1 AND kind = $2 "
"AND params = $3::jsonb AND status IN ('pending', 'running') LIMIT 1",
"AND params = $3::jsonb AND (status IN ('pending', 'running') "
"OR created_at > now() - $4::interval) LIMIT 1",
account_id,
kind,
params_json,
cooldown,
)
if existing is not None:
return existing
+87
View File
@@ -0,0 +1,87 @@
import asyncio
import contextlib
from collections.abc import Callable, Mapping
import asyncpg
from utils.env import env
from utils.logging import logger
RETRY_MIN_SECONDS = 1.0
RETRY_MAX_SECONDS = 60.0
PING_INTERVAL_SECONDS = 60.0
PING_TIMEOUT_SECONDS = 10.0
CLOSE_TIMEOUT_SECONDS = 5.0
OnNotify = Callable[[str], None]
class Listener:
def __init__(
self,
name: str,
channels: Mapping[str, OnNotify],
on_reconnect: Callable[[], None] | None = None,
) -> None:
self._name = name
self._channels = channels
self._on_reconnect = on_reconnect
self._task: asyncio.Task | None = None
self._ready = asyncio.Event()
self._listening = False
async def start(self) -> None:
self._task = asyncio.create_task(self._run())
await self._ready.wait()
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
async def _run(self) -> None:
delay = RETRY_MIN_SECONDS
while True:
try:
await self._listen()
except Exception as exc:
if self._listening:
delay = RETRY_MIN_SECONDS
logger.warning(
f"{self._name}: no listen connection ({exc!r}), "
f"reconnecting in {delay:.0f}s"
)
self._listening = False
self._ready.set()
await asyncio.sleep(delay)
delay = min(delay * 2, RETRY_MAX_SECONDS)
async def _listen(self) -> None:
conn = await asyncpg.connect(dsn=env.db.connection_url)
try:
lost = asyncio.Event()
conn.add_termination_listener(lambda _conn: lost.set())
for channel, on_notify in self._channels.items():
await conn.add_listener(
channel,
lambda _conn, _pid, _channel, payload, f=on_notify: f(payload),
)
logger.info(f"{self._name}: listening on {', '.join(self._channels)}")
self._listening = True
if self._ready.is_set() and self._on_reconnect is not None:
self._on_reconnect()
self._ready.set()
while True:
try:
await asyncio.wait_for(lost.wait(), PING_INTERVAL_SECONDS)
except TimeoutError:
await conn.fetchval("SELECT 1", timeout=PING_TIMEOUT_SECONDS)
else:
msg = "closed by the server"
raise ConnectionError(msg)
finally:
with contextlib.suppress(Exception):
await conn.close(timeout=CLOSE_TIMEOUT_SECONDS)
+20 -12
View File
@@ -4,23 +4,31 @@ import asyncpg
from utils.read.accounts import self_user_id
from utils.read.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 = 90
pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int, tz: str
) -> list[VolumeBucket]:
self_id = await self_user_id(pool, account_id)
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),
)
since, _ = await period_bounds(pool, days, tz)
rows = await pool.fetch(_VOLUME, account_id, chat_id, self_id, since, tz)
return [
VolumeBucket(
bucket=row["bucket"],
+12 -6
View File
@@ -10,7 +10,7 @@ from utils.read.models import (
MessageView,
Page,
)
from utils.read.read_receipts import read_up_to
from utils.read.read_receipts import read_times, read_up_to
_MESSAGE_COLS = (
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
@@ -285,12 +285,18 @@ async def _apply_read_status(
self_id = await self_user_id(pool, account_id)
if self_id is None:
return
marker = await read_up_to(pool, account_id, chat_id)
if marker is None:
own = [view for view in views if view.sender_id == self_id]
if not own:
return
for view in views:
if view.sender_id == self_id and view.message_id <= marker:
view.read = True
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
)
def _build_album(
+210 -2
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import date, datetime
from typing import Any
from pydantic import BaseModel
@@ -203,6 +203,7 @@ class MessageView(BaseModel):
is_sticker: bool = False
is_animated_emoji: bool = False
read: bool = False
read_at: datetime | None = None
class MessageVersionView(BaseModel):
@@ -361,8 +362,61 @@ 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: datetime
bucket: date
total: int
outgoing: int
incoming: int
@@ -387,6 +441,29 @@ 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
@@ -397,6 +474,54 @@ class PeerHistoryView(BaseModel):
is_deleted_account: bool
class ProfileChange(BaseModel):
field: str
old: str | None
new: str | None
changed_at: datetime
actor_id: int | None = None
message_id: int | None = None
class MembershipView(BaseModel):
chat_id: int
chat_title: str | None
has_avatar: bool
message_id: int
event: str
actor_id: int | None
actor_name: str | None
ts: datetime
class DirectChatView(BaseModel):
chat_id: int
message_count: int
last_date: datetime | None
class PeerOverview(BaseModel):
peer: PeerView | None
changes: list[ProfileChange]
avatars: list[AvatarHistoryView]
memberships: list[MembershipView]
dm: DirectChatView | None
class SharedGroupView(BaseModel):
chat_id: int
title: str | None
has_avatar: bool
message_count: int
last_date: datetime
last_message_id: int
class PeerGroups(BaseModel):
groups: list[SharedGroupView]
deleted_count: int
class ChatLinkView(BaseModel):
message_id: int
date: datetime | None
@@ -446,12 +571,33 @@ class WatchView(BaseModel):
id: int
account_id: int
kind: str
name: str | None
params: dict[str, Any]
enabled: bool
created_at: datetime
updated_at: datetime
class ChatRef(BaseModel):
id: int
title: str | None
kind: str
has_avatar: bool
class PersonRef(BaseModel):
id: int
name: str | None
has_avatar: bool
class WatchItem(WatchView):
chat: ChatRef | None
person: PersonRef | None
alert_count: int
last_alert_at: datetime | None
class AlertView(BaseModel):
id: int
account_id: int
@@ -462,6 +608,68 @@ class AlertView(BaseModel):
created_at: datetime
class AlertWatch(BaseModel):
id: int
kind: str
name: str | None
params: dict[str, Any]
class AlertMessage(BaseModel):
id: int
text: str | None
sender: PersonRef | None
date: datetime | None
deleted: bool
edited: bool
class AlertItem(BaseModel):
id: int
account_id: int
ts: datetime
seen: bool
event: str
watch: AlertWatch
chat: ChatRef | None
message: AlertMessage | None
peer: PersonRef | None
away_since: datetime | None
path: str
class AlertPage(BaseModel):
items: list[AlertItem]
next_before: int | None
unseen: int
class DeliveryView(BaseModel):
id: int
webhook_id: int
alert_id: int | None
event: str
status: str
attempts: int
next_attempt_at: datetime
response_code: int | None
last_error: str | None
created_at: datetime
finished_at: datetime | None
class WebhookView(BaseModel):
id: int
account_id: int
url: str
secret: str
enabled: bool
watch_ids: list[int] | None
created_at: datetime
updated_at: datetime
last_delivery: DeliveryView | None
class FileShareView(BaseModel):
id: int
account_id: int
+20 -8
View File
@@ -2,6 +2,8 @@ import asyncpg
from utils.read.models import Page, PeerHistoryView, PeerView, StoryView
HISTORY_LIMIT = 500
_PEER_COLS = (
"peer_id, first_name, last_name, username, phone, photo_unique_id, "
"is_deleted_account, updated_at, "
@@ -9,6 +11,23 @@ _PEER_COLS = (
"AND a.owner_id = peers.peer_id) AS has_avatar"
)
_HISTORY = """
SELECT observed_at, first_name, last_name, username, phone, photo_unique_id,
is_deleted_account
FROM (
SELECT observed_at, first_name, last_name, username, phone, photo_unique_id,
is_deleted_account,
lag((first_name, last_name, username, phone, photo_unique_id,
is_deleted_account)) OVER (ORDER BY observed_at) AS previous
FROM peer_history
WHERE account_id = $1 AND peer_id = $2
) states
WHERE previous IS DISTINCT FROM
(first_name, last_name, username, phone, photo_unique_id, is_deleted_account)
ORDER BY observed_at DESC
LIMIT $3
"""
async def get_peer(
pool: asyncpg.Pool, account_id: int, peer_id: int
@@ -39,14 +58,7 @@ async def get_peers(
async def get_peer_history(
pool: asyncpg.Pool, account_id: int, peer_id: int
) -> list[PeerHistoryView]:
rows = await pool.fetch(
"SELECT observed_at, first_name, last_name, username, phone, "
"photo_unique_id, is_deleted_account FROM peer_history "
"WHERE account_id = $1 AND peer_id = $2 "
"ORDER BY observed_at DESC LIMIT 500",
account_id,
peer_id,
)
rows = await pool.fetch(_HISTORY, account_id, peer_id, HISTORY_LIMIT)
return [PeerHistoryView(**dict(row)) for row in rows]
+194
View File
@@ -0,0 +1,194 @@
from collections.abc import Callable
from datetime import datetime
from itertools import pairwise
import asyncpg
from utils.read import chats
from utils.read.avatars import avatar_history
from utils.read.models import (
DirectChatView,
MembershipView,
PeerGroups,
PeerHistoryView,
PeerOverview,
ProfileChange,
SharedGroupView,
)
from utils.read.peers import HISTORY_LIMIT, get_peer, get_peer_history
_CHAT_TITLES = """
SELECT ts, title, previous, actor_id, message_id
FROM (
SELECT ts, title,
CASE WHEN event = 'title' THEN actor_id END AS actor_id,
CASE WHEN event = 'title' THEN message_id END AS message_id,
lag(title) OVER (ORDER BY ts, message_id) AS previous
FROM chat_history
WHERE account_id = $1 AND chat_id = $2 AND event IN ('meta', 'title')
AND title IS NOT NULL
) titles
WHERE previous IS DISTINCT FROM title AND previous IS NOT NULL
ORDER BY ts DESC
LIMIT $3
"""
_MEMBERSHIPS = """
SELECT m.chat_id, m.message_id, m.event, m.actor_id, m.ts,
COALESCE(NULLIF(trim(concat_ws(' ', a.first_name, a.last_name)), ''),
a.username) AS actor_name
FROM memberships m
LEFT JOIN peers a ON a.account_id = m.account_id AND a.peer_id = m.actor_id
WHERE m.account_id = $1 AND m.user_id = $2
ORDER BY m.ts DESC
LIMIT 200
"""
_DIRECT_CHAT = """
SELECT $2::bigint AS chat_id, COALESCE(cs.message_count, 0) AS message_count,
cs.last_date
FROM (SELECT 1) one
LEFT JOIN chat_stats cs ON cs.account_id = $1 AND cs.chat_id = $2
WHERE cs.chat_id IS NOT NULL
OR EXISTS (SELECT 1 FROM dialogs WHERE account_id = $1 AND chat_id = $2)
"""
_SENT = """
SELECT chat_id, count(*) AS message_count, max(date) AS last_date,
max(message_id) AS last_message_id,
count(*) FILTER (WHERE deleted_at IS NOT NULL) AS deleted_count
FROM messages
WHERE account_id = $1 AND sender_id = $2
GROUP BY chat_id
"""
type Point = tuple[datetime, str | None]
def _full_name(state: PeerHistoryView) -> str | None:
parts = [part for part in (state.first_name, state.last_name) if part]
return " ".join(parts) or None
def _timeline(
states: list[PeerHistoryView], value: Callable[[PeerHistoryView], str | None]
) -> list[Point]:
points: list[Point] = []
for state in states:
current = value(state)
if not points or points[-1][1] != current:
points.append((state.observed_at, current))
return points
def _without_gaps(points: list[Point]) -> list[Point]:
kept: list[Point] = []
for at, current in points:
gap = len(kept) > 1 and kept[-1][1] is None and kept[-2][1] == current
if gap:
kept.pop()
elif not kept or kept[-1][1] != current:
kept.append((at, current))
return kept
def _known(points: list[Point]) -> list[Point]:
return _without_gaps([point for point in points if point[1] is not None])
def _changes(field: str, points: list[Point]) -> list[ProfileChange]:
return [
ProfileChange(field=field, old=old, new=new, changed_at=at)
for (_, old), (at, new) in pairwise(points)
]
def profile_changes(states: list[PeerHistoryView]) -> list[ProfileChange]:
ordered = sorted(states, key=lambda state: state.observed_at)
alive = [state for state in ordered if not state.is_deleted_account]
deleted = _timeline(
ordered, lambda state: "deleted" if state.is_deleted_account else None
)
changes = [
*_changes("name", _without_gaps(_timeline(alive, _full_name))),
*_changes(
"username", _without_gaps(_timeline(alive, lambda state: state.username))
),
*_changes("phone", _known(_timeline(alive, lambda state: state.phone))),
*[change for change in _changes("deleted", deleted) if change.new is not None],
]
return sorted(changes, key=lambda change: change.changed_at, reverse=True)
async def get_chat_changes(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> list[ProfileChange]:
rows = await pool.fetch(_CHAT_TITLES, account_id, chat_id, HISTORY_LIMIT)
return [
ProfileChange(
field="title",
old=row["previous"],
new=row["title"],
changed_at=row["ts"],
actor_id=row["actor_id"],
message_id=row["message_id"],
)
for row in rows
]
async def _memberships(
pool: asyncpg.Pool, account_id: int, peer_id: int
) -> list[MembershipView]:
rows = await pool.fetch(_MEMBERSHIPS, account_id, peer_id)
ids = list({row["chat_id"] for row in rows})
known = {
chat.chat_id: chat for chat in await chats.get_chats(pool, account_id, ids)
}
views: list[MembershipView] = []
for row in rows:
chat = known.get(row["chat_id"])
views.append(
MembershipView(
**dict(row),
chat_title=chat.title if chat else None,
has_avatar=bool(chat and chat.has_avatar),
)
)
return views
async def get_overview(
pool: asyncpg.Pool, account_id: int, peer_id: int
) -> PeerOverview:
direct = await pool.fetchrow(_DIRECT_CHAT, account_id, peer_id)
return PeerOverview(
peer=await get_peer(pool, account_id, peer_id),
changes=profile_changes(await get_peer_history(pool, account_id, peer_id)),
avatars=await avatar_history(pool, account_id, peer_id),
memberships=await _memberships(pool, account_id, peer_id),
dm=DirectChatView(**dict(direct)) if direct else None,
)
async def get_groups(pool: asyncpg.Pool, account_id: int, peer_id: int) -> PeerGroups:
async with pool.acquire() as conn, conn.transaction():
await conn.execute("SET LOCAL jit = off")
rows = await conn.fetch(_SENT, account_id, peer_id)
sent = {row["chat_id"]: row for row in rows if row["chat_id"] < 0}
found = await chats.get_chats(pool, account_id, list(sent))
groups = [
SharedGroupView(
chat_id=chat.chat_id,
title=chat.title,
has_avatar=chat.has_avatar,
message_count=sent[chat.chat_id]["message_count"],
last_date=sent[chat.chat_id]["last_date"],
last_message_id=sent[chat.chat_id]["last_message_id"],
)
for chat in found
]
groups.sort(key=lambda group: group.last_date, reverse=True)
return PeerGroups(
groups=groups, deleted_count=sum(row["deleted_count"] for row in rows)
)
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
import asyncpg
DEFAULT_TZ = "UTC"
MAX_DAYS = 365
_timezones: set[str] = set()
async def known_timezone(pool: asyncpg.Pool, tz: str) -> bool:
if not _timezones:
rows = await pool.fetch("SELECT name FROM pg_timezone_names")
_timezones.update(row["name"] for row in rows)
return tz in _timezones
async def period_bounds(
pool: asyncpg.Pool, days: int, tz: str
) -> tuple[datetime, datetime]:
row = await pool.fetchrow(
"SELECT (date_trunc('day', now() AT TIME ZONE $1) "
"- make_interval(days => $2 - 1)) AT TIME ZONE $1 AS since, now() AS until",
tz,
days,
)
return row["since"], row["until"]
+289
View File
@@ -0,0 +1,289 @@
import json
import asyncpg
from utils.read.models import (
CoverageGap,
Page,
PresenceDay,
PresenceHour,
PresenceSession,
PresenceStats,
PresenceSummary,
)
from utils.read.period import period_bounds
from utils.read.presence import current_presence
SECONDS_IN_HOUR = 3600
SECONDS_IN_DAY = 86_400
MIN_CELL_COVERAGE = 1800
MIN_CELL_SHARE = 0.5
STATS_WORK_MEM = "32MB"
FUZZY_STATUSES = {"recently", "last_week", "last_month", "long_time_ago"}
_SESSIONS = """
marks AS (
SELECT ts, status,
least(next_offline_date, ts + interval '5 minutes',
lead(coalesce(last_online_date, ts)) OVER w, $4) AS ended,
lag(CASE WHEN status = 'online'
THEN least(next_offline_date, ts + interval '5 minutes')
ELSE coalesce(last_online_date, ts) END) OVER w AS previous
FROM presence
WHERE account_id = $1 AND peer_id = $2
AND ts >= $3::timestamptz - interval '5 minutes' AND ts <= $4
WINDOW w AS (ORDER BY ts)
),
online AS (
SELECT greatest(ts, $3) AS started, ended,
count(*) FILTER (WHERE previous IS NULL
OR ts > previous + interval '5 minutes')
OVER (ORDER BY ts) AS visit
FROM marks
WHERE status = 'online' AND ended > greatest(ts, $3)
),
sessions AS (
SELECT min(started) AS started, max(ended) AS ended,
extract(epoch FROM sum(ended - started))::float8 AS online_seconds
FROM online
GROUP BY visit
)
"""
_SESSION_FIELDS = """
started, ended, extract(epoch FROM ended - started)::float8 AS seconds,
online_seconds, ended >= $4 AS ongoing
"""
_SESSION_PAGE = f"""
WITH {_SESSIONS}
SELECT {_SESSION_FIELDS}
FROM sessions
ORDER BY started DESC
LIMIT $5 OFFSET $6
""" # noqa: S608
_COVERED_HOURS = """
SELECT local_hour,
extract(epoch FROM sum(
least(ended, (local_hour + interval '1 hour') AT TIME ZONE $5)
- greatest(started, local_hour AT TIME ZONE $5)))::float8 AS seconds
FROM uptime,
generate_series(date_trunc('hour', started AT TIME ZONE $5),
date_trunc('hour', ended AT TIME ZONE $5),
interval '1 hour') AS local_hour
GROUP BY local_hour
"""
_STATS = f"""
WITH {_SESSIONS},
uptime AS (
SELECT greatest(started_at, $3) AS started,
least(seen_at + interval '90 seconds', $4) AS ended
FROM capture_uptime
WHERE account_id = $1 AND started_at < $4
AND seen_at + interval '90 seconds' > $3
),
quarters AS (
SELECT quarter, sum(ended - started - spill) AS inside, sum(spill) AS spill
FROM (SELECT started, ended, quarter,
greatest(ended - quarter - interval '15 minutes',
interval '0') AS spill
FROM (SELECT started, ended,
date_bin('15 minutes', started, timestamptz 'epoch') AS quarter
FROM online) binned) split
GROUP BY quarter
),
online_hours AS (
SELECT date_trunc('hour', quarter AT TIME ZONE $5) AS local_hour,
extract(epoch FROM sum(share))::float8 AS seconds
FROM (SELECT quarter, inside AS share FROM quarters
UNION ALL
SELECT quarter + interval '15 minutes', spill FROM quarters) shares
GROUP BY 1
),
covered_hours AS ({_COVERED_HOURS}),
hours AS (
SELECT local_hour,
extract(epoch FROM
least((local_hour + interval '1 hour') AT TIME ZONE $5, $4)
- greatest(local_hour AT TIME ZONE $5, $3))::float8 AS span,
coalesce(online.seconds, 0) AS online,
least(3600, greatest(coalesce(covered.seconds, 0),
coalesce(online.seconds, 0))) AS covered
FROM generate_series(date_trunc('hour', $3 AT TIME ZONE $5),
date_trunc('hour', $4 AT TIME ZONE $5),
interval '1 hour') AS local_hour
LEFT JOIN online_hours AS online USING (local_hour)
LEFT JOIN covered_hours AS covered USING (local_hour)
),
week AS (
SELECT extract(isodow FROM local_hour)::int - 1 AS weekday,
extract(hour FROM local_hour)::int AS hour,
count(*) AS hours, sum(span) AS span,
sum(online) AS online, sum(covered) AS covered
FROM hours
GROUP BY 1, 2
),
days AS (
SELECT local_hour::date AS day, sum(online) AS online, sum(covered) AS covered
FROM hours
GROUP BY 1
),
uptime_reach AS (
SELECT started,
coalesce(max(ended) OVER (ORDER BY started
ROWS BETWEEN UNBOUNDED PRECEDING
AND 1 PRECEDING), $3) AS reached
FROM uptime
),
gaps AS (
SELECT reached AS started, started AS ended FROM uptime_reach
UNION ALL
SELECT coalesce(max(ended), $3), $4 FROM uptime
),
quiet AS (
SELECT hour
FROM week
GROUP BY hour
ORDER BY sum(online) / nullif(sum(covered), 0) NULLS LAST, hour
LIMIT 1
),
activity_days AS (
SELECT (sessions.started AT TIME ZONE $5) - make_interval(hours => quiet.hour)
AS started,
(sessions.ended AT TIME ZONE $5) - make_interval(hours => quiet.hour)
AS ended,
(($4 AT TIME ZONE $5) - make_interval(hours => quiet.hour))::date AS today
FROM sessions, quiet
),
daily AS (
SELECT extract(epoch FROM min(started - started::date))::float8 AS first_online,
extract(epoch FROM max(ended - started::date))::float8 AS last_offline
FROM activity_days
WHERE started::date < today
GROUP BY started::date
)
SELECT
(SELECT count(*) FROM sessions) AS session_count,
(SELECT coalesce(sum(online_seconds), 0) FROM sessions) AS online_seconds,
(SELECT sum(covered) FROM hours) AS covered_seconds,
(SELECT percentile_cont(0.5) WITHIN GROUP (
ORDER BY extract(epoch FROM ended - started)) FROM sessions) AS median_session,
(SELECT max(ended) FROM sessions) AS last_seen,
(SELECT hour FROM quiet) AS day_start,
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY first_online)
FROM daily) AS first_online,
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY last_offline)
FROM daily) AS last_offline,
(SELECT json_agg(json_build_object(
'weekday', weekday, 'hour', hour, 'hours', hours, 'span', span,
'online', online, 'covered', covered))
FROM week) AS week,
(SELECT json_agg(json_build_object(
'day', day, 'online_seconds', online, 'covered_seconds', covered)
ORDER BY day)
FROM days) AS days,
(SELECT json_agg(json_build_object('started', started, 'ended', ended)
ORDER BY started)
FROM gaps WHERE ended - started > interval '5 minutes') AS gaps,
(SELECT json_agg(row_to_json(page) ORDER BY page.started DESC)
FROM (SELECT {_SESSION_FIELDS} FROM sessions
ORDER BY started DESC LIMIT $6 OFFSET $7) page) AS sessions
""" # noqa: S608
def _time_of_day(shifted: float | None, day_start: int | None) -> int | None:
if shifted is None or day_start is None:
return None
return round(shifted + day_start * SECONDS_IN_HOUR) % SECONDS_IN_DAY
def _week(rows: list[dict]) -> list[PresenceHour]:
cells = {(row["weekday"], row["hour"]): row for row in rows}
week: list[PresenceHour] = []
for weekday in range(7):
for hour in range(24):
row = cells.get(
(weekday, hour), {"hours": 0, "span": 0, "online": 0, "covered": 0}
)
covered = row["covered"]
minutes = (
min(60.0, row["online"] / covered * 60)
if covered > 0
and covered >= min(MIN_CELL_COVERAGE, row["span"] * MIN_CELL_SHARE)
else None
)
week.append(
PresenceHour(
weekday=weekday,
hour=hour,
hours=row["hours"],
online_minutes=minutes,
covered_hours=covered / SECONDS_IN_HOUR,
)
)
return week
async def presence_stats( # noqa: PLR0913
pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str
) -> PresenceStats:
since, until = await period_bounds(pool, days, tz)
async with pool.acquire() as conn, conn.transaction():
await conn.execute(f"SET LOCAL work_mem = '{STATS_WORK_MEM}'")
row = await conn.fetchrow(
_STATS,
account_id,
peer_id,
since,
until,
tz,
page.capped_limit,
page.offset,
)
current = await current_presence(pool, account_id, peer_id)
sessions = [PresenceSession(**item) for item in json.loads(row["sessions"] or "[]")]
covered = row["covered_seconds"] or 0
online = row["online_seconds"]
return PresenceStats(
peer_id=peer_id,
tz=tz,
since=since,
until=until,
current=current,
online=current is not None
and current.status == "online"
and current.next_offline_date is not None
and current.next_offline_date > until,
hidden=row["session_count"] == 0
and current is not None
and current.status in FUZZY_STATUSES,
summary=PresenceSummary(
online_seconds=online,
daily_seconds=online / covered * SECONDS_IN_DAY
if covered >= SECONDS_IN_HOUR
else None,
covered_seconds=covered,
session_count=row["session_count"],
median_session_seconds=row["median_session"],
first_online=_time_of_day(row["first_online"], row["day_start"]),
last_offline=_time_of_day(row["last_offline"], row["day_start"]),
last_seen=row["last_seen"]
or (current.last_online_date if current else None),
),
week=_week(json.loads(row["week"] or "[]")),
days=[PresenceDay(**item) for item in json.loads(row["days"] or "[]")],
gaps=[CoverageGap(**item) for item in json.loads(row["gaps"] or "[]")],
sessions=sessions,
)
async def presence_sessions( # noqa: PLR0913
pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str
) -> list[PresenceSession]:
since, until = await period_bounds(pool, days, tz)
rows = await pool.fetch(
_SESSION_PAGE, account_id, peer_id, since, until, page.capped_limit, page.offset
)
return [PresenceSession(**dict(row)) for row in rows]
+178
View File
@@ -1,5 +1,89 @@
from datetime import UTC, datetime, timedelta
import asyncpg
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(
@@ -8,3 +92,97 @@ 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,
)
+299 -27
View File
@@ -4,13 +4,70 @@ from typing import Any
import asyncpg
from utils.read.models import AlertView, Page, WatchView
from utils.read import chats as chats_read
from utils.read import peers as peers_read
from utils.read.models import (
AlertItem,
AlertMessage,
AlertPage,
AlertView,
AlertWatch,
ChatListItem,
ChatRef,
PeerView,
PersonRef,
WatchItem,
WatchView,
)
WATCHES_CHANGED_CHANNEL = "watches_changed"
ALERTS_CHANGED_CHANNEL = "alerts_changed"
ALERT_PAGE = 50
MAX_ALERT_PAGE = 200
_WATCH_COLS = "id, account_id, kind, params, enabled, created_at, updated_at"
_WATCH_COLS = "id, account_id, kind, name, params, enabled, created_at, updated_at"
_ALERT_COLS = "id, account_id, watch_id, ts, payload, seen, created_at"
_LEGACY_EVENTS = {"keyword": "message", "peer_online": "online"}
_INSERT_ALERT = f"""
WITH alert AS (
INSERT INTO alerts (account_id, watch_id, ts, payload, seen, dedup_key)
VALUES ($1, $2, $3, $4::jsonb, false, $5)
ON CONFLICT (watch_id, dedup_key) WHERE dedup_key IS NOT NULL DO NOTHING
RETURNING {_ALERT_COLS}
), hooks AS (
INSERT INTO webhook_deliveries (webhook_id, alert_id, event)
SELECT w.id, alert.id, 'alert' FROM alert
JOIN webhooks w ON w.account_id = alert.account_id AND w.enabled
AND (w.watch_ids IS NULL OR alert.watch_id = ANY(w.watch_ids))
)
SELECT * FROM alert
""" # noqa: S608
_WATCH_STATS = """
SELECT watch_id, count(*) AS count, max(ts) AS last FROM alerts
WHERE watch_id = ANY($1::int[]) GROUP BY watch_id
"""
_ALERT_ROWS = """
SELECT a.id, a.account_id, a.ts, a.seen, a.payload,
w.id AS watch_id, w.kind AS watch_kind, w.name AS watch_name,
w.params AS watch_params,
m.text AS stored_text,
m.sender_id AS stored_sender, m.date AS stored_date,
m.deleted_at IS NOT NULL AS stored_deleted,
m.edited_at IS NOT NULL AS stored_edited
FROM alerts a
JOIN watches w ON w.id = a.watch_id
LEFT JOIN LATERAL (
SELECT text, sender_id, date, deleted_at, edited_at
FROM messages
WHERE account_id = a.account_id
AND chat_id = (a.payload->>'chat_id')::bigint
AND message_id = (a.payload->>'message_id')::bigint
ORDER BY date DESC LIMIT 1
) m ON true
"""
def _to_watch(row: asyncpg.Record) -> WatchView:
@@ -25,6 +82,10 @@ def _to_alert(row: asyncpg.Record) -> AlertView:
return AlertView(**data)
async def _notify(pool: asyncpg.Pool, channel: str, payload: dict[str, Any]) -> None:
await pool.execute("SELECT pg_notify($1, $2)", channel, json.dumps(payload))
async def list_watches(pool: asyncpg.Pool, account_id: int) -> list[WatchView]:
rows = await pool.fetch(
f"SELECT {_WATCH_COLS} FROM watches WHERE account_id = $1 " # noqa: S608
@@ -42,19 +103,21 @@ async def get_watch(pool: asyncpg.Pool, watch_id: int) -> WatchView | None:
return _to_watch(row) if row else None
async def create_watch(
async def create_watch( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
kind: str,
params: dict[str, Any],
*,
name: str | None = None,
enabled: bool = True,
) -> WatchView:
row = await pool.fetchrow(
"INSERT INTO watches (account_id, kind, params, enabled) " # noqa: S608
f"VALUES ($1, $2, $3::jsonb, $4) RETURNING {_WATCH_COLS}",
"INSERT INTO watches (account_id, kind, name, params, enabled) " # noqa: S608
f"VALUES ($1, $2, $3, $4::jsonb, $5) RETURNING {_WATCH_COLS}",
account_id,
kind,
name,
json.dumps(params),
enabled,
)
@@ -62,13 +125,22 @@ async def create_watch(
return _to_watch(row)
async def update_watch(
pool: asyncpg.Pool, watch_id: int, params: dict[str, Any], *, enabled: bool
async def update_watch( # noqa: PLR0913
pool: asyncpg.Pool,
watch_id: int,
kind: str,
params: dict[str, Any],
*,
name: str | None,
enabled: bool,
) -> WatchView | None:
row = await pool.fetchrow(
"UPDATE watches SET params = $2::jsonb, enabled = $3, updated_at = now() " # noqa: S608
"UPDATE watches SET kind = $2, name = $3, params = $4::jsonb, " # noqa: S608
"enabled = $5, updated_at = now() "
f"WHERE id = $1 RETURNING {_WATCH_COLS}",
watch_id,
kind,
name,
json.dumps(params),
enabled,
)
@@ -79,29 +151,210 @@ async def update_watch(
async def delete_watch(pool: asyncpg.Pool, watch_id: int) -> bool:
result = await pool.execute("DELETE FROM watches WHERE id = $1", watch_id)
if not result.endswith("1"):
account_id = await pool.fetchval(
"DELETE FROM watches WHERE id = $1 RETURNING account_id", watch_id
)
if account_id is None:
return False
await pool.execute(f"NOTIFY {WATCHES_CHANGED_CHANNEL}")
await _notify(pool, ALERTS_CHANGED_CHANNEL, {"account_id": account_id})
return True
def _chat_ref(chat_id: int, chat: ChatListItem | None, name: str | None) -> ChatRef:
if chat is None:
kind = "private" if chat_id > 0 else "group"
return ChatRef(id=chat_id, title=name, kind=kind, has_avatar=False)
kind = "channel" if chat.is_broadcast else chat.kind
return ChatRef(
id=chat_id, title=chat.title or name, kind=kind, has_avatar=chat.has_avatar
)
def _peer_name(peer: PeerView) -> str | None:
full = " ".join(part for part in (peer.first_name, peer.last_name) if part)
return full or peer.username
class Refs:
def __init__(self, chats: list[ChatListItem], people: list[PeerView]) -> None:
self._chats = {chat.chat_id: chat for chat in chats}
self._people = {peer.peer_id: peer for peer in people}
def chat(self, chat_id: int | None) -> ChatRef | None:
if chat_id is None:
return None
peer = self._people.get(chat_id)
name = _peer_name(peer) if peer else None
return _chat_ref(chat_id, self._chats.get(chat_id), name)
def person(self, person_id: int | None) -> PersonRef | None:
if person_id is None:
return None
peer = self._people.get(person_id)
chat = self._chats.get(person_id)
name = (_peer_name(peer) if peer else None) or (chat.title if chat else None)
has_avatar = (peer.has_avatar if peer else False) or bool(
chat and chat.has_avatar
)
return PersonRef(id=person_id, name=name, has_avatar=has_avatar)
async def load_refs(
pool: asyncpg.Pool, account_id: int, chat_ids: set[int], person_ids: set[int]
) -> Refs:
chats = await chats_read.get_chats(pool, account_id, sorted(chat_ids | person_ids))
people = await peers_read.get_peers(
pool, account_id, sorted(person_ids | {i for i in chat_ids if i > 0})
)
return Refs(chats, people)
def _int(value: object) -> int | None:
return value if isinstance(value, int) and not isinstance(value, bool) else None
def _watch_person(params: dict[str, Any]) -> int | None:
return _int(params.get("sender_id")) or _int(params.get("peer_id"))
async def describe_watches(
pool: asyncpg.Pool, account_id: int, watches: list[WatchView]
) -> list[WatchItem]:
targets = [
(_int(watch.params.get("chat_id")), _watch_person(watch.params))
for watch in watches
]
refs = await load_refs(
pool,
account_id,
{chat for chat, _ in targets if chat is not None},
{person for _, person in targets if person is not None},
)
stats = {
row["watch_id"]: row
for row in await pool.fetch(_WATCH_STATS, [watch.id for watch in watches])
}
return [
WatchItem(
**watch.model_dump(),
chat=refs.chat(chat),
person=refs.person(person),
alert_count=stats[watch.id]["count"] if watch.id in stats else 0,
last_alert_at=stats[watch.id]["last"] if watch.id in stats else None,
)
for watch, (chat, person) in zip(watches, targets, strict=True)
]
def _path(chat_id: int | None, message_id: int | None, peer_id: int | None) -> str:
if chat_id is not None and message_id is not None:
return f"/app/{chat_id}?message={message_id}"
if peer_id is not None:
return f"/app/{peer_id}"
return "/app"
def _alert_item(row: asyncpg.Record, refs: Refs) -> AlertItem:
payload = json.loads(row["payload"])
event = payload.get("event") or _LEGACY_EVENTS.get(row["watch_kind"], "message")
chat_id = _int(payload.get("chat_id"))
message_id = _int(payload.get("message_id"))
peer_id = _int(payload.get("peer_id"))
message = None
if message_id is not None:
date = row["stored_date"] or (
datetime.fromisoformat(payload["date"]) if payload.get("date") else None
)
message = AlertMessage(
id=message_id,
text=payload.get("text") or row["stored_text"],
sender=refs.person(_int(payload.get("sender_id")) or row["stored_sender"]),
date=date,
deleted=bool(row["stored_deleted"]) or event == "delete",
edited=bool(row["stored_edited"]) or event == "edit",
)
return AlertItem(
id=row["id"],
account_id=row["account_id"],
ts=row["ts"],
seen=row["seen"],
event=event,
watch=AlertWatch(
id=row["watch_id"],
kind=row["watch_kind"],
name=row["watch_name"],
params=json.loads(row["watch_params"]),
),
chat=refs.chat(chat_id),
message=message,
peer=refs.person(peer_id),
away_since=payload.get("since"),
path=_path(chat_id, message_id, peer_id),
)
async def _alert_items(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> list[AlertItem]:
chat_ids: set[int] = set()
person_ids: set[int] = set()
for row in rows:
payload = json.loads(row["payload"])
for key, bucket in (
("chat_id", chat_ids),
("sender_id", person_ids),
("peer_id", person_ids),
):
value = _int(payload.get(key))
if value is not None:
bucket.add(value)
if row["stored_sender"] is not None:
person_ids.add(row["stored_sender"])
refs = await load_refs(pool, account_id, chat_ids, person_ids)
return [_alert_item(row, refs) for row in rows]
async def list_alerts(
pool: asyncpg.Pool, account_id: int, page: Page, *, seen: bool | None = None
) -> list[AlertView]:
pool: asyncpg.Pool,
account_id: int,
*,
seen: bool | None = None,
before: int | None = None,
limit: int = ALERT_PAGE,
) -> AlertPage:
size = max(1, min(limit, MAX_ALERT_PAGE))
params: list[object] = [account_id]
where = "account_id = $1"
where = "a.account_id = $1"
if seen is not None:
params.append(seen)
where += f" AND seen = ${len(params)}"
params.append(page.capped_limit)
params.append(page.offset)
where += f" AND a.seen = ${len(params)}"
if before is not None:
params.append(before)
where += f" AND a.id < ${len(params)}"
params.append(size + 1)
rows = await pool.fetch(
f"SELECT {_ALERT_COLS} FROM alerts WHERE {where} " # noqa: S608
f"ORDER BY ts DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
*params,
f"{_ALERT_ROWS} WHERE {where} ORDER BY a.id DESC LIMIT ${len(params)}", *params
)
items = await _alert_items(pool, account_id, rows[:size])
return AlertPage(
items=items,
next_before=items[-1].id if len(rows) > size else None,
unseen=await unseen_count(pool, account_id),
)
async def get_alert(pool: asyncpg.Pool, alert_id: int) -> AlertItem | None:
row = await pool.fetchrow(f"{_ALERT_ROWS} WHERE a.id = $1", alert_id)
if row is None:
return None
return (await _alert_items(pool, row["account_id"], [row]))[0]
async def unseen_count(pool: asyncpg.Pool, account_id: int) -> int:
return await pool.fetchval(
"SELECT count(*) FROM alerts WHERE account_id = $1 AND NOT seen", account_id
)
return [_to_alert(row) for row in rows]
async def insert_alert(
@@ -113,10 +366,7 @@ async def insert_alert(
dedup_key: str | None = None,
) -> AlertView | None:
row = await pool.fetchrow(
"INSERT INTO alerts (account_id, watch_id, ts, payload, seen, dedup_key) " # noqa: S608
"VALUES ($1, $2, $3, $4::jsonb, false, $5) "
"ON CONFLICT (watch_id, dedup_key) WHERE dedup_key IS NOT NULL "
f"DO NOTHING RETURNING {_ALERT_COLS}",
_INSERT_ALERT,
account_id,
watch_id,
datetime.now(UTC),
@@ -125,10 +375,32 @@ async def insert_alert(
)
if row is None:
return None
await pool.execute(f"NOTIFY {ALERTS_CHANGED_CHANNEL}")
await _notify(
pool, ALERTS_CHANGED_CHANNEL, {"account_id": account_id, "alert_id": row["id"]}
)
return _to_alert(row)
async def mark_alert_seen(pool: asyncpg.Pool, alert_id: int) -> bool:
result = await pool.execute("UPDATE alerts SET seen = true WHERE id = $1", alert_id)
return result.endswith("1")
account_id = await pool.fetchval(
"UPDATE alerts SET seen = true WHERE id = $1 RETURNING account_id", alert_id
)
if account_id is None:
return False
await _notify(pool, ALERTS_CHANGED_CHANNEL, {"account_id": account_id})
return True
async def mark_all_seen(
pool: asyncpg.Pool, account_id: int, up_to: int | None = None
) -> int:
result = await pool.execute(
"UPDATE alerts SET seen = true WHERE account_id = $1 AND NOT seen "
"AND ($2::int IS NULL OR id <= $2)",
account_id,
up_to,
)
marked = int(result.split()[-1])
if marked:
await _notify(pool, ALERTS_CHANGED_CHANNEL, {"account_id": account_id})
return marked
+198
View File
@@ -0,0 +1,198 @@
import json
import secrets
from datetime import datetime
import asyncpg
from utils.read.models import DeliveryView, WebhookView
STATUS_PENDING = "pending"
STATUS_SENDING = "sending"
STATUS_DELIVERED = "delivered"
STATUS_FAILED = "failed"
DELIVERY_LOG = 20
_DELIVERY_COLS = (
"id, webhook_id, alert_id, event, status, attempts, next_attempt_at, "
"response_code, last_error, created_at, finished_at"
)
_HOOKS = f"""
SELECT w.id, w.account_id, w.url, w.secret, w.enabled, w.watch_ids,
w.created_at, w.updated_at, to_jsonb(d) AS last_delivery
FROM webhooks w
LEFT JOIN LATERAL (
SELECT {_DELIVERY_COLS} FROM webhook_deliveries
WHERE webhook_id = w.id ORDER BY id DESC LIMIT 1
) d ON true
""" # noqa: S608
_CLAIM = """
UPDATE webhook_deliveries d SET status = 'sending', attempts = d.attempts + 1
FROM webhooks w
WHERE w.id = d.webhook_id AND d.id IN (
SELECT id FROM webhook_deliveries
WHERE status = 'pending' AND next_attempt_at <= now()
ORDER BY next_attempt_at LIMIT $1
FOR UPDATE SKIP LOCKED
)
RETURNING d.id, d.alert_id, d.event, d.attempts, d.body,
w.id AS webhook_id, w.account_id, w.url, w.secret
"""
_FINISH = """
UPDATE webhook_deliveries SET
status = $2, response_code = $3, last_error = $4,
next_attempt_at = coalesce($5, next_attempt_at),
finished_at = CASE WHEN $2 = 'pending' THEN NULL ELSE now() END
WHERE id = $1
"""
def new_secret() -> str:
return secrets.token_hex(32)
def _to_webhook(row: asyncpg.Record) -> WebhookView:
data = dict(row)
last = data.pop("last_delivery")
return WebhookView(
**data, last_delivery=DeliveryView(**json.loads(last)) if last else None
)
async def list_webhooks(pool: asyncpg.Pool, account_id: int) -> list[WebhookView]:
rows = await pool.fetch(
f"{_HOOKS} WHERE w.account_id = $1 ORDER BY w.id", account_id
)
return [_to_webhook(row) for row in rows]
async def get_webhook(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
row = await pool.fetchrow(f"{_HOOKS} WHERE w.id = $1", webhook_id)
return _to_webhook(row) if row else None
async def create_webhook(
pool: asyncpg.Pool,
account_id: int,
url: str,
watch_ids: list[int] | None,
*,
enabled: bool,
) -> WebhookView | None:
webhook_id = await pool.fetchval(
"INSERT INTO webhooks (account_id, url, secret, enabled, watch_ids) "
"VALUES ($1, $2, $3, $4, $5) RETURNING id",
account_id,
url,
new_secret(),
enabled,
watch_ids,
)
return await get_webhook(pool, webhook_id)
async def update_webhook(
pool: asyncpg.Pool,
webhook_id: int,
url: str,
watch_ids: list[int] | None,
*,
enabled: bool,
) -> WebhookView | None:
async with pool.acquire() as conn, conn.transaction():
found = await conn.fetchval(
"UPDATE webhooks SET url = $2, enabled = $3, watch_ids = $4, "
"updated_at = now() WHERE id = $1 RETURNING id",
webhook_id,
url,
enabled,
watch_ids,
)
if found is not None and not enabled:
await conn.execute(
"UPDATE webhook_deliveries SET status = 'failed', "
"last_error = 'endpoint disabled', finished_at = now() "
"WHERE webhook_id = $1 AND status = 'pending'",
webhook_id,
)
return await get_webhook(pool, webhook_id)
async def rotate_secret(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
await pool.execute(
"UPDATE webhooks SET secret = $2, updated_at = now() WHERE id = $1",
webhook_id,
new_secret(),
)
return await get_webhook(pool, webhook_id)
async def delete_webhook(pool: asyncpg.Pool, webhook_id: int) -> bool:
result = await pool.execute("DELETE FROM webhooks WHERE id = $1", webhook_id)
return result.endswith("1")
async def list_deliveries(
pool: asyncpg.Pool, webhook_id: int, limit: int = DELIVERY_LOG
) -> list[DeliveryView]:
rows = await pool.fetch(
f"SELECT {_DELIVERY_COLS} FROM webhook_deliveries " # noqa: S608
"WHERE webhook_id = $1 ORDER BY id DESC LIMIT $2",
webhook_id,
limit,
)
return [DeliveryView(**dict(row)) for row in rows]
async def get_delivery(pool: asyncpg.Pool, delivery_id: int) -> DeliveryView | None:
row = await pool.fetchrow(
f"SELECT {_DELIVERY_COLS} FROM webhook_deliveries WHERE id = $1", # noqa: S608
delivery_id,
)
return DeliveryView(**dict(row)) if row else None
async def start_test(pool: asyncpg.Pool, webhook_id: int, body: str) -> int:
return await pool.fetchval(
"INSERT INTO webhook_deliveries (webhook_id, event, status, attempts, body) "
"VALUES ($1, 'test', 'sending', 1, $2) RETURNING id",
webhook_id,
body,
)
async def release_stuck(pool: asyncpg.Pool) -> None:
await pool.execute(
"UPDATE webhook_deliveries SET status = 'pending' WHERE status = 'sending'"
)
async def claim_due(pool: asyncpg.Pool, limit: int) -> list[asyncpg.Record]:
return await pool.fetch(_CLAIM, limit)
async def save_body(pool: asyncpg.Pool, delivery_id: int, body: str) -> None:
await pool.execute(
"UPDATE webhook_deliveries SET body = $2 WHERE id = $1", delivery_id, body
)
async def finish( # noqa: PLR0913
pool: asyncpg.Pool,
delivery_id: int,
status: str,
response_code: int | None,
error: str | None,
next_attempt_at: datetime | None = None,
) -> None:
await pool.execute(
_FINISH, delivery_id, status, response_code, error, next_attempt_at
)
async def next_due(pool: asyncpg.Pool) -> datetime | None:
return await pool.fetchval(
"SELECT min(next_attempt_at) FROM webhook_deliveries WHERE status = 'pending'"
)
+145
View File
@@ -0,0 +1,145 @@
import re
from collections.abc import Iterable, Iterator
from importlib import import_module
from typing import Any, Literal, Self, get_args
from pydantic import BaseModel, Field, ValidationError, model_validator
MAX_PATTERN = 200
MAX_TEXT = 4096
MAX_NAME = 120
WatchKind = Literal[
"keyword", "new_message", "message_deleted", "message_edited", "peer_online"
]
WATCH_KINDS: tuple[str, ...] = get_args(WatchKind)
sre_parser = import_module("re._parser")
_REPEATS = {sre_parser.MAX_REPEAT, sre_parser.MIN_REPEAT, sre_parser.POSSESSIVE_REPEAT}
class WatchParamsError(ValueError):
pass
def _subpatterns(value: object) -> Iterator[Any]:
if isinstance(value, sre_parser.SubPattern):
yield value
elif isinstance(value, tuple | list):
for item in value:
yield from _subpatterns(item)
def _repeat_depth(items: Iterable[tuple[Any, Any]]) -> int:
depth = 0
for op, value in items:
if op in _REPEATS:
low, high, body = value
depth = max(depth, _repeat_depth(body) + (high > max(low, 1)))
else:
inner = (_repeat_depth(sub) for sub in _subpatterns(value))
depth = max(depth, *inner, 0)
return depth
def compile_pattern(pattern: str) -> re.Pattern[str]:
try:
compiled = re.compile(pattern, re.IGNORECASE)
except re.error as exc:
msg = f"invalid regex: {exc}"
raise ValueError(msg) from exc
if _repeat_depth(sre_parser.parse(pattern)) > 1:
msg = "regex repeats inside a repeat, like (a+)+, which can hang"
raise ValueError(msg)
return compiled
class KeywordParams(BaseModel):
pattern: str = Field(min_length=1, max_length=MAX_PATTERN)
regex: bool = False
chat_id: int | None = None
sender_id: int | None = None
@model_validator(mode="before")
@classmethod
def _legacy_keyword(cls, data: object) -> object:
if isinstance(data, dict) and "pattern" not in data and "keyword" in data:
return {**data, "pattern": data["keyword"]}
return data
@model_validator(mode="after")
def _valid_pattern(self) -> Self:
if not self.pattern.strip():
msg = "pattern is blank"
raise ValueError(msg)
if self.regex:
compile_pattern(self.pattern)
return self
class NewMessageParams(BaseModel):
chat_id: int | None = None
sender_id: int | None = None
@model_validator(mode="after")
def _scoped(self) -> Self:
if self.chat_id is None and self.sender_id is None:
msg = "set chat_id, sender_id or both"
raise ValueError(msg)
return self
class DeletedParams(BaseModel):
chat_id: int | None = None
class EditedParams(BaseModel):
chat_id: int | None = None
class PeerOnlineParams(BaseModel):
peer_id: int
WatchParams = (
KeywordParams | NewMessageParams | DeletedParams | EditedParams | PeerOnlineParams
)
PARAMS_BY_KIND: dict[str, type[WatchParams]] = {
"keyword": KeywordParams,
"new_message": NewMessageParams,
"message_deleted": DeletedParams,
"message_edited": EditedParams,
"peer_online": PeerOnlineParams,
}
def _describe(exc: ValidationError) -> str:
parts = []
for error in exc.errors():
where = ".".join(str(part) for part in error["loc"])
message = error["msg"].removeprefix("Value error, ")
parts.append(f"{where}: {message}" if where else message)
return "; ".join(parts)
def parse_params(kind: str, params: dict[str, Any]) -> WatchParams:
model = PARAMS_BY_KIND.get(kind)
if model is None:
msg = f"unknown kind {kind!r}, expected one of {', '.join(WATCH_KINDS)}"
raise WatchParamsError(msg)
try:
return model.model_validate(params)
except ValidationError as exc:
raise WatchParamsError(_describe(exc)) from exc
def normalize_params(kind: str, params: dict[str, Any]) -> dict[str, Any]:
return parse_params(kind, params).model_dump(exclude_none=True)
def clean_name(name: str | None) -> str | None:
if name is None:
return None
name = name.strip()[:MAX_NAME]
return name or None
+53 -16
View File
@@ -1,6 +1,7 @@
import asyncio
import contextlib
from dataclasses import dataclass
from datetime import timedelta
import asyncpg
from pyrogram import Client, enums
@@ -15,6 +16,7 @@ from userbot.modules.capture import capture_message
from userbot.modules.capture.chat_meta import meta_from_chat
from userbot.modules.client import PyroClient
from userbot.modules.jobs import JobConsumer
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event
from utils.jobs import enqueue
from utils.logging import logger
@@ -29,6 +31,7 @@ TOKEN_MISSING = "Токен не найден на диске" # noqa: S105
INVALID_TOKEN_ERRORS = ("ACCESS_TOKEN_INVALID", "ACCESS_TOKEN_EXPIRED")
BACKLOG_ATTEMPTS = 3
BACKLOG_RETRY_PAUSE = 60
ENRICH_COOLDOWN = timedelta(hours=6)
@dataclass
@@ -51,9 +54,11 @@ def _handlers(pool: asyncpg.Pool) -> tuple:
if ctx is None or message.empty or message.chat is None or message.date is None:
return
meta = meta_from_chat(message.chat, ctx.contacts.ids)
await ctx.watches.on_text(
meta.chat_id, message.id, message.text or message.caption
)
seen = seen_message(meta.chat_id, message)
if message.edit_date is None:
await ctx.watches.on_message(seen)
else:
await ctx.watches.on_edit(seen)
toggles = ctx.resolve(meta)
if not toggles.messages:
return
@@ -67,14 +72,24 @@ def _handlers(pool: asyncpg.Pool) -> tuple:
)
if message.chat.type is not enums.ChatType.PRIVATE:
await enqueue(
pool, ctx.account_id, "enrich_chat", {"chat_id": meta.chat_id}
pool,
ctx.account_id,
"enrich_chat",
{"chat_id": meta.chat_id},
cooldown=ENRICH_COOLDOWN,
)
async def on_member(client: Client, update) -> None: # noqa: ANN001
ctx = getattr(client, "capture", None)
if ctx is None or update.chat is None:
return
await enqueue(pool, ctx.account_id, "enrich_chat", {"chat_id": update.chat.id})
await enqueue(
pool,
ctx.account_id,
"enrich_chat",
{"chat_id": update.chat.id},
cooldown=ENRICH_COOLDOWN,
)
return (
MessageHandler(on_message),
@@ -115,6 +130,12 @@ class BotRegistry:
for bot_id in list(self._running):
await self._stop(bot_id)
async def refresh_watches(self) -> None:
for running in list(self._running.values()):
capture = getattr(running.client, "capture", None)
if capture is not None:
await capture.watches.refresh()
async def _adopt_dropped_in(self) -> None:
unknown = tokens.stored_bot_ids() - await bots.known_bot_ids(self._pool)
for bot_id in unknown:
@@ -167,17 +188,33 @@ class BotRegistry:
await client.stop()
return
account_id = await accounts.sync_account(
self._pool, me, str(bot_id), is_bot=True
)
client.capture = await build_bot_capture_context(
client, self._pool, self._storage, account_id
)
await bots.link_account(self._pool, bot_id, account_id)
await bots.set_status(
self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name
)
await accounts.notify_accounts_changed(self._pool)
try:
account_id = await accounts.sync_account(
self._pool, me, str(bot_id), is_bot=True
)
client.capture = await build_bot_capture_context(
client, self._pool, self._storage, account_id
)
await bots.link_account(self._pool, bot_id, account_id)
await bots.set_status(
self._pool,
bot_id,
bots.STATUS_OK,
username=me.username,
title=me.full_name,
)
await accounts.notify_accounts_changed(self._pool)
except Exception as exc:
logger.exception(
f"[red]Bot {bot_id} failed to start,[/] retrying on the next sync"
)
with contextlib.suppress(Exception):
await bots.set_status(
self._pool, bot_id, bots.STATUS_ERROR, detail=str(exc)
)
with contextlib.suppress(Exception):
await client.stop()
return
consumer = JobConsumer(client, self._pool, account_id)
self._running[bot_id] = RunningBot(
+24 -16
View File
@@ -6,9 +6,10 @@ import asyncpg
import uvloop
from dependencies.container import container
from utils.env import env
from utils.listen import Listener
from utils.logging import logger, setup_logging
from utils.read.bots import BOTS_CHANGED_CHANNEL
from utils.read.watches import WATCHES_CHANGED_CHANNEL
from utils.storage import ContentAddressedStorage
from watcher.registry import BotRegistry
@@ -20,23 +21,32 @@ RETRY_INTERVAL = 300
async def _retry_loop(registry: BotRegistry) -> None:
while True:
await asyncio.sleep(RETRY_INTERVAL)
with contextlib.suppress(Exception):
try:
await registry.sync()
except Exception:
logger.exception("[red]Bot sync failed[/]")
async def _listen_changes(
registry: BotRegistry, tasks: set[asyncio.Task]
) -> asyncpg.Connection:
def on_change(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
) -> None:
def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener:
def on_change(_payload: str) -> None:
task = asyncio.create_task(registry.sync())
tasks.add(task)
task.add_done_callback(tasks.discard)
conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener(BOTS_CHANGED_CHANNEL, on_change)
return conn
def on_watch(_payload: str) -> None:
task = asyncio.create_task(registry.refresh_watches())
tasks.add(task)
task.add_done_callback(tasks.discard)
def on_reconnect() -> None:
on_change("")
on_watch("")
return Listener(
"watcher",
{BOTS_CHANGED_CHANNEL: on_change, WATCHES_CHANGED_CHANNEL: on_watch},
on_reconnect,
)
async def runner() -> None:
@@ -44,7 +54,7 @@ async def runner() -> None:
storage = await container.get(ContentAddressedStorage)
registry = BotRegistry(pool, storage)
tasks: set[asyncio.Task] = set()
listen_conn: asyncpg.Connection | None = None
listener = _change_listener(registry, tasks)
stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
retry = asyncio.create_task(_retry_loop(registry))
@@ -52,16 +62,14 @@ async def runner() -> None:
await registry.sync()
if not registry.running:
logger.warning("[yellow]No bots yet. Add a bot token in the web UI.[/]")
listen_conn = await _listen_changes(registry, tasks)
await listener.start()
logger.info("[green]Bot watcher running.[/]")
await stop.wait()
finally:
retry.cancel()
with contextlib.suppress(asyncio.CancelledError):
await retry
if listen_conn is not None:
with contextlib.suppress(Exception):
await listen_conn.close()
await listener.stop()
await registry.close()
await container.close()
+20 -29
View File
@@ -21,16 +21,16 @@ wheels = [
[[package]]
name = "alembic"
version = "1.20.1"
version = "1.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/1c/00909e0fbebad2121d916fdcab044cae988146680e9ee4387ba25fef8747/alembic-1.20.1.tar.gz", hash = "sha256:83490def1f723413db5096591f7609ac52544805be93849170088ed04f5a2e0b", size = 2103576, upload-time = "2026-10-22T03:24:35.93Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/3c/8740f5b4e1bd57333e4f84265b9b6363da4f7cf70f5622dd1e2fde7a0bd5/alembic-1.20.1-py3-none-any.whl", hash = "sha256:9970712cfb81ea26c8658d378aaa5b8f6ff9146e5bb111d70497c164016cf598", size = 270355, upload-time = "2026-10-22T03:24:37.667Z" },
{ url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" },
]
[[package]]
@@ -149,6 +149,7 @@ dependencies = [
{ name = "fastapi" },
{ name = "fastmcp" },
{ name = "greenlet" },
{ name = "httpx2" },
{ name = "kurigram" },
{ name = "pydantic-settings" },
{ name = "rich" },
@@ -166,6 +167,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "fastmcp", specifier = ">=3.3" },
{ name = "greenlet", specifier = ">=3.1.0" },
{ name = "httpx2", specifier = ">=2.13.1" },
{ name = "kurigram", specifier = ">=2.2.23" },
{ name = "pydantic-settings", specifier = ">=2.7.0" },
{ name = "rich", specifier = ">=13.9.0" },
@@ -226,15 +228,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
]
[[package]]
name = "certifi"
version = "2026.10.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
@@ -420,7 +413,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.159.1"
version = "0.143.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -430,26 +423,26 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/40/a212bffb9809afb9a94b250f11e0c29fd8b2589ec92e4bedd1cc7adf9938/fastapi-0.159.1.tar.gz", hash = "sha256:b45b1ec57f3aa4d2f80f09bfbcf068b46764a2ee892e56753f35bb3d9d845ce2", size = 522004, upload-time = "2026-10-21T20:59:29.054Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/35/b03b5df04a1a8ee400c9df4b1795af84370b2bb6f1cc4be57c0b79688d00/fastapi-0.159.1-py3-none-any.whl", hash = "sha256:49b94c29afec2fca1390e9c39be202c59755d5cda8e4f1148a133761e4313e7e", size = 149604, upload-time = "2026-10-21T20:59:27.58Z" },
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
]
[[package]]
name = "fastmcp"
version = "4.1.3"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastmcp-slim", extra = ["client", "server"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/ea/f4/79169f9a2419e1f541b3b39b1bda1c57569b4c7c1ac4b606750c81471ad1/fastmcp-4.1.3.tar.gz", hash = "sha256:a1f79e9db929a1603c75331df603f74530fc236aa711e0ee2e185821c6d9888d", size = 42755717, upload-time = "2026-10-20T22:32:28.005Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/c4/fe4e24af2cb03f4701f45634873d87557c2b5d283d287412345345ad0ede/fastmcp-4.1.0.tar.gz", hash = "sha256:7a8bf4e58cc6c2f3a8552b5a7a17ba1e4682b6fd4cafb7f461920a66dbd2499f", size = 42686511, upload-time = "2026-10-08T22:57:05.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/7f/27c501d98b5681ca4f6c21a7d741b3e732e962c5e4c3b4db6dd1ffdf4fd8/fastmcp-4.1.3-py3-none-any.whl", hash = "sha256:b983d8acc44ab1143e0c86fd6da843f25043ee0aeb64ab983b36c527bd1d33e9", size = 8153, upload-time = "2026-10-20T22:32:22.881Z" },
{ url = "https://files.pythonhosted.org/packages/5c/81/77610de5d79187455ec4a4eafdc17942365f1bc4e5850633e50159e9afdc/fastmcp-4.1.0-py3-none-any.whl", hash = "sha256:cf19fd603315b48ef1829c8579431e77793feafdcbed51f0dc5607af10a24e9b", size = 8154, upload-time = "2026-10-08T22:57:02.531Z" },
]
[[package]]
name = "fastmcp-slim"
version = "4.1.3"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mcp-types" },
@@ -460,9 +453,9 @@ dependencies = [
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/86/01705c74106297c021e5f8a22ed051adb43061cc779cf543dc05a4d47aba/fastmcp_slim-4.1.3.tar.gz", hash = "sha256:d95277db0979e4cbfa84528cf9016ae079494b41ed8e88cc909fee8233db6035", size = 749467, upload-time = "2026-10-20T22:30:58.047Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/88/85aafec43ec9940a360d75f2d8e9517eda01ac38b8b1610b0e9e125d61d3/fastmcp_slim-4.1.0.tar.gz", hash = "sha256:390b2b430195e0cdd0327aff80fe5dceb862b2421baf71de6d4eec92f80f99f8", size = 734647, upload-time = "2026-10-08T22:56:40.642Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/ff/a7feaeb130a2003700789ce32e933b4c2ae00fc7c264642e57ab12e4684f/fastmcp_slim-4.1.3-py3-none-any.whl", hash = "sha256:81af8699331c33d438caf85b6947f36df6e2f04118e6f318ca00374fd9afea9a", size = 931046, upload-time = "2026-10-20T22:30:56.182Z" },
{ url = "https://files.pythonhosted.org/packages/54/6a/d61e1541f4c65872146a576c1379f6f32013fd8c636bcaf0f8e70a04e533/fastmcp_slim-4.1.0-py3-none-any.whl", hash = "sha256:6edb48a78e508ef5ac691bf2a68f3e5000403773655d859eceea1068b894df4a", size = 911585, upload-time = "2026-10-08T22:56:39.136Z" },
]
[package.optional-dependencies]
@@ -479,7 +472,6 @@ client = [
server = [
{ name = "authlib" },
{ name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "certifi" },
{ name = "cyclopts" },
{ name = "exceptiongroup" },
{ name = "griffelib" },
@@ -922,14 +914,14 @@ wheels = [
[[package]]
name = "openapi-pydantic"
version = "0.6.0"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/32/0c9bd3e4e847cd6117b64dbef4cf810faa9cdbd6689323b811569cc8a1b8/openapi_pydantic-0.6.0.tar.gz", hash = "sha256:11f3ac6ad41521fc156381ec587246b0c0ecea523bd9565ed74d3e7fac9d91cd", size = 1667049, upload-time = "2026-10-02T12:20:00.508Z" }
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/57/9d3eb823a03c33e9d4c810e6a3276e3a6dc820a7fdc0961db2c04e613817/openapi_pydantic-0.6.0-py3-none-any.whl", hash = "sha256:8bd6d548a250da37db96f461bcd52b3468e8834ad7d6db14b00a6b2ebbd163a3", size = 96931, upload-time = "2026-10-02T12:19:58.917Z" },
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
]
[[package]]
@@ -1090,17 +1082,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.16.1"
version = "2.15.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/05/ca/7817b4bf0c4b15c6a6ab8cdf4cb96c268335a8ca2f525de93b6ebe4c7d28/pydantic_settings-2.16.1.tar.gz", hash = "sha256:aff602a1886338c2df3d1e958e2193a8dc33cec75b91f202e83fd98484b4b0da", size = 303436, upload-time = "2026-10-20T11:37:19.024Z" }
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/74/fdf926afe145d8629c4b2ff57f4ea1d64ed7abc4d84d97463b2517a2547e/pydantic_settings-2.16.1-py3-none-any.whl", hash = "sha256:30f5313340da4e8f1b440d1910b92a5bc3ccbb6b4ba70ec2b8395bc921f418f5", size = 80771, upload-time = "2026-10-20T11:37:17.298Z" },
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
]
[[package]]
+10 -1
View File
@@ -1,9 +1,18 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<meta content="scale" name="text-scale" />
<meta content="#ffffff" media="(prefers-color-scheme: light)" name="theme-color" />
<meta content="#212121" media="(prefers-color-scheme: dark)" name="theme-color" />
<meta content="yes" name="mobile-web-app-capable" />
<meta content="black-translucent" name="apple-mobile-web-app-status-bar-style" />
<meta content="Beavergram" name="apple-mobile-web-app-title" />
<link href="/icon.svg" rel="icon" type="image/svg+xml" />
<link href="/apple-touch-icon.png" rel="apple-touch-icon" />
<link href="/manifest.webmanifest" rel="manifest" />
<title>Beavergram</title>
<script>
(() => {
const stored = localStorage.getItem("bg.theme");
+21 -7
View File
@@ -4,13 +4,27 @@ import { PUBLIC_API_BASE_URL } from "$app/env/public";
const BASE = PUBLIC_API_BASE_URL;
const RETRY_DELAY = 2500;
const MISSING_TTL = 120_000;
export type AvatarKind = "peer" | "chat";
const MAX_CACHED = 240;
const ready = new Map<string, string>();
const missing = new Set<string>();
const missing = new Map<string, number>();
function isMissing(key: string): boolean {
const since = missing.get(key);
if (since === undefined) {
return false;
}
if (Date.now() - since < MISSING_TTL) {
return true;
}
missing.delete(key);
return false;
}
const inflight = new Map<string, Promise<string | null>>();
function cacheKey(account: number, kind: AvatarKind, id: number): string {
@@ -60,15 +74,15 @@ async function fetchAvatar(
await delay(RETRY_DELAY);
return fetchAvatar(account, kind, id, key, false);
}
missing.add(key);
missing.set(key, Date.now());
return null;
}
export function loadAvatar(
kind: AvatarKind,
id: number
id: number,
account: number | null = accounts.selectedId
): Promise<string | null> {
const account = accounts.selectedId;
if (account === null) {
return Promise.resolve(null);
}
@@ -77,7 +91,7 @@ export function loadAvatar(
if (cached) {
return Promise.resolve(cached);
}
if (missing.has(key)) {
if (isMissing(key)) {
return Promise.resolve(null);
}
const existing = inflight.get(key);
@@ -110,7 +124,7 @@ async function fetchVariant(
await delay(RETRY_DELAY);
return fetchVariant(account, kind, id, uniqueId, key, false);
}
missing.add(key);
missing.set(key, Date.now());
return null;
}
@@ -128,7 +142,7 @@ export function loadAvatarVariant(
if (cached) {
return Promise.resolve(cached);
}
if (missing.has(key)) {
if (isMissing(key)) {
return Promise.resolve(null);
}
const existing = inflight.get(key);
+52 -55
View File
@@ -1,7 +1,6 @@
import { request } from "#lib/api/client.js";
import type {
Account,
Alert,
Annotation,
AvatarHistoryView,
CallbackView,
@@ -20,7 +19,8 @@ import type {
MessageAt,
MessageVersion,
MessageView,
PeerHistoryView,
PeerGroups,
PeerOverview,
PeerView,
PinnedView,
PolicyChatKind,
@@ -28,9 +28,12 @@ import type {
PolicyRecord,
PolicyRule,
PolicyScope,
PresenceHourly,
PresenceSample,
PresenceSession,
PresenceStats,
ProfileChange,
ReactionView,
ReadInfo,
ResponseStats,
ScopeTarget,
SearchHit,
@@ -43,7 +46,6 @@ import type {
TrashItem,
TrashOutcome,
VolumeBucket,
Watch,
WatchedBot,
} from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
@@ -205,30 +207,38 @@ export function getCurrentPresence(
});
}
export function getPresenceHistory(
export function getPresenceStats(
peerId: number,
days: number,
tz: string,
page: Page = {}
): Promise<PresenceSample[]> {
return request<PresenceSample[]>("/presence", {
): Promise<PresenceStats> {
return request<PresenceStats>("/presence/stats", {
account: true,
query: { peer_id: peerId, ...page },
query: { days, peer_id: peerId, tz, ...page },
});
}
export function getPresenceHourly(peerId: number): Promise<PresenceHourly[]> {
return request<PresenceHourly[]>("/presence/hourly", {
export function getPresenceSessions(
peerId: number,
days: number,
tz: string,
page: Page = {}
): Promise<PresenceSession[]> {
return request<PresenceSession[]>("/presence/sessions", {
account: true,
query: { peer_id: peerId },
query: { days, peer_id: peerId, tz, ...page },
});
}
export function getMessageVolume(
chatId: number,
days = 90
days: number,
tz: string
): Promise<VolumeBucket[]> {
return request<VolumeBucket[]>("/analytics/volume", {
account: true,
query: { chat_id: chatId, days },
query: { chat_id: chatId, days, tz },
});
}
@@ -268,8 +278,16 @@ export function getPeer(peerId: number): Promise<PeerView> {
return request<PeerView>(`/peers/${peerId}`, { account: true });
}
export function getPeerHistory(peerId: number): Promise<PeerHistoryView[]> {
return request<PeerHistoryView[]>(`/peers/${peerId}/history`, {
export function getPeerOverview(peerId: number): Promise<PeerOverview> {
return request<PeerOverview>(`/peers/${peerId}/overview`, { account: true });
}
export function getPeerGroups(peerId: number): Promise<PeerGroups> {
return request<PeerGroups>(`/peers/${peerId}/groups`, { account: true });
}
export function getChatChanges(chatId: number): Promise<ProfileChange[]> {
return request<ProfileChange[]>(`/chats/${chatId}/changes`, {
account: true,
});
}
@@ -304,6 +322,25 @@ export function getChatLinks(
});
}
export function getReadInfo(
chatId: number,
messageId: number
): Promise<ReadInfo> {
return request<ReadInfo>(`/messages/${chatId}/${messageId}/read`, {
account: true,
});
}
export function refreshReadInfo(
chatId: number,
messageId: number
): Promise<{ job_id: number | null }> {
return request<{ job_id: number | null }>(
`/messages/${chatId}/${messageId}/read/refresh`,
{ body: { account_id: accounts.selectedId }, method: "POST" }
);
}
export function getMessageReactions(
chatId: number,
messageId: number
@@ -518,46 +555,6 @@ export function transcribeMedia(
});
}
export function listWatches(): Promise<Watch[]> {
return request<Watch[]>("/watches", { account: true });
}
export function createWatch(
kind: string,
params: Record<string, unknown>,
enabled: boolean
): Promise<Watch> {
return request<Watch>("/watches", {
body: { account_id: accounts.selectedId, enabled, kind, params },
method: "POST",
});
}
export function updateWatch(
id: number,
params: Record<string, unknown>,
enabled: boolean
): Promise<Watch> {
return request<Watch>(`/watches/${id}`, {
body: { enabled, params },
method: "PUT",
});
}
export function deleteWatch(id: number): Promise<void> {
return request<void>(`/watches/${id}`, { method: "DELETE" });
}
export function listAlerts(
options: Page & { seen?: boolean } = {}
): Promise<Alert[]> {
return request<Alert[]>("/alerts", { account: true, query: { ...options } });
}
export function markAlertSeen(id: number): Promise<void> {
return request<void>(`/alerts/${id}/seen`, { method: "POST" });
}
export function listAnnotations(
options: { chatId?: number; messageId?: number } = {}
): Promise<Annotation[]> {
+137 -33
View File
@@ -1,3 +1,5 @@
import type { LiveAlertEvent } from "#lib/api/watches.js";
export type ChatKind = "private" | "group";
export type PolicyScopeType =
| "default_dm"
@@ -204,6 +206,7 @@ export interface MessageView {
quote: string | null;
reactions: ReactionCount[];
read: boolean;
read_at: string | null;
reply: ReplyView | null;
sender_id: number | null;
service: ServiceView | null;
@@ -335,12 +338,57 @@ export interface PresenceSample {
ts: string;
}
export interface PresenceHourly {
bucket: 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;
last_seen: string | null;
online_samples: number;
median_session_seconds: number | null;
online_seconds: number;
session_count: number;
}
export interface PresenceStats {
current: PresenceSample | null;
days: PresenceDay[];
gaps: CoverageGap[];
hidden: boolean;
online: boolean;
peer_id: number;
samples: number;
sessions: PresenceSession[];
since: string;
summary: PresenceSummary;
tz: string;
until: string;
week: PresenceHour[];
}
export interface VolumeBucket {
@@ -369,14 +417,88 @@ export interface PeerView {
username: string | null;
}
export interface PeerHistoryView {
first_name: string | null;
is_deleted_account: boolean;
last_name: string | null;
observed_at: string;
phone: string | null;
photo_unique_id: string | null;
username: string | null;
export interface ReaderView {
peer: PeerView | null;
peer_id: number;
read_at: string;
}
export type ReadOutcome =
| "ok"
| "not_read_yet"
| "user_privacy"
| "your_privacy"
| "chat_too_big"
| "too_old"
| "unavailable"
| "flood"
| "error";
export interface ReadInfo {
can_fetch: boolean;
chat_id: number;
chat_kind: "dm" | "group" | "channel";
exact_at: string | null;
fetched_at: string | null;
listened_at: string | null;
message_id: number;
observed_at: string | null;
outcome: ReadOutcome | null;
own: boolean;
read: boolean;
readers: ReaderView[];
unread: boolean;
voice: boolean;
}
export type ProfileField = "name" | "username" | "phone" | "deleted" | "title";
export interface ProfileChange {
actor_id: number | null;
changed_at: string;
field: ProfileField;
message_id: number | null;
new: string | null;
old: string | null;
}
export interface MembershipView {
actor_id: number | null;
actor_name: string | null;
chat_id: number;
chat_title: string | null;
event: "join" | "leave";
has_avatar: boolean;
message_id: number;
ts: string;
}
export interface DirectChatView {
chat_id: number;
last_date: string | null;
message_count: number;
}
export interface PeerOverview {
avatars: AvatarHistoryView[];
changes: ProfileChange[];
dm: DirectChatView | null;
memberships: MembershipView[];
peer: PeerView | null;
}
export interface SharedGroupView {
chat_id: number;
has_avatar: boolean;
last_date: string;
last_message_id: number;
message_count: number;
title: string | null;
}
export interface PeerGroups {
deleted_count: number;
groups: SharedGroupView[];
}
export interface StoryView {
@@ -463,26 +585,6 @@ export interface Folder {
title: string;
}
export interface Watch {
account_id: number;
created_at: string;
enabled: boolean;
id: number;
kind: string;
params: Record<string, unknown>;
updated_at: string;
}
export interface Alert {
account_id: number;
created_at: string;
id: number;
payload: Record<string, unknown>;
seen: boolean;
ts: string;
watch_id: number;
}
export interface JobView {
account_id: number;
attempts: number;
@@ -518,6 +620,7 @@ export interface LivePresenceEvent {
export interface LiveReceiptEvent {
chat_id: number;
read_at: string;
read_up_to: number;
type: "receipt";
}
@@ -526,7 +629,8 @@ export type LiveEvent =
| LiveMessageEvent
| LiveDeleteEvent
| LivePresenceEvent
| LiveReceiptEvent;
| LiveReceiptEvent
| LiveAlertEvent;
export interface FileShare {
account_id: number;
+210
View File
@@ -0,0 +1,210 @@
import { request } from "#lib/api/client.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
export type WatchKind =
| "keyword"
| "new_message"
| "message_deleted"
| "message_edited"
| "peer_online";
export interface WatchParams {
chat_id?: number;
pattern?: string;
peer_id?: number;
regex?: boolean;
sender_id?: number;
}
export type StoredParams = WatchParams & { keyword?: string };
export interface ChatRef {
has_avatar: boolean;
id: number;
kind: "private" | "group" | "channel";
title: string | null;
}
export interface PersonRef {
has_avatar: boolean;
id: number;
name: string | null;
}
export interface Watch {
account_id: number;
alert_count: number;
chat: ChatRef | null;
created_at: string;
enabled: boolean;
id: number;
kind: WatchKind;
last_alert_at: string | null;
name: string | null;
params: StoredParams;
person: PersonRef | null;
updated_at: string;
}
export interface WatchDraft {
enabled: boolean;
kind: WatchKind;
name: string | null;
params: WatchParams;
}
export type AlertEvent =
| "message"
| "edit"
| "transcript"
| "delete"
| "online";
export interface AlertMessage {
date: string | null;
deleted: boolean;
edited: boolean;
id: number;
sender: PersonRef | null;
text: string | null;
}
export interface AlertItem {
account_id: number;
away_since: string | null;
chat: ChatRef | null;
event: AlertEvent;
id: number;
message: AlertMessage | null;
path: string;
peer: PersonRef | null;
seen: boolean;
ts: string;
watch: {
id: number;
kind: WatchKind;
name: string | null;
params: StoredParams;
};
}
export interface AlertPage {
items: AlertItem[];
next_before: number | null;
unseen: number;
}
export interface LiveAlertEvent {
alert: AlertItem | null;
type: "alert";
unseen: number;
}
export interface Webhook {
account_id: number;
created_at: string;
enabled: boolean;
id: number;
last_delivery: Delivery | null;
secret: string;
updated_at: string;
url: string;
watch_ids: number[] | null;
}
export interface WebhookDraft {
enabled: boolean;
url: string;
watch_ids: number[] | null;
}
export interface Delivery {
alert_id: number | null;
attempts: number;
created_at: string;
event: "alert" | "test";
finished_at: string | null;
id: number;
last_error: string | null;
next_attempt_at: string;
response_code: number | null;
status: "pending" | "sending" | "delivered" | "failed";
webhook_id: number;
}
export function listWatches(): Promise<Watch[]> {
return request<Watch[]>("/watches", { account: true });
}
export function createWatch(draft: WatchDraft): Promise<Watch> {
return request<Watch>("/watches", {
body: { account_id: accounts.selectedId, ...draft },
method: "POST",
});
}
export function updateWatch(id: number, draft: WatchDraft): Promise<Watch> {
return request<Watch>(`/watches/${id}`, { body: draft, method: "PUT" });
}
export function deleteWatch(id: number): Promise<void> {
return request<void>(`/watches/${id}`, { method: "DELETE" });
}
export function listAlerts(
options: { before?: number; limit?: number; seen?: boolean } = {}
): Promise<AlertPage> {
return request<AlertPage>("/alerts", { account: true, query: options });
}
export async function unseenAlerts(): Promise<number> {
const { unseen } = await request<{ unseen: number }>("/alerts/unseen", {
account: true,
});
return unseen;
}
export function markAlertSeen(id: number): Promise<void> {
return request<void>(`/alerts/${id}/seen`, { method: "POST" });
}
export function markAllAlertsSeen(upTo?: number): Promise<{ marked: number }> {
return request<{ marked: number }>("/alerts/seen", {
body: { account_id: accounts.selectedId, up_to: upTo ?? null },
method: "POST",
});
}
export function listWebhooks(): Promise<Webhook[]> {
return request<Webhook[]>("/webhooks", { account: true });
}
export function createWebhook(draft: WebhookDraft): Promise<Webhook> {
return request<Webhook>("/webhooks", {
body: { account_id: accounts.selectedId, ...draft },
method: "POST",
});
}
export function updateWebhook(
id: number,
draft: WebhookDraft
): Promise<Webhook> {
return request<Webhook>(`/webhooks/${id}`, { body: draft, method: "PUT" });
}
export function deleteWebhook(id: number): Promise<void> {
return request<void>(`/webhooks/${id}`, { method: "DELETE" });
}
export function rotateWebhookSecret(id: number): Promise<Webhook> {
return request<Webhook>(`/webhooks/${id}/secret`, { method: "POST" });
}
export function testWebhook(id: number): Promise<Delivery> {
return request<Delivery>(`/webhooks/${id}/test`, { method: "POST" });
}
export function listDeliveries(id: number): Promise<Delivery[]> {
return request<Delivery[]>(`/webhooks/${id}/deliveries`);
}
-16
View File
@@ -1,16 +0,0 @@
<svg
height="128"
viewBox="0 0 107 128"
width="107"
xmlns="http://www.w3.org/2000/svg"
>
<title>svelte-logo</title>
<path
d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116"
style="fill:#ff3e00"
/>
<path
d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328"
style="fill:#fff"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

+17 -40
View File
@@ -1,10 +1,6 @@
<script lang="ts">
import {
enqueueBackfill,
getCurrentPresence,
getPeer,
} from "#lib/api/endpoints.js";
import type { PeerView, PresenceSample } from "#lib/api/types.js";
import { enqueueBackfill, getPeer } from "#lib/api/endpoints.js";
import type { PeerView } 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";
@@ -16,6 +12,7 @@
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";
@@ -26,13 +23,10 @@
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() {
@@ -81,34 +75,8 @@
});
$effect(() => {
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);
};
presence.follow(accounts.selectedId, isDm ? chatId : null);
return () => presence.follow(null, null);
});
const fallbackTitle = $derived(
@@ -119,8 +87,8 @@
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
const subtitle = $derived.by(() => {
if (isDm) {
if (presence) {
return formatPresence(presence);
if (presence.sample) {
return formatPresence(presence.sample, presence.now);
}
if (peer?.username) {
return `@${peer.username}`;
@@ -139,6 +107,10 @@
);
</script>
<svelte:head>
<title>{title} · Beavergram</title>
</svelte:head>
<header class="chat-header">
<div class="back">
<Button
@@ -172,7 +144,12 @@
/>
<div class="info">
<h2 class="title">{title}</h2>
<span class="subtitle" class:online={isDm && isOnline(presence)}>
<span
class={[
"subtitle",
{ online: isDm && isOnline(presence.sample, presence.now) },
]}
>
{subtitle}
</span>
</div>
+172
View File
@@ -0,0 +1,172 @@
<script lang="ts">
import { DropdownMenu } from "bits-ui";
import AlertBadge from "#lib/components/alerts/AlertBadge.svelte";
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,
})}
<span class="menu-trigger">
<Button
{...props}
aria-label={t("menu.open")}
round
smaller
variant="translucent"
>
<Icon name="menu" />
</Button>
<AlertBadge />
</span>
{/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" />
{#if entry.panel === "alerts"}
<span class="account-name">{t(entry.label)}</span>
<AlertBadge inline />
{:else}
{t(entry.label)}
{/if}
</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">
.menu-trigger {
position: relative;
display: flex;
flex-shrink: 0;
}
:global(.main-menu) {
width: 17rem;
max-height: calc(100dvh - 5rem);
overflow-y: auto;
}
:global(.main-menu .account-item) {
padding-block: 0.375rem;
}
.account-name {
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-tag {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
:global(.main-menu .account-check) {
flex-shrink: 0;
color: var(--color-primary);
}
</style>
@@ -11,6 +11,7 @@
import Poll from "#lib/components/Poll.svelte";
import 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";
@@ -22,7 +23,6 @@
import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { appear } from "#lib/transitions/appear.js";
import { goto } from "$app/navigation";
interface Props {
animate: boolean;
@@ -124,8 +124,7 @@
}
function openSenderProfile() {
if (message.sender_id !== null) {
goto(`/app/${message.sender_id}`);
ui.openPanel("profile");
ui.openPerson(message.chat_id, message.sender_id);
}
}
</script>
@@ -149,20 +148,33 @@
{#if isGroupChat}
<div class="avatar-slot">
{#if lastInGroup && avatarId !== null}
<Avatar
avatar={{ id: avatarId, kind: "peer" }}
colorKey={avatarId}
hasAvatar={avatarHas}
name={senderName}
size={2.125}
/>
<button
aria-label={senderName}
class="sender-avatar"
onclick={openSenderProfile}
type="button"
>
<Avatar
avatar={{ id: avatarId, kind: "peer" }}
colorKey={avatarId}
hasAvatar={avatarHas}
name={senderName}
size={2.125}
/>
</button>
{/if}
</div>
{/if}
<div class="message-content" class:has-appendix={lastInGroup}>
{#if showName}
<div class="sender-name peer-color-{colorIndex}">{senderName}</div>
<button
class="sender-name peer-color-{colorIndex}"
onclick={openSenderProfile}
type="button"
>
{senderName}
</button>
{/if}
{#if message.forward}
<ForwardHeader forward={message.forward} />
@@ -247,6 +259,9 @@
</div>
{/snippet}
{#snippet menu()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="note" onselect={openAnnotations}
>Заметки</ContextMenuItem
>
@@ -360,12 +375,35 @@
border-bottom-left-radius: 0;
}
.sender-avatar,
.sender-name {
cursor: pointer;
padding: 0;
border: 0;
font: inherit;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.sender-avatar {
display: block;
border-radius: 50%;
}
.sender-name {
overflow: hidden;
display: block;
max-width: 100%;
margin-bottom: 0.0625rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -363,13 +363,13 @@
);
}
function applyReceipt(readUpTo: number) {
function applyReceipt(readUpTo: number, readAt: string) {
if (ownId === null) {
return;
}
messages = messages.map((m) =>
m.sender_id === ownId && m.message_id <= readUpTo && !m.read
? { ...m, read: true }
? { ...m, read: true, read_at: readAt }
: m
);
}
@@ -388,7 +388,7 @@
) {
applyDelete(event.message_ids);
} else if (event.type === "receipt" && event.chat_id === chatId) {
applyReceipt(event.read_up_to);
applyReceipt(event.read_up_to, event.read_at);
}
}
@@ -14,6 +14,7 @@
import TgsSticker from "#lib/components/media/TgsSticker.svelte";
import 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";
@@ -222,6 +223,9 @@
</div>
{/snippet}
{#snippet menu()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть на весь экран</ContextMenuItem
>
@@ -2,6 +2,7 @@
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;
@@ -25,11 +26,9 @@
{/if}
<span class="time">{formatTime(message.date)}</span>
{#if own}
<Icon
class="ticks"
name={message.read ? "message-read" : "check"}
size="1rem"
/>
<span class="ticks" title={readTitle(message)}>
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
</span>
{/if}
</span>
@@ -52,6 +51,10 @@
white-space: nowrap;
}
.ticks {
display: inline-flex;
}
.edited {
cursor: pointer;
padding: 0;
+19 -5
View File
@@ -8,6 +8,7 @@
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";
@@ -18,14 +19,16 @@
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 WebhooksPanel from "#lib/components/watches/WebhooksPanel.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> = {
alerts: "Алерты",
const titles: Record<RightPanel, string> = $derived({
alerts: t("settings.alerts"),
annotations: "Заметки",
bots: "Боты",
callbacks: "Callback-данные",
@@ -35,6 +38,7 @@
presence: "Аналитика",
profile: "Профиль",
reactions: "Реакции",
readers: t("read.readers.title"),
search: "Поиск",
shares: "Файлы по ссылке",
storage: "Хранилище",
@@ -43,12 +47,14 @@
stories: "Сторис",
"stories-all": "Все сторис",
versions: "Версии",
watches: "Отслеживания",
};
watches: t("settings.watches"),
webhooks: t("watches.webhooks"),
});
const parents: Partial<Record<RightPanel, RightPanel>> = {
"storage-chat": "storage",
"storage-trash": "storage",
webhooks: "watches",
};
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
@@ -104,6 +110,8 @@
<ChatSearchPanel />
{:else if ui.rightPanel === "reactions"}
<ReactionsPanel />
{:else if ui.rightPanel === "readers"}
<ReadersPanel />
{:else if ui.rightPanel === "callbacks"}
<CallbacksPanel />
{:else if ui.rightPanel === "links"}
@@ -113,7 +121,13 @@
{:else if ui.rightPanel === "stories-all"}
<AllStoriesArchive />
{:else if ui.rightPanel === "watches"}
<WatchesPanel />
{#key accounts.selectedId}
<WatchesPanel />
{/key}
{:else if ui.rightPanel === "webhooks"}
{#key accounts.selectedId}
<WebhooksPanel />
{/key}
{:else if ui.rightPanel === "alerts"}
<AlertsPanel />
{:else if ui.rightPanel === "annotations"}
@@ -0,0 +1,65 @@
<script lang="ts">
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { alerts } from "#lib/stores/alerts.svelte.js";
interface Props {
inline?: boolean;
}
const { inline = false }: Props = $props();
const MAX_SHOWN = 99;
const count = $derived(alerts.unseen);
$effect(() => {
alerts.track(accounts.selectedId);
});
</script>
{#if count > 0}
<span class="alert-badge" title={t("alerts.badge", { count })} class:inline>
{count > MAX_SHOWN ? `${MAX_SHOWN}+` : count}
</span>
{/if}
<style lang="scss">
.alert-badge {
pointer-events: none;
position: absolute;
top: 0;
right: -0.125rem;
min-width: 1.125rem;
height: 1.125rem;
padding: 0 0.3125rem;
border-radius: 0.5625rem;
font-size: 0.6875rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
line-height: 1.125rem;
color: var(--color-white);
text-align: center;
background-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-background);
&.inline {
position: static;
flex-shrink: 0;
min-width: 1.375rem;
height: 1.375rem;
padding: 0 0.4375rem;
border-radius: 0.6875rem;
font-size: 0.8125rem;
line-height: 1.375rem;
box-shadow: none;
}
}
</style>
@@ -1,82 +0,0 @@
<script lang="ts">
import type { Alert } from "#lib/api/types.js";
import Button from "../ui/Button.svelte";
import Icon from "../ui/Icon.svelte";
interface Props {
alerts: Alert[];
onseen: (alert: Alert) => void;
}
const { alerts, onseen }: Props = $props();
function text(alert: Alert): string {
const p = alert.payload;
const value = p.text ?? p.message ?? p.keyword;
return typeof value === "string" ? value : JSON.stringify(p);
}
</script>
<ul class="alert-list">
{#each alerts as alert (alert.id)}
<li class="alert-item" class:unseen={!alert.seen}>
<div class="alert-main">
<p class="alert-text">{text(alert)}</p>
<time class="alert-date">{new Date(alert.ts).toLocaleString()}</time>
</div>
{#if !alert.seen}
<Button
aria-label="Отметить прочитанным"
onclick={() => onseen(alert)}
round
smaller
variant="translucent"
>
<Icon name="check" />
</Button>
{/if}
</li>
{/each}
</ul>
<style lang="scss">
.alert-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.alert-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
&.unseen {
border-color: var(--color-primary);
}
}
.alert-main {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.alert-text {
margin: 0;
overflow-wrap: anywhere;
}
.alert-date {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
</style>
@@ -1,72 +1,640 @@
<script lang="ts">
import { listAlerts, markAlertSeen } from "#lib/api/endpoints.js";
import type { Alert } from "#lib/api/types.js";
import { onMount, untrack } from "svelte";
import {
type AlertItem,
listAlerts,
markAlertSeen,
markAllAlertsSeen,
} from "#lib/api/watches.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";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull, formatListDate } from "#lib/format/datetime.js";
import {
alertAvatar,
alertLabel,
alertPreview,
alertTitle,
EVENT_ICONS,
} from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import Spinner from "../ui/Spinner.svelte";
import AlertList from "./AlertList.svelte";
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let alerts = $state<Alert[]>([]);
type Filter = "all" | "unseen";
const PAGE = 50;
let items = $state<AlertItem[]>([]);
let nextBefore = $state<number | null>(null);
let loading = $state(true);
let unseenOnly = $state(false);
let loadingMore = $state(false);
let failed = $state(false);
let notifyProblem = $state(false);
let token = 0;
async function load(_account: number | null, unseen: boolean) {
loading = true;
let filter = $state<Filter>(
untrack(() => page.url.searchParams.get(ALERTS_PARAM)) === "unseen"
? "unseen"
: "all"
);
function writeParam(value: Filter | null) {
const url = new URL(page.url.href);
if (value === null) {
url.searchParams.delete(ALERTS_PARAM);
} else {
url.searchParams.set(ALERTS_PARAM, value);
}
if (url.href !== page.url.href) {
goto(url, { replace: true, reset: false });
}
}
function setFilter(next: Filter) {
filter = next;
writeParam(next);
}
async function load(current: Filter, before: number | null = null) {
token += 1;
const mine = token;
if (before === null) {
loading = true;
} else {
loadingMore = true;
}
try {
alerts = await listAlerts(unseen ? { seen: false } : {});
const result = await listAlerts({
before: before ?? undefined,
limit: PAGE,
seen: current === "unseen" ? false : undefined,
});
if (mine !== token) {
return;
}
items = before === null ? result.items : [...items, ...result.items];
nextBefore = result.next_before;
failed = false;
} catch {
if (mine === token) {
failed = true;
}
} finally {
loading = false;
if (mine === token) {
loading = false;
loadingMore = false;
}
}
}
$effect(() => {
load(accounts.selectedId, unseenOnly);
if (accounts.selectedId !== null) {
load(filter);
}
});
async function seen(alert: Alert) {
await markAlertSeen(alert.id);
if (unseenOnly) {
alerts = alerts.filter((a) => a.id !== alert.id);
} else {
alerts = alerts.map((a) =>
a.id === alert.id ? { ...a, seen: true } : a
);
onMount(() => {
writeParam(filter);
const stop = alerts.onAlert((alert) => {
if (!items.some((item) => item.id === alert.id)) {
items = [alert, ...items];
}
});
return () => {
stop();
writeParam(null);
};
});
function markLocal(alert: AlertItem) {
if (alert.seen) {
return;
}
items = items.map((item) =>
item.id === alert.id ? { ...item, seen: true } : item
);
alerts.read();
}
async function seen(alert: AlertItem) {
if (alert.seen) {
return;
}
markLocal(alert);
await markAlertSeen(alert.id).catch(() => undefined);
}
async function openAlert(alert: AlertItem) {
seen(alert);
await goto(alert.path);
if (isMobile()) {
ui.closePanel();
}
}
async function seeAll() {
items = items.map((item) => ({ ...item, seen: true }));
alerts.readAll();
await markAllAlertsSeen().catch(() => undefined);
}
async function toggleNotify() {
notifyProblem = false;
const wanted = !alerts.notify;
const done = await alerts.setNotify(wanted);
notifyProblem = wanted && !done;
}
</script>
<div class="panel-content">
<label class="toggle">
<input type="checkbox" bind:checked={unseenOnly}>
<span>Только непрочитанные</span>
</label>
{#if loading}
<Spinner />
{:else if alerts.length === 0}
<p class="empty">Алертов нет.</p>
{:else}
<AlertList {alerts} onseen={seen} />
{/if}
<div class="tabs">
<button
aria-pressed={filter === "all"}
class="tab"
onclick={() => setFilter("all")}
type="button"
class:on={filter === "all"}
>
{t("alerts.all")}
</button>
<button
aria-pressed={filter === "unseen"}
class="tab"
onclick={() => setFilter("unseen")}
type="button"
class:on={filter === "unseen"}
>
{t("alerts.unseen")}
</button>
</div>
{#if alerts.unseen > 0}
<button class="act" onclick={seeAll} type="button">
<Icon name="readchats" size="1.375rem" />
<span class="label">{t("alerts.markAll")}</span>
<span class="value">{alerts.unseen}</span>
</button>
{/if}
{#if alerts.notifySupported}
<button
aria-checked={alerts.notify}
class="toggle"
onclick={toggleNotify}
role="switch"
type="button"
>
<Icon name="unmute" size="1.375rem" />
<span class="label">{t("alerts.notify")}</span>
<span class="switch" class:on={alerts.notify}
><span class="knob"></span></span
>
</button>
{#if notifyProblem}
<p class="problem">{t("alerts.notifyDenied")}</p>
{/if}
{/if}
{#if loading && items.length === 0}
<div class="state"><Spinner /></div>
{:else if failed && items.length === 0}
<div class="state">
<p>{t("alerts.loadFailed")}</p>
<Button onclick={() => load(filter)} smaller variant="text">
{t("alerts.retry")}
</Button>
</div>
{:else if items.length === 0}
<div class="empty">
<EmptyState
description={filter === "unseen"
? undefined
: t("alerts.empty.description")}
title={filter === "unseen"
? t("alerts.emptyUnseen.title")
: t("alerts.empty.title")}
/>
{#if filter === "all"}
<Button onclick={() => ui.openPanel("watches")} smaller variant="text">
{t("alerts.setup")}
</Button>
{/if}
</div>
{:else}
<ul class="list">
{#each items as alert (alert.id)}
{@const avatar = alertAvatar(alert)}
{@const preview = alertPreview(alert)}
<li>
<ContextMenu>
{#snippet children({
props,
})}
<button
{...props}
class="alert"
onclick={() => openAlert(alert)}
type="button"
class:unseen={!alert.seen}
>
<span class="lead">
<Avatar
avatar={{ id: avatar.id, kind: avatar.kind }}
colorKey={avatar.id}
hasAvatar={avatar.hasAvatar}
name={avatar.name}
size={3}
/>
<span class="kind {alert.event}">
<Icon name={EVENT_ICONS[alert.event]} size="0.75rem" />
</span>
</span>
<span class="text">
<span class="top">
<span class="title">{alertTitle(alert)}</span>
<time datetime={alert.ts} title={formatFull(alert.ts)}>
{formatListDate(alert.ts)}
</time>
</span>
<span class="bottom">
<span class="line">
<span class="what" class:alone={!preview}>
{alertLabel(alert)}
</span>
{preview}
</span>
{#if !alert.seen}
<span class="dot"></span>
{/if}
</span>
</span>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem
icon={alert.message ? "reply" : "message"}
onselect={() => openAlert(alert)}
>
{alert.message ? t("alerts.open") : t("alerts.openChat")}
</ContextMenuItem>
{#if !alert.seen}
<ContextMenuItem icon="check" onselect={() => seen(alert)}>
{t("alerts.markSeen")}
</ContextMenuItem>
{/if}
{/snippet}
</ContextMenu>
</li>
{/each}
</ul>
{#if nextBefore !== null}
<button
class="more"
disabled={loadingMore}
onclick={() => load(filter, nextBefore)}
type="button"
>
{t("alerts.more")}
</button>
{/if}
{/if}
<style lang="scss">
.panel-content {
.tabs {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
flex-shrink: 0;
border-bottom: 1px solid var(--color-borders);
}
.tab {
cursor: pointer;
flex: 1;
height: 2.75rem;
border: 0;
border-bottom: 2px solid transparent;
font: inherit;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: none;
transition: color 0.15s ease-out;
&.on {
border-bottom-color: var(--color-primary);
color: var(--color-primary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:not(.on):hover {
color: var(--color-text);
}
}
}
.act,
.toggle {
cursor: pointer;
display: flex;
gap: 0.875rem;
align-items: center;
width: 100%;
height: 3rem;
padding: 0 1rem 0 1.125rem;
border: 0;
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background: none;
.label {
flex: 1;
}
.value {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
&:focus-visible {
outline: 2px solid currentcolor;
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.toggle {
color: var(--color-text);
:global(.icon) {
color: var(--color-icon-secondary);
}
}
.switch {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders-input);
transition: background-color 0.15s ease-out;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease-out;
.on & {
transform: translateX(0.875rem);
}
}
.problem {
margin: 0;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--color-borders);
font-size: 0.8125rem;
color: var(--color-error);
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
p {
margin: 0;
}
}
.empty {
color: var(--color-text-secondary);
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
padding: 2.5rem 1rem 2rem;
text-align: center;
}
.list {
margin: 0;
padding: 0.5rem;
list-style: none;
}
.alert {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
min-height: 4.5rem;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.lead {
position: relative;
flex-shrink: 0;
}
.kind {
position: absolute;
right: -0.125rem;
bottom: -0.125rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-background);
&.delete {
background-color: var(--color-error);
}
&.online {
background-color: var(--color-green);
}
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.top,
.bottom {
display: flex;
gap: 0.5rem;
align-items: center;
min-width: 0;
}
.title {
overflow: hidden;
flex: 1;
min-width: 0;
font-size: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
.unseen & {
font-weight: var(--font-weight-medium);
}
}
time {
flex-shrink: 0;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.line {
overflow: hidden;
flex: 1;
min-width: 0;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.what {
color: var(--color-text);
&:not(.alone)::after {
content: ":";
}
}
.dot {
flex-shrink: 0;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--color-primary);
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0 0.5rem 1rem;
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;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
@media (pointer: coarse) {
min-height: 2.75rem;
}
}
</style>
@@ -1,193 +1,295 @@
<script lang="ts">
import {
getCurrentPresence,
getMessageVolume,
getPresenceHistory,
getPresenceHourly,
getPresenceStats,
getResponseStats,
} from "#lib/api/endpoints.js";
import type {
PresenceHourly,
PresenceSample,
PresenceStats,
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 { formatFull } from "#lib/format/datetime.js";
import { formatPresence } from "#lib/format/presence.js";
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 { accounts } from "#lib/stores/accounts.svelte.js";
import { events } from "#lib/stores/events.svelte.js";
import { presence } from "#lib/stores/presence.svelte.js";
import { page } from "$app/state";
const HISTORY_LIMIT = 200;
const SECONDS_IN_MINUTE = 60;
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 SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400;
const MS_IN_SECOND = 1000;
const tz = browserTimeZone();
const zone = timeZoneLabel();
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const isDm = $derived(chatId !== null && chatId > 0);
let current = $state<PresenceSample | null>(null);
let hourly = $state<PresenceHourly[]>([]);
let history = $state<PresenceSample[]>([]);
let volume = $state<VolumeBucket[]>([]);
let days = $state(DEFAULT_PERIOD);
let stats = $state<PresenceStats | null>(null);
let volume = $state<VolumeBucket[] | null>(null);
let response = $state<ResponseStats | null>(null);
let loading = $state(false);
let shownDays = $state(DEFAULT_PERIOD);
let failed = $state(false);
let shownChat: number | null = null;
let token = 0;
const changes = $derived.by(() => {
const result: PresenceSample[] = [];
let last: string | null = null;
for (const sample of history) {
if (sample.status !== last) {
result.push(sample);
last = sample.status;
}
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 { 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 formatDuration(seconds: number | null): string {
if (seconds === null) {
return "—";
function reply(seconds: number | null): string {
return seconds === null ? TEXT.noData : formatSpan(seconds);
}
async function load(id: number, period: number) {
token += 1;
const current = token;
failed = false;
if (shownChat !== id) {
shownChat = id;
stats = null;
volume = null;
response = null;
}
if (seconds < SECONDS_IN_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) {
return;
if (accounts.selectedId !== null && chatId !== null) {
load(chatId, days);
}
const id = chatId;
let active = true;
loading = true;
current = null;
hourly = [];
history = [];
volume = [];
response = null;
Promise.all([getMessageVolume(id), getResponseStats(id)])
.then(([vol, resp]) => {
if (active) {
volume = vol;
response = resp;
}
})
.catch(() => undefined)
.finally(() => {
if (active) {
loading = false;
}
});
if (id > 0) {
Promise.all([
getCurrentPresence(id),
getPresenceHourly(id),
getPresenceHistory(id, { limit: HISTORY_LIMIT }),
])
.then(([cur, hrs, hist]) => {
if (active) {
current = cur;
hourly = hrs;
history = hist;
}
})
.catch(() => undefined);
}
const unsub = events.subscribe((event) => {
if (event.type === "presence" && event.peer_id === id && event.sample) {
current = event.sample;
}
});
return () => {
active = false;
unsub();
};
});
</script>
{#if chatId === null}
<EmptyState description="Откройте чат" title="Аналитика" />
<EmptyState description={TEXT.openChat} title={TEXT.title} />
{:else}
<div class="analytics">
<section>
<h3>Объём сообщений (90 дней)</h3>
{#if volume.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Нет сообщений"}</p>
{:else}
<VolumeChart {volume} />
{/if}
</section>
<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>
<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="stat">
<span class="value"
>{formatDuration(response.their_median_seconds)}</span
>
<span class="caption">мне отвечают ({response.their_count})</span>
</div>
</div>
{:else}
<p class="muted">{loading ? "Загрузка…" : "Нет данных"}</p>
{/if}
</section>
{#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 isDm}
<section>
<div class="status" class:online={current?.status === "online"}>
{current ? formatPresence(current) : "нет данных"}
</div>
</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>
<h3>Когда в сети</h3>
{#if hourly.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Пока нет данных"}</p>
<div class="section-title"><h3>{TEXT.messages}</h3></div>
{#if quiet}
<p class="state">{TEXT.noMessages}</p>
{:else}
<PresenceHeatmap {hourly} />
<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>
</div>
<div class="row">
<dt>{TEXT.theirs}</dt>
<dd>{reply(response.their_median_seconds)}</dd>
</div>
</dl>
{/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 isDm && stats && tracked && !stats.hidden}
<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}
</section>
{/if}
{/if}
</div>
{/if}
@@ -196,48 +298,89 @@
.analytics {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
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;
}
}
section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 0;
& + section {
border-top: 1px solid var(--color-borders);
}
}
h3 {
margin: 0;
font-size: 0.75rem;
.hero {
padding: 0.75rem 1rem 0.875rem;
}
.total-line {
display: flex;
flex-wrap: wrap;
gap: 0 0.625rem;
align-items: baseline;
}
.total {
font-size: 1.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
font-variant-numeric: tabular-nums;
line-height: 1.2;
letter-spacing: -0.01em;
}
.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;
.count {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.status {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
color: var(--color-text-secondary);
@@ -246,49 +389,80 @@
}
}
.muted {
margin: 0;
font-size: 0.8125rem;
.note {
margin: 0.5rem 0 0;
font-size: 0.875rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.changes {
.section-title {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
gap: 0.75rem;
align-items: baseline;
justify-content: space-between;
padding: 0.375rem 1rem 0.625rem;
.changes li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
.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);
h3 {
margin: 0;
font-size: inherit;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
white-space: nowrap;
}
}
.label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
.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;
}
.time {
flex-shrink: 0;
.state {
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 {
margin: 0;
}
}
</style>
@@ -0,0 +1,144 @@
<script lang="ts">
import type { PresenceDay } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props {
days: PresenceDay[];
}
const { days }: Props = $props();
const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400;
const TEXT = {
bar: (day: string, online: number, missing: number) => {
if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) {
return `${day}, нет данных`;
}
const gap =
missing >= SECONDS_IN_HOUR ? `, нет данных ${formatSpan(missing)}` : "";
return `${day}, ${formatSpan(online)} в сети${gap}`;
},
today: "сегодня",
};
const peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds)));
const first = $derived(days[0]);
function label(day: string): string {
return formatDay(parseDay(day).toISOString());
}
function absent(day: PresenceDay, index: number): number {
if (index === days.length - 1) {
return 0;
}
return Math.max(0, SECONDS_IN_DAY - day.covered_seconds);
}
</script>
<div class="days">
<div class="chart">
<span class="peak">{formatSpan(peak)}</span>
<div class={["bars", { dense: days.length > 45 }]}>
{#each days as day, index (day.day)}
<div
class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]}
title={TEXT.bar(
label(day.day),
day.online_seconds,
absent(day, index)
)}
>
<span
class={["bar", { zero: day.online_seconds === 0 }]}
style:height="{(day.online_seconds / peak) * 100}%"
></span>
</div>
{/each}
</div>
</div>
{#if first}
<div class="axis">
<span>{label(first.day)}</span>
<span>{TEXT.today}</span>
</div>
{/if}
</div>
<style lang="scss">
.days {
padding: 0 1rem;
}
.chart {
position: relative;
padding-top: 1.125rem;
border-top: 1px dashed var(--color-borders);
}
.peak {
position: absolute;
top: 0.125rem;
left: 0;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.bars {
display: flex;
gap: 3px;
align-items: flex-end;
height: 6rem;
&.dense {
gap: 1px;
}
}
.slot {
display: flex;
flex: 1;
align-items: flex-end;
height: 100%;
&.empty {
border-radius: 2px 2px 0 0;
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.35) 0 1px,
transparent 1px 4px
);
}
}
.bar {
width: 100%;
min-height: 2px;
border-radius: 2px 2px 0 0;
background-color: var(--color-primary);
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.3);
}
.empty & {
min-height: 0;
}
}
.axis {
display: flex;
justify-content: space-between;
margin-top: 0.375rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
@@ -1,116 +1,217 @@
<script lang="ts">
import type { PresenceHourly } from "#lib/api/types.js";
import type { PresenceHour } from "#lib/api/types.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props {
hourly: PresenceHourly[];
week: PresenceHour[];
}
const { hourly }: Props = $props();
const { week }: Props = $props();
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;
}
const grid = $derived.by(() => {
const cells: Cell[][] = DAYS.map(() =>
HOURS.map(() => ({ online: 0, total: 0 }))
);
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;
const TEXT = {
cell: (day: string, cell: PresenceHour) => {
const slot = `${day}, ${cell.hour}:00`;
if (cell.hours === 0) {
return slot;
}
}
return cells;
});
if (cell.online_minutes === null) {
return `${slot}, нет данных`;
}
return `${slot}, в среднем ${formatSpan(cell.online_minutes * 60)} в сети`;
},
days: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
empty: "нет данных",
max: (minutes: number) => `до ${formatSpan(minutes * 60)}`,
};
function intensity(cell: Cell): number {
return cell.total > 0 ? cell.online / cell.total : 0;
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 peak = $derived(
Math.max(0, ...week.map((cell) => cell.online_minutes ?? 0))
);
const hasGaps = $derived(week.some(missing));
function missing(cell: PresenceHour): boolean {
return cell.hours > 0 && cell.online_minutes === null;
}
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`;
function shade(minutes: number | null): number {
if (minutes === null || minutes === 0 || peak === 0) {
return 0;
}
return MIN_SHADE + (1 - MIN_SHADE) * (minutes / peak);
}
</script>
<div class="heatmap">
{#each grid as row, day (day)}
<div class="heat-row">
<span class="day-label">{DAYS[day]}</span>
{#each rows as cells, weekday (weekday)}
<div class="row">
<span class="day">{TEXT.days[weekday]}</span>
<div class="cells">
{#each row as cell, hour (hour)}
{#each cells as cell (cell.hour)}
<span
class="cell"
title={cellTitle(day, hour, cell)}
style:--i={intensity(cell)}
class:empty={cell.total === 0}
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}%"
></span>
{/each}
</div>
</div>
{/each}
<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>
<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>
{/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: 3px;
gap: 2px;
padding: 0 1rem;
}
.heat-row {
.row {
display: flex;
align-items: center;
gap: 0.5rem;
align-items: center;
}
.day-label {
width: 1.5rem;
.day {
flex-shrink: 0;
font-size: 0.6875rem;
width: 1.375rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
text-align: right;
}
.cells {
display: grid;
flex: 1;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
grid-template-columns: repeat(24, minmax(0, 1fr));
gap: 2px;
}
.cell {
aspect-ratio: 1;
border-radius: 2px;
background-color: var(--color-primary);
opacity: calc(0.12 + 0.88 * var(--i));
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;
}
&.empty {
background-color: var(--color-text-secondary);
opacity: 0.08;
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
transparent 1px 3px
);
}
}
.tick {
font-size: 0.625rem;
.ticks {
position: relative;
flex: 1;
height: 1rem;
span {
position: absolute;
top: 0.125rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
}
.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);
text-align: left;
}
.key {
display: inline-flex;
gap: 0.375rem;
align-items: center;
}
.scale {
width: 2.5rem;
height: 0.625rem;
border-radius: 2px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-primary) 18%, transparent),
var(--color-primary)
);
}
.swatch {
width: 0.625rem;
height: 0.625rem;
border-radius: 2px;
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.12);
}
&.empty {
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
transparent 1px 3px
);
}
}
</style>
@@ -0,0 +1,205 @@
<script lang="ts">
import { getPresenceSessions } from "#lib/api/endpoints.js";
import type { PresenceSession } from "#lib/api/types.js";
import Button from "#lib/components/ui/Button.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatLongDay, formatTime } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props {
days: number;
first: PresenceSession[];
pageSize: number;
peerId: number;
tz: string;
}
const { days, first, pageSize, peerId, tz }: Props = $props();
const TEXT = {
failed: "Не удалось загрузить сессии.",
more: "Показать ещё",
none: "За этот период в сети не был(а).",
now: "в сети",
retry: "Повторить",
since: (time: string) => `с ${time}`,
span: (from: string, to: string) => (from === to ? from : `${from}–${to}`),
today: "Сегодня",
yesterday: "Вчера",
};
const MS_IN_DAY = 86_400_000;
let extra = $state<PresenceSession[]>([]);
let loading = $state(false);
let failed = $state(false);
let done = $state(false);
const sessions = $derived([...first, ...extra]);
const exhausted = $derived(done || first.length < pageSize);
const groups = $derived.by(() => {
const result: { label: string; sessions: PresenceSession[] }[] = [];
for (const session of sessions) {
const label = dayLabel(new Date(session.started));
const last = result.at(-1);
if (last?.label === label) {
last.sessions.push(session);
} else {
result.push({ label, sessions: [session] });
}
}
return result;
});
function dayLabel(date: Date): string {
const today = new Date();
if (date.toDateString() === today.toDateString()) {
return TEXT.today;
}
if (
date.toDateString() ===
new Date(today.getTime() - MS_IN_DAY).toDateString()
) {
return TEXT.yesterday;
}
const text = formatLongDay(date);
return text.charAt(0).toUpperCase() + text.slice(1);
}
async function loadMore() {
if (loading) {
return;
}
loading = true;
failed = false;
try {
const batch = await getPresenceSessions(peerId, days, tz, {
limit: pageSize,
offset: sessions.length,
});
const seen = new Set(sessions.map((session) => session.started));
extra = [
...extra,
...batch.filter((session) => !seen.has(session.started)),
];
done = batch.length < pageSize;
} catch {
failed = true;
} finally {
loading = false;
}
}
</script>
{#if sessions.length === 0}
<p class="state">{TEXT.none}</p>
{:else}
{#each groups as group (group.label)}
<h4 class="day">{group.label}</h4>
<ul>
{#each group.sessions as session (session.started)}
<li>
<span>
{session.ongoing
? TEXT.since(formatTime(session.started))
: TEXT.span(
formatTime(session.started),
formatTime(session.ended)
)}
</span>
<span class={["length", { online: session.ongoing }]}>
{session.ongoing ? TEXT.now : formatSpan(session.seconds)}
</span>
</li>
{/each}
</ul>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>{TEXT.failed}</p>
<Button onclick={loadMore} smaller variant="text">{TEXT.retry}</Button>
</div>
{:else if !exhausted}
<button class="more" onclick={loadMore} type="button">{TEXT.more}</button>
{/if}
{/if}
<style lang="scss">
.day {
margin: 0;
padding: 0.625rem 1rem 0.25rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-normal);
color: var(--color-text-secondary);
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
min-height: 2.5rem;
padding: 0 1rem;
font-size: 0.9375rem;
font-variant-numeric: tabular-nums;
}
.length {
font-size: 0.875rem;
color: var(--color-text-secondary);
&.online {
color: var(--color-primary);
}
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
margin: 0;
padding: 1.5rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts">
import type { VolumeBucket } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js";
interface Props {
volume: VolumeBucket[];
@@ -7,47 +8,104 @@
const { volume }: Props = $props();
const max = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
const TEXT = {
bar: (day: string, bucket: VolumeBucket) =>
bucket.total === 0
? `${day}, нет сообщений`
: `${day}, всего ${bucket.total}: входящих ${bucket.incoming}, моих ${bucket.outgoing}`,
incoming: "входящие",
outgoing: "мои",
today: "сегодня",
};
function barTitle(bucket: VolumeBucket): string {
return `${bucket.bucket.slice(0, 10)} — всего ${bucket.total} (вх ${bucket.incoming} / исх ${bucket.outgoing})`;
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());
}
</script>
<div class="volume">
<div class="bars">
{#each volume as bucket (bucket.bucket)}
<div
class="bar"
title={barTitle(bucket)}
style:height={`${(bucket.total / max) * 100}%`}
>
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
<div class="seg in" style:flex-grow={bucket.incoming}></div>
</div>
{/each}
<div class="chart">
<span class="peak">{peak}</span>
<div class={["bars", { dense: volume.length > 45 }]}>
{#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}%"
>
<div class="seg in" style:flex-grow={bucket.incoming}></div>
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
</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 out"></span>исходящие</span>
<span class="key"><span class="swatch in"></span>входящие</span>
<span class="key"><span class="swatch in"></span>{TEXT.incoming}</span>
<span class="key"><span class="swatch out"></span>{TEXT.outgoing}</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;
gap: 1px;
height: 7.5rem;
height: 6rem;
&.dense {
gap: 1px;
}
}
.slot {
display: flex;
flex: 1;
align-items: flex-end;
height: 100%;
}
.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 {
@@ -55,8 +113,18 @@
}
.seg.in {
background-color: var(--color-text-secondary);
opacity: 0.4;
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);
}
.legend {
@@ -69,8 +137,8 @@
.key {
display: flex;
align-items: center;
gap: 0.375rem;
align-items: center;
}
.swatch {
@@ -83,8 +151,11 @@
}
&.in {
background-color: var(--color-text-secondary);
opacity: 0.4;
background-color: color-mix(
in srgb,
var(--color-primary) 40%,
transparent
);
}
}
</style>
@@ -4,29 +4,32 @@
import { getAvatarHistory } from "#lib/api/endpoints.js";
import type { AvatarHistoryView } from "#lib/api/types.js";
import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
interface Props {
id: number;
items?: AvatarHistoryView[];
kind: AvatarKind;
name: string;
}
let { kind, id, name }: Props = $props();
let { kind, id, name, items: given }: Props = $props();
let items = $state<AvatarHistoryView[]>([]);
let fetched = $state<AvatarHistoryView[]>([]);
const items = $derived(given ?? fetched);
const urls = $state<Record<string, string | null>>({});
$effect(() => {
if (accounts.selectedId === null) {
if (given || accounts.selectedId === null) {
return;
}
let active = true;
items = [];
fetched = [];
getAvatarHistory(kind, id)
.then((result) => {
if (active) {
items = result;
fetched = result;
}
})
.catch(() => undefined);
@@ -59,7 +62,10 @@
{#if items.length > 0}
<section>
<h3>История аватарок ({items.length})</h3>
<h3 class="section-title">
<span>{t("profile.avatars")}</span>
<span class="count">{items.length}</span>
</h3>
<div class="grid">
{#each items as item (item.unique_id)}
<figure title={formatFull(item.first_seen_at)}>
@@ -76,24 +82,34 @@
<style lang="scss">
section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 0 1rem;
border-top: 1px solid var(--color-borders);
}
h3 {
.section-title {
display: flex;
align-items: baseline;
justify-content: space-between;
margin: 0;
font-size: 0.75rem;
padding: 0.5rem 1rem 0.625rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.count {
font-weight: var(--font-weight-normal);
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(4rem, 1fr));
gap: 0.375rem;
padding: 0 1rem;
}
figure {
@@ -0,0 +1,142 @@
<script lang="ts">
import type { ProfileChange, ProfileField } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { peers } from "#lib/stores/peers.svelte.js";
interface Props {
change: ProfileChange;
chatId: number;
}
let { change, chatId }: Props = $props();
const ICONS: Record<ProfileField, string> = {
deleted: "delete-user",
name: "user",
phone: "phone",
title: "edit",
username: "mention",
};
const LABELS: Record<ProfileField, MessageKey> = {
deleted: "profile.accountDeleted",
name: "profile.fieldName",
phone: "profile.fieldPhone",
title: "profile.fieldTitle",
username: "profile.fieldUsername",
};
function shown(value: string | null): string {
if (value === null) {
return t("profile.none");
}
if (change.field === "username") {
return `@${value}`;
}
return change.field === "phone" ? `+${value}` : value;
}
const actor = $derived(
change.actor_id === null ? undefined : peers.get(change.actor_id)
);
const meta = $derived(
[
change.field === "deleted" ? null : t(LABELS[change.field]),
formatFull(change.changed_at),
actor ? peerName(actor) : null,
]
.filter(Boolean)
.join(" · ")
);
const href = $derived(
change.message_id === null
? undefined
: `/app/${chatId}?message=${change.message_id}`
);
</script>
<svelte:element
class="change"
{href}
this={href ? "a" : "div"}
class:link={href !== undefined}
>
<Icon class="change-icon" name={ICONS[change.field]} size="1.5rem" />
<span class="text">
{#if change.field === "deleted"}
<span class="value">{t(LABELS.deleted)}</span>
{:else}
<span class="value">
<span class="old">{shown(change.old)}</span>
<span class="arrow">→</span>
{shown(change.new)}
</span>
{/if}
<span class="meta">{meta}</span>
</span>
</svelte:element>
<style lang="scss">
.change {
display: flex;
gap: 1.5rem;
align-items: center;
min-height: 3.5rem;
padding: 0.5rem 1rem;
color: var(--color-text);
text-decoration: none;
}
.link {
cursor: pointer;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
:global(.change .change-icon) {
flex-shrink: 0;
color: var(--color-icon-secondary);
}
.text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.value {
font-size: 0.9375rem;
overflow-wrap: anywhere;
}
.old {
color: var(--color-text-secondary);
}
.arrow {
color: var(--color-text-secondary);
}
.meta {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,103 @@
<script lang="ts">
import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { goto } from "$app/navigation";
interface Props {
chatId: number;
hasAvatar: boolean;
href: string;
messageId: number;
subtitle: string;
title: string;
}
let { chatId, hasAvatar, href, messageId, subtitle, title }: Props = $props();
</script>
<ContextMenu>
{#snippet children({
props,
})}
<a {...props} class="chat-row" {href}>
<Avatar
avatar={{ id: chatId, kind: "chat" }}
colorKey={chatId}
{hasAvatar}
name={title}
size={2.75}
/>
<span class="text">
<span class="title">{title}</span>
<span class="subtitle">{subtitle}</span>
</span>
</a>
{/snippet}
{#snippet menu()}
<ContextMenuItem
icon="open-in-new-tab"
onselect={() => goto(`/app/${chatId}`)}
>
{t("profile.open")}
</ContextMenuItem>
<ContextMenuItem
icon="show-message"
onselect={() => goto(`/app/${chatId}?message=${messageId}`)}
>
{t("profile.goToMessage")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
<style lang="scss">
.chat-row {
display: flex;
gap: 1rem;
align-items: center;
min-height: 3.5rem;
padding: 0.375rem 1rem;
color: var(--color-text);
text-decoration: none;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.title {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
overflow: hidden;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -1,93 +1,101 @@
<script lang="ts">
import type { AvatarKind } from "#lib/api/avatars.js";
import { getPeerHistory, getStories } from "#lib/api/endpoints.js";
import type { PeerHistoryView, PeerView } from "#lib/api/types.js";
import { ripple } from "#lib/actions/ripple.js";
import {
getChatChanges,
getPeerGroups,
getStories,
} from "#lib/api/endpoints.js";
import type {
MembershipView,
PeerGroups,
PeerOverview,
ProfileChange,
SharedGroupView,
} from "#lib/api/types.js";
import AvatarHistory from "#lib/components/profile/AvatarHistory.svelte";
import ChangeRow from "#lib/components/profile/ChangeRow.svelte";
import ChatRow from "#lib/components/profile/ChatRow.svelte";
import ProfileSection from "#lib/components/profile/ProfileSection.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { formatDate, formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
interface Props {
avatarKind: AvatarKind;
chatId: number;
isDm: boolean;
id: number;
name: string;
peer: PeerView | null;
onstories: () => void;
overview: PeerOverview | null;
}
let { chatId, isDm, peer, name, avatarKind }: Props = $props();
let { chatId, id, name, onstories, overview }: Props = $props();
interface InfoRow {
icon: string;
label: string;
value: string;
}
const isPerson = $derived(id > 0);
const peer = $derived(overview?.peer ?? null);
const dm = $derived(overview?.dm ?? null);
let groups = $state<PeerGroups | null>(null);
let titleChanges = $state<ProfileChange[]>([]);
let storyCount = $state(0);
const changes = $derived(isPerson ? (overview?.changes ?? []) : titleChanges);
const rows = $derived.by(() => {
const result: InfoRow[] = [];
if (peer?.username) {
result.push({ label: "Имя пользователя", value: `@${peer.username}` });
}
if (peer?.phone) {
result.push({ label: "Телефон", value: `+${peer.phone}` });
result.push({
icon: "phone",
label: t("profile.phone"),
value: `+${peer.phone}`,
});
}
result.push({ label: "ID", value: String(chatId) });
if (peer?.username) {
result.push({
icon: "mention",
label: t("profile.username"),
value: `@${peer.username}`,
});
}
result.push({ icon: "info", label: "ID", value: String(id) });
if (peer?.is_deleted_account) {
result.push({ label: "Статус", value: "Удалённый аккаунт" });
result.push({
icon: "delete-user",
label: t("profile.status"),
value: t("profile.deletedAccount"),
});
}
if (peer?.updated_at) {
result.push({ label: "Обновлено", value: formatFull(peer.updated_at) });
result.push({
icon: "recent",
label: t("profile.updated"),
value: formatFull(peer.updated_at),
});
}
return result;
});
let history = $state<PeerHistoryView[]>([]);
function fingerprint(entry: PeerHistoryView): string {
return [
entry.first_name,
entry.last_name,
entry.username,
entry.phone,
entry.is_deleted_account,
].join("|");
}
const changes = $derived.by(() => {
const result: PeerHistoryView[] = [];
let last: string | null = null;
for (const entry of history) {
const print = fingerprint(entry);
if (print !== last) {
result.push(entry);
last = print;
}
}
return result;
});
function entryName(entry: PeerHistoryView): string {
const parts = [entry.first_name, entry.last_name].filter(Boolean);
if (parts.length > 0) {
return parts.join(" ");
}
if (entry.username) {
return `@${entry.username}`;
}
return "—";
}
$effect(() => {
if (accounts.selectedId === null || !isDm) {
history = [];
if (accounts.selectedId === null || !isPerson) {
groups = null;
return;
}
const peerId = id;
let active = true;
history = [];
getPeerHistory(chatId)
groups = null;
getPeerGroups(peerId)
.then((result) => {
if (active) {
history = result;
groups = result;
}
})
.catch(() => undefined);
@@ -96,17 +104,44 @@
};
});
let storyCount = $state(0);
$effect(() => {
if (accounts.selectedId === null || isPerson) {
titleChanges = [];
return;
}
const groupId = id;
let active = true;
titleChanges = [];
getChatChanges(groupId)
.then((result) => {
if (active) {
titleChanges = result;
}
})
.catch(() => undefined);
return () => {
active = false;
};
});
$effect(() => {
const actors = titleChanges
.map((change) => change.actor_id)
.filter((actor) => actor !== null);
if (actors.length > 0) {
peers.ensure(actors);
}
});
$effect(() => {
if (accounts.selectedId === null) {
storyCount = 0;
return;
}
const id = chatId;
const storyOwner = id;
let active = true;
storyCount = 0;
getStories(id, { limit: 100 })
getStories(storyOwner, { limit: 100 })
.then((result) => {
if (active) {
storyCount = result.length;
@@ -117,51 +152,164 @@
active = false;
};
});
function membershipAction(item: MembershipView): string {
const join = item.event === "join";
if (item.actor_id === null || item.actor_id === id) {
return join ? t("profile.joined") : t("profile.left");
}
if (item.actor_id === accounts.selected?.tg_user_id) {
return join ? t("profile.addedByYou") : t("profile.removedByYou");
}
const actor = item.actor_name ?? String(item.actor_id);
return join
? t("profile.addedBy", { name: actor })
: t("profile.removedBy", { name: actor });
}
function membershipLine(item: MembershipView): string {
return `${membershipAction(item)} · ${formatDate(item.ts)}`;
}
function groupLine(group: SharedGroupView): string {
const count = t("profile.messages", { count: group.message_count });
return `${count} · ${formatDate(group.last_date)}`;
}
async function openAnalytics() {
if (id !== chatId) {
await goto(`/app/${id}`);
}
ui.openPanel("presence");
}
</script>
{#snippet action(
icon: string,
label: string,
onclick: () => void,
value = ""
)}
<button class="row action" {onclick} type="button" use:ripple>
<Icon class="row-icon" name={icon} size="1.5rem" />
<span class="label">{label}</span>
{#if value}
<span class="end">{value}</span>
{/if}
<Icon class="row-chevron" name="next" size="1rem" />
</button>
{/snippet}
<div class="info-tab">
{#if rows.length > 0}
<section>
<dl>
{#each rows as row (row.label)}
<div class="row">
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
{/each}
</dl>
<section class="rows">
{#each rows as row (row.label)}
<div class="row">
<Icon class="row-icon" name={row.icon} size="1.5rem" />
<span class="text">
<span class="value">{row.value}</span>
<span class="caption">{row.label}</span>
</span>
</div>
{/each}
{#if isPerson}
<div class="row">
<Icon class="row-icon" name="delete" size="1.5rem" />
<span class="label">{t("profile.deletedMessages")}</span>
<span class="end">{groups?.deleted_count ?? ""}</span>
</div>
{/if}
</section>
{#if (isPerson && overview) || storyCount > 0}
<section class="rows">
{#if isPerson && dm && id !== chatId}
{@render action(
"message",
t("profile.openChat"),
() => goto(`/app/${id}`),
t("profile.messages", { count: dm.message_count })
)}
{:else if isPerson && !dm}
<div class="row">
<Icon class="row-icon" name="message" size="1.5rem" />
<span class="label muted">{t("profile.noChat")}</span>
</div>
{/if}
{#if isPerson && dm}
{@render action("stats", t("profile.analytics"), openAnalytics)}
{/if}
{#if storyCount > 0}
{@render action(
"play-story",
t("profile.stories"),
onstories,
String(storyCount)
)}
{/if}
</section>
{/if}
{#if storyCount > 0}
<section>
<button
class="entry"
onclick={() => ui.openPanel("stories")}
type="button"
>
<span class="entry-icon"><Icon name="play-story" /></span>
<span class="entry-label">Сторис</span>
<span class="entry-count">{storyCount}</span>
<Icon name="next" size="1rem" />
</button>
</section>
<ProfileSection
items={changes}
key={(change) => `${change.field}:${change.changed_at}`}
title={isPerson ? t("profile.changes") : t("profile.titleChanges")}
>
{#snippet row(
change
)}
<ChangeRow {change} chatId={id} />
{/snippet}
</ProfileSection>
{#if !isPerson || overview}
<AvatarHistory
{id}
items={overview?.avatars}
kind={isPerson ? "peer" : "chat"}
{name}
/>
{/if}
<AvatarHistory id={chatId} kind={avatarKind} {name} />
{#if groups}
<ProfileSection
items={groups.groups}
key={(group) => group.chat_id}
title={t("profile.groups")}
>
{#snippet row(
group
)}
<ChatRow
chatId={group.chat_id}
hasAvatar={group.has_avatar}
href="/app/{group.chat_id}"
messageId={group.last_message_id}
subtitle={groupLine(group)}
title={group.title ?? t("profile.chat", { id: group.chat_id })}
/>
{/snippet}
</ProfileSection>
{/if}
{#if changes.length > 1}
<section>
<h3>История профиля</h3>
<ul class="changes">
{#each changes as entry (entry.observed_at)}
<li>
<span class="label">{entryName(entry)}</span>
<span class="time">{formatFull(entry.observed_at)}</span>
</li>
{/each}
</ul>
</section>
{#if overview}
<ProfileSection
items={overview.memberships}
key={(item) => `${item.chat_id}:${item.message_id}`}
title={t("profile.memberships")}
>
{#snippet row(
item
)}
<ChatRow
chatId={item.chat_id}
hasAvatar={item.has_avatar}
href="/app/{item.chat_id}?message={item.message_id}"
messageId={item.message_id}
subtitle={membershipLine(item)}
title={item.chat_title ?? t("profile.chat", { id: item.chat_id })}
/>
{/snippet}
</ProfileSection>
{/if}
</div>
@@ -169,119 +317,94 @@
.info-tab {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
padding-bottom: 1rem;
}
section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rows {
padding: 0.5rem 0;
h3 {
margin: 0;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
dl {
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0;
& + .rows {
border-top: 1px solid var(--color-borders);
}
}
.row {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
dt {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
dd {
margin: 0;
font-size: 0.9375rem;
color: var(--color-text);
overflow-wrap: anywhere;
}
.entry {
display: flex;
gap: 1.5rem;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.5rem;
border: 0;
border-radius: 0.5rem;
min-height: 3.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
color: var(--color-text);
background: transparent;
}
&:hover {
background-color: var(--color-background-secondary);
.action {
cursor: pointer;
position: relative;
overflow: hidden;
border: 0;
font: inherit;
text-align: start;
background-color: transparent;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.entry-icon {
display: flex;
:global(.info-tab .row-icon),
:global(.info-tab .row-chevron) {
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
color: var(--color-icon-secondary);
}
.entry-label {
flex: 1;
font-size: 0.9375rem;
text-align: left;
}
.entry-count {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.changes {
.text {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
gap: 0.125rem;
min-width: 0;
}
.changes li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
.value {
font-size: 0.9375rem;
overflow-wrap: anywhere;
}
.caption {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.label {
overflow: hidden;
flex: 1;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
.muted {
color: var(--color-text-secondary);
}
.end {
flex-shrink: 0;
font-size: 0.9375rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more