Compare commits

...
67 Commits
Author SHA1 Message Date
renovate Bot b9e8a96774 chore(deps): update ghcr.io/astral-sh/uv:python3.13-trixie-slim docker digest to 331fbb1 (main) (#22)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-25 04:11:41 +00:00
hh d660079c02 feat(backend,frontend): search everywhere in buttons, links and message data, a jump from search or an alert lights up the found words and buttons 2026-10-23 22:59:29 +00:00
hh acfa726e61 fix(backend,frontend): expired story keeps its kind and archive heals stories that lost it, phone inputs no longer zoom the page 2026-10-23 16:41:42 +02:00
hh d9378056f4 fix(frontend): chat header keeps the name readable on a phone, all its actions are in its menu, menus open below the pointer and stay on screen 2026-10-19 06:19:46 +00:00
hh c307426f4e fix(frontend): context menus stay inside a narrow screen 2026-10-19 06:12:48 +00:00
hh 977dee3cb2 chore(frontend): toasts lose the success kind nobody calls anymore 2026-10-19 06:07:54 +00:00
hh bca0b34636 feat(frontend): a finished action answers on the button that was pressed instead of a toast 2026-10-19 06:07:54 +00:00
hh e228d999dd fix(frontend): account phone stored with a plus is not shown with two 2026-10-19 05:47:09 +00:00
hh aff8a9e443 fix(frontend): empty media and links tabs keep a gap under the tabs instead of sticking to them 2026-10-19 05:38:24 +00:00
hh 25364f4523 feat(frontend): forwarded header, pinned bar, folder tabs, inline buttons, edit history and deleted accounts speak russian and english 2026-10-19 05:38:24 +00:00
hh 0cd34f3037 feat(frontend): profile media, files and links tabs speak russian and english 2026-10-19 05:38:24 +00:00
hh cff493f5dd feat(frontend): analytics panel and online statuses speak russian and english with real plurals 2026-10-19 05:37:44 +00:00
hh 543e6eaa50 feat(frontend): service messages speak russian and english 2026-10-19 05:32:34 +00:00
hh 7baa32f400 chore(frontend): notes dictionary drops a string the shared ui one replaced 2026-10-19 05:26:53 +00:00
hh e977ab77a8 fix(frontend): a filter written to the url survives another url write, the chat picked with alt and arrows stays in view 2026-10-19 05:26:20 +00:00
hh 8a1723e99f fix(frontend): panel history survives a link that rewrites its own url, dictionaries reuse the thread and panel strings of the feed 2026-10-19 05:26:20 +00:00
hh c06597aba2 chore(backend): chat stats migration follows the search migration 2026-10-19 05:26:11 +00:00
hh 7f3458b3ef docs: mcp tool list matches the server 2026-10-19 05:26:11 +00:00
hh 4a32e32114 feat(frontend): online dots on private chats, deleted last message struck through, back from a chat goes through history 2026-10-19 05:26:11 +00:00
hh 9c556ff8b9 feat(frontend): notes list with tag chips and the tag in the url, from the menu and settings, capture status dot on the menu and a line in settings 2026-10-19 05:26:11 +00:00
hh 171d8d922d feat(frontend): exact time on the message time, deletion and edit times in the meta, note marker, copy link answered in the menu, media menu keeps the message items, date pill opens the calendar, capture gaps between messages 2026-10-19 05:25:54 +00:00
hh 848d2aca1a feat(frontend): right panel lives in browser history and back closes it first, message panels close on chat switch, hotkeys for search, chats, in-chat search and closing 2026-10-19 05:25:54 +00:00
hh 7f7fc1296e feat(watcher): watched bots report capture uptime like the userbot 2026-10-19 05:25:44 +00:00
hh f62c345e2b feat(backend): who of the given people is online now in one request 2026-10-19 05:25:44 +00:00
hh 9ae8b34005 feat(backend): capture status and the stretches the archive was not recording 2026-10-19 05:25:44 +00:00
hh a0e134756c feat(backend): notes list across chats with the message and chat they belong to, filter by tag, tag counts 2026-10-19 05:25:44 +00:00
hh 97f9082f5e feat(backend): chat list marks a deleted last message, history pages mark messages with a note 2026-10-19 05:25:44 +00:00
hh 90b37ec708 fix(frontend): jobs name their chats, open links tab counts only links that are really open 2026-10-19 03:53:08 +00:00
hh 4082adba25 fix(frontend): buttons keep a gap between icon and text, viewer counter stays clear of the actions on mobile 2026-10-19 03:53:08 +00:00
hh b3805123b5 fix(frontend): english storage and policy labels fit on mobile, phone placeholder no longer peeks out from under its label 2026-10-19 03:53:08 +00:00
hh 896b89f3b0 refactor(frontend): stories archives drop tile styles and imports that live in the tile 2026-10-19 03:53:08 +00:00
hh 4a5c92f0e9 feat(frontend): storage, trash, policy, stories, shares, jobs, bots, media, login and notes speak russian and english 2026-10-19 03:53:08 +00:00
hh e5a5edcaae perf(backend): search stays in tens of milliseconds at archive size for rare words, channels, deleted and transcripts 2026-10-19 03:41:49 +00:00
hh 9a6bd8a714 feat(frontend): search results show deleted, edited and earlier version marks with highlighted words, filter chips live in the url, results load on scroll 2026-10-19 03:41:44 +00:00
hh d45c96c259 feat(backend): search finds word prefixes, earlier versions and deleted messages, with filters and cursor paging 2026-10-19 03:41:39 +00:00
hh 42b4e1d7e6 fix(frontend): chips of the changes panel stay on top while the list scrolls 2026-10-19 03:27:27 +00:00
hh 309614c979 fix(frontend): channel posts show no sender id, a post sent as a chat shows its title and avatar 2026-10-19 03:25:48 +00:00
hh 98dda99c5c feat(frontend): profiles chip in the feed shows name, username, photo and title changes 2026-10-19 03:25:48 +00:00
hh 8e0f828dd4 feat(backend): feed lists name, username and photo changes of people and chat renames, merged per day 2026-10-19 03:25:43 +00:00
hh 9aa8690e33 chore(backend): feed migrations follow the profile history migration 2026-10-19 03:06:20 +00:00
hh dadba4d545 fix(frontend): message highlight and active folder tab are visible in the light theme 2026-10-19 03:00:23 +00:00
hh fffbb08d36 fix(frontend): a link to a message opens the chat right at it without loading the latest page first 2026-10-19 03:00:23 +00:00
hh c247dd0217 docs: readme mentions the feed and the get_feed tool 2026-10-19 03:00:23 +00:00
hh b9a0604426 fix(frontend): jumps to replies, pins and album photos load the message when it is not on screen 2026-10-19 03:00:12 +00:00
hh 9f16e2aa56 feat(frontend): feed of deleted and edited messages above the chat list and per chat in the changes panel 2026-10-19 03:00:12 +00:00
hh 010fa29ce0 feat(backend): feed items carry the message id even when the message row is gone 2026-10-19 02:59:07 +00:00
hh 395d58464d feat(backend): get_feed mcp tool reads the feed of deleted and edited messages 2026-10-19 02:59:07 +00:00
hh 83c57ac96b feat(backend): feed of deleted and edited messages with bulk deletions grouped and a shared seen marker 2026-10-19 02:58:51 +00:00
hh 45c19cac20 feat(backend): edit versions are kept in groups and channels by default 2026-10-19 02:58:51 +00:00
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
226 changed files with 17461 additions and 2632 deletions

No files matched your search

+3 -3
View File
@@ -11,7 +11,7 @@ Useful on its own: a Telegram where nothing disappears. In the [beaver](https://
- **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out. - **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out.
- **Presence** as a time series (TimescaleDB), read receipts, stories. - **Presence** as a time series (TimescaleDB), read receipts, stories.
- **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default. - **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default.
- **Annotations and watches**: your notes on chats and rules that fire alerts, both yours to write in the web client. - **Annotations and watches**: your notes on messages, with `#tags` and one list of them all, and rules that fire alerts, both yours to write in the web client.
- **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API. - **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API.
Nothing is ever deleted from the archive. Nothing is ever deleted from the archive.
@@ -19,12 +19,12 @@ Nothing is ever deleted from the archive.
## Parts ## Parts
- `backend/` - `userbot` (kurigram, the capture loop and jobs), `watcher` (kurigram again, one bot-token session per watched bot) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files). - `backend/` - `userbot` (kurigram, the capture loop and jobs), `watcher` (kurigram again, one bot-token session per watched bot) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files).
- `frontend/` - SvelteKit, `adapter-static`; a chat client over the archive: folders, chat list, thread with media, versions and the deleted messages inline, search, stories, presence graphs. - `frontend/` - SvelteKit, `adapter-static`; a chat client over the archive: folders, chat list, thread with media, versions and the deleted messages inline, a feed of what was deleted or edited across chats, search, stories, presence graphs.
- `docker-compose.yml` - includes `backend/` (Postgres with TimescaleDB, `userbot`, `api`) and `frontend/` (the dev server); profiles `external` and `services`. - `docker-compose.yml` - includes `backend/` (Postgres with TimescaleDB, `userbot`, `api`) and `frontend/` (the dev server); profiles `external` and `services`.
## MCP ## MCP
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `presence_stats`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be. Read-only: `search_messages_tool`, `list_chats`, `get_chat_history` (a readable transcript, deleted messages included) and `get_chat_history_raw` (the same as JSON), `get_deleted_messages`, `get_feed` (deleted and edited messages and profile changes across chats), `get_message_versions`, `get_media`, `get_callbacks`, `get_read_info`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. Two tools write: `set_watch`, and `download_media`, which pulls any attachment and leaves a public link to it for a day, visible and revocable in the web client. There are no send, edit or delete tools and there will not be.
## Run ## Run
+1
View File
@@ -25,6 +25,7 @@ BOTS__BACKFILL_PER_CHAT=0
API__HOST=0.0.0.0 API__HOST=0.0.0.0
API__PORT=8080 API__PORT=8080
API__PUBLIC_URL=
AUTH__TOKEN= AUTH__TOKEN=
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:7dd72af08bdef012fdf433082190d2b5133a96b6a43aca0bb71c2aa793e47e20 FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:331fbb158258608b9391e802305a9a2c9e42d689435c1d98706647122a122ab0
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,32 @@
"""edit versions everywhere
Revision ID: a2c4e6f8b1d3
Revises: e8b3f1a6c2d4
Create Date: 2026-10-19 02:30:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "a2c4e6f8b1d3"
down_revision: str | None = "e8b3f1a6c2d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"UPDATE capture_policy SET track_edits_deletes = TRUE, updated_at = now() "
"WHERE scope_type IN ('default_group', 'default_channel') "
"AND NOT track_edits_deletes"
)
def downgrade() -> None:
op.execute(
"UPDATE capture_policy SET track_edits_deletes = FALSE, updated_at = now() "
"WHERE scope_type IN ('default_group', 'default_channel') "
"AND track_edits_deletes"
)
@@ -0,0 +1,198 @@
"""feed of deleted and edited messages
Revision ID: b3d5f7a9c2e4
Revises: a2c4e6f8b1d3
Create Date: 2026-10-19 02:40:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "b3d5f7a9c2e4"
down_revision: str | None = "a2c4e6f8b1d3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_ITEMS = """
CREATE TABLE feed_items (
id bigserial PRIMARY KEY,
account_id integer NOT NULL,
kind text NOT NULL,
chat_id bigint NOT NULL,
chat_kind text NOT NULL,
message_id bigint NOT NULL,
message_date timestamptz,
at timestamptz NOT NULL,
since timestamptz NOT NULL,
message_count integer NOT NULL DEFAULT 1
)
"""
_SEEN = """
CREATE TABLE feed_seen (
account_id integer PRIMARY KEY,
seen_at timestamptz NOT NULL
)
"""
_CHAT_KIND = """
CREATE FUNCTION feed_chat_kind(chat_id bigint, chat_type text)
RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT CASE
WHEN chat_id > 0 THEN 'dm'
WHEN chat_type = 'ChatType.CHANNEL' THEN 'channel'
ELSE 'group' END
$$
"""
_MESSAGE_DATE = """
CREATE FUNCTION feed_message_date(stamp text)
RETURNS timestamptz LANGUAGE sql STABLE AS $$
SELECT CASE
WHEN stamp ~ '^\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?$'
THEN stamp::timestamp AT TIME ZONE 'UTC' END
$$
"""
_ON_DELETE = """
CREATE FUNCTION feed_on_delete() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
item feed_items%ROWTYPE;
BEGIN
SELECT * INTO item FROM feed_items
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
AND kind = 'deleted'
ORDER BY at DESC, id DESC LIMIT 1;
IF FOUND AND NEW.deleted_at BETWEEN item.since - interval '5 seconds'
AND item.at + interval '5 seconds' THEN
IF (NEW.date, NEW.message_id) > (item.message_date, item.message_id) THEN
item.message_id := NEW.message_id;
item.message_date := NEW.date;
END IF;
UPDATE feed_items SET
message_count = message_count + 1,
at = greatest(at, NEW.deleted_at),
since = least(since, NEW.deleted_at),
message_id = item.message_id,
message_date = item.message_date
WHERE id = item.id;
ELSE
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, message_date,
at, since)
VALUES (NEW.account_id, 'deleted', NEW.chat_id,
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
NEW.message_id, NEW.date, NEW.deleted_at, NEW.deleted_at);
END IF;
RETURN NULL;
END;
$$
"""
_ON_VERSION = """
CREATE FUNCTION feed_on_version() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.edit_date IS NULL AND NOT EXISTS (
SELECT 1 FROM message_versions v
WHERE v.account_id = NEW.account_id AND v.chat_id = NEW.chat_id
AND v.message_id = NEW.message_id AND v.observed_at < NEW.observed_at
) THEN
RETURN NULL;
END IF;
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, message_date, at, since)
VALUES (NEW.account_id, 'edited', NEW.chat_id,
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
NEW.message_id, feed_message_date(NEW.raw->>'date'),
NEW.observed_at, NEW.observed_at);
RETURN NULL;
END;
$$
"""
_BACKFILL = """
WITH d AS (
SELECT account_id, chat_id, message_id, date, deleted_at,
raw->'chat'->>'type' AS chat_type,
deleted_at - lag(deleted_at) OVER (
PARTITION BY account_id, chat_id ORDER BY deleted_at, message_id
) AS gap
FROM messages WHERE deleted_at IS NOT NULL
), b AS (
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '5 seconds')
OVER (PARTITION BY account_id, chat_id ORDER BY deleted_at, message_id)
AS burst
FROM d
), deleted AS (
SELECT account_id, 'deleted' AS kind, chat_id,
feed_chat_kind(chat_id,
(array_agg(chat_type ORDER BY date DESC, message_id DESC))[1])
AS chat_kind,
(array_agg(message_id ORDER BY date DESC, message_id DESC))[1]
AS message_id,
max(date) AS message_date, max(deleted_at) AS at,
min(deleted_at) AS since, count(*)::integer AS message_count
FROM b GROUP BY account_id, chat_id, burst
), edited AS (
SELECT v.account_id, 'edited' AS kind, v.chat_id,
feed_chat_kind(v.chat_id, v.raw->'chat'->>'type') AS chat_kind,
v.message_id, feed_message_date(v.raw->>'date') AS message_date,
v.observed_at AS at, v.observed_at AS since, 1 AS message_count
FROM message_versions v
WHERE v.edit_date IS NOT NULL OR EXISTS (
SELECT 1 FROM message_versions p
WHERE p.account_id = v.account_id AND p.chat_id = v.chat_id
AND p.message_id = v.message_id AND p.observed_at < v.observed_at
)
)
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, message_date, at, since,
message_count)
SELECT * FROM (SELECT * FROM deleted UNION ALL SELECT * FROM edited) items
ORDER BY at, chat_id, message_id
"""
def upgrade() -> None:
op.execute("SET LOCAL jit = off")
op.execute(_ITEMS)
op.execute(_SEEN)
op.execute(_CHAT_KIND)
op.execute(_MESSAGE_DATE)
op.execute(_ON_DELETE)
op.execute(_ON_VERSION)
op.execute(
"CREATE TRIGGER messages_feed_deleted AFTER UPDATE OF deleted_at "
"ON messages FOR EACH ROW "
"WHEN (OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL) "
"EXECUTE FUNCTION feed_on_delete()"
)
op.execute(
"CREATE TRIGGER message_versions_feed AFTER INSERT ON message_versions "
"FOR EACH ROW EXECUTE FUNCTION feed_on_version()"
)
op.execute(_BACKFILL)
op.execute(
"CREATE INDEX ix_feed_items_recent ON feed_items (account_id, at DESC, id DESC)"
)
op.execute(
"CREATE INDEX ix_feed_items_chat ON feed_items "
"(account_id, chat_id, at DESC, id DESC)"
)
op.execute(
"INSERT INTO feed_seen (account_id, seen_at) SELECT account_id, now() "
"FROM accounts"
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS message_versions_feed ON message_versions")
op.execute("DROP TRIGGER IF EXISTS messages_feed_deleted ON messages")
op.execute("DROP FUNCTION IF EXISTS feed_on_version()")
op.execute("DROP FUNCTION IF EXISTS feed_on_delete()")
op.execute("DROP FUNCTION IF EXISTS feed_message_date(text)")
op.execute("DROP FUNCTION IF EXISTS feed_chat_kind(bigint, text)")
op.execute("DROP TABLE IF EXISTS feed_seen")
op.execute("DROP TABLE IF EXISTS feed_items")
@@ -0,0 +1,321 @@
"""feed of profile changes and chat renames
Revision ID: c5e7a9b1d3f6
Revises: b3d5f7a9c2e4
Create Date: 2026-10-19 04:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "c5e7a9b1d3f6"
down_revision: str | None = "b3d5f7a9c2e4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_PEER_VALUE = """
CREATE FUNCTION feed_peer_value(
field text, first_name text, last_name text, username text, photo text
) RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT CASE field
WHEN 'name' THEN NULLIF(trim(concat_ws(' ', first_name, last_name)), '')
WHEN 'username' THEN NULLIF(username, '')
WHEN 'photo' THEN NULLIF(photo, '') END
$$
"""
_PEER_SCOPE = """
CREATE FUNCTION feed_peer_in_scope(account integer, peer bigint)
RETURNS boolean LANGUAGE sql STABLE AS $$
SELECT EXISTS (
SELECT 1 FROM dialogs WHERE account_id = account AND chat_id = peer
) OR EXISTS (
SELECT 1 FROM peers
WHERE account_id = account AND peer_id = peer
AND (raw->>'is_contact' = 'true' OR raw->>'contact' = 'true')
)
$$
"""
_MERGE_DETAIL = """
CREATE FUNCTION feed_merge_detail(base jsonb, incoming jsonb)
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
SELECT coalesce(jsonb_object_agg(key, pair), '{}'::jsonb)
FROM (
SELECT key, jsonb_build_array(
coalesce(base->key->0, incoming->key->0),
coalesce(incoming->key->1, base->key->1)) AS pair
FROM (
SELECT jsonb_object_keys(coalesce(base, '{}'::jsonb)) AS key
UNION
SELECT jsonb_object_keys(incoming)
) keys
) merged
WHERE pair->0 IS DISTINCT FROM pair->1
$$
"""
_UPSERT = """
CREATE FUNCTION feed_profile_upsert(
account integer, chat bigint, kind_of_chat text, message bigint,
changed_at timestamptz, changes jsonb
) RETURNS void LANGUAGE plpgsql AS $$
DECLARE
item feed_items%ROWTYPE;
merged jsonb;
BEGIN
SELECT * INTO item FROM feed_items
WHERE account_id = account AND chat_id = chat AND kind = 'profile'
ORDER BY at DESC, id DESC LIMIT 1;
IF FOUND AND changed_at BETWEEN item.since - interval '1 day'
AND item.at + interval '1 day' THEN
merged := feed_merge_detail(item.detail, changes);
IF merged = '{}'::jsonb THEN
DELETE FROM feed_items WHERE id = item.id;
ELSE
UPDATE feed_items SET
detail = merged,
message_count = message_count
+ (SELECT count(*) FROM jsonb_object_keys(changes)),
at = greatest(at, changed_at),
since = least(since, changed_at),
message_id = greatest(message_id, message)
WHERE id = item.id;
END IF;
ELSE
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, at, since,
message_count, detail)
VALUES (account, 'profile', chat, kind_of_chat, message, changed_at,
changed_at,
(SELECT count(*) FROM jsonb_object_keys(changes)), changes);
END IF;
END;
$$
"""
_ON_PEER = """
CREATE FUNCTION feed_on_peer_change() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
changes jsonb := '{}'::jsonb;
field text;
fresh text;
old text;
BEGIN
IF NEW.peer_id <= 0 OR NOT feed_peer_in_scope(NEW.account_id, NEW.peer_id) THEN
RETURN NULL;
END IF;
FOREACH field IN ARRAY ARRAY['name', 'username', 'photo'] LOOP
fresh := feed_peer_value(field, NEW.first_name, NEW.last_name,
NEW.username, NEW.photo_unique_id);
CONTINUE WHEN fresh IS NULL;
SELECT value INTO old FROM (
SELECT feed_peer_value(field, h.first_name, h.last_name, h.username,
h.photo_unique_id) AS value, h.observed_at
FROM peer_history h
WHERE h.account_id = NEW.account_id AND h.peer_id = NEW.peer_id
AND h.observed_at < NEW.observed_at
) earlier
WHERE value IS NOT NULL
ORDER BY observed_at DESC LIMIT 1;
CONTINUE WHEN old IS NULL OR old = fresh;
CONTINUE WHEN EXISTS (
SELECT 1 FROM peer_history h
WHERE h.account_id = NEW.account_id AND h.peer_id = NEW.peer_id
AND h.observed_at < NEW.observed_at
AND feed_peer_value(field, h.first_name, h.last_name, h.username,
h.photo_unique_id) = fresh
);
changes := changes || jsonb_build_object(field, jsonb_build_array(old, fresh));
END LOOP;
IF changes <> '{}'::jsonb THEN
PERFORM feed_profile_upsert(NEW.account_id, NEW.peer_id, 'dm', 0,
NEW.observed_at, changes);
END IF;
RETURN NULL;
END;
$$
"""
_ON_TITLE = """
CREATE FUNCTION feed_on_chat_title() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
previous text;
BEGIN
IF NOT EXISTS (
SELECT 1 FROM dialogs
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
) THEN
RETURN NULL;
END IF;
SELECT title INTO previous FROM chat_history
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
AND event IN ('meta', 'title') AND title IS NOT NULL
AND (ts, message_id) < (NEW.ts, NEW.message_id)
ORDER BY ts DESC, message_id DESC LIMIT 1;
IF previous IS NULL OR previous = NEW.title THEN
RETURN NULL;
END IF;
PERFORM feed_profile_upsert(
NEW.account_id, NEW.chat_id,
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
CASE WHEN NEW.event = 'title' THEN NEW.message_id ELSE 0 END,
NEW.ts, jsonb_build_object('title', jsonb_build_array(previous, NEW.title)));
RETURN NULL;
END;
$$
"""
_BACKFILL_PEERS = """
WITH scope AS (
SELECT account_id::integer AS account_id, chat_id AS peer_id
FROM dialogs WHERE chat_id > 0
UNION
SELECT account_id, peer_id FROM peers
WHERE raw->>'is_contact' = 'true' OR raw->>'contact' = 'true'
), firsts AS (
SELECT h.account_id, h.peer_id, f.field, f.value,
min(h.observed_at) AS at
FROM peer_history h
JOIN scope s ON s.account_id = h.account_id AND s.peer_id = h.peer_id
CROSS JOIN LATERAL (
SELECT field, feed_peer_value(field, h.first_name, h.last_name,
h.username, h.photo_unique_id) AS value
FROM unnest(ARRAY['name', 'username', 'photo']) AS field
) f
WHERE f.value IS NOT NULL
GROUP BY 1, 2, 3, 4
), ranked AS (
SELECT *, row_number() OVER (
PARTITION BY account_id, peer_id, field ORDER BY at
) AS position
FROM firsts
), changes AS (
SELECT r.account_id, r.peer_id, r.field, r.value AS fresh, r.at, o.value AS old
FROM ranked r
CROSS JOIN LATERAL (
SELECT value FROM (
SELECT feed_peer_value(r.field, h.first_name, h.last_name,
h.username, h.photo_unique_id) AS value,
h.observed_at
FROM peer_history h
WHERE h.account_id = r.account_id AND h.peer_id = r.peer_id
AND h.observed_at < r.at
) earlier
WHERE value IS NOT NULL
ORDER BY observed_at DESC LIMIT 1
) o
WHERE r.position > 1 AND r.at > now() - interval '90 days'
AND o.value <> r.value
), gaps AS (
SELECT *, at - lag(at) OVER (PARTITION BY account_id, peer_id ORDER BY at)
AS gap
FROM changes
), bursts AS (
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '1 day')
OVER (PARTITION BY account_id, peer_id ORDER BY at) AS burst
FROM gaps
), fields AS (
SELECT account_id, peer_id, burst, field,
(array_agg(old ORDER BY at))[1] AS old,
(array_agg(fresh ORDER BY at DESC))[1] AS fresh,
count(*) AS changes, min(at) AS since, max(at) AS at
FROM bursts GROUP BY 1, 2, 3, 4
)
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, at, since,
message_count, detail)
SELECT account_id, 'profile', peer_id, 'dm', 0, max(at), min(since),
sum(changes)::integer,
jsonb_object_agg(field, jsonb_build_array(old, fresh))
FILTER (WHERE old <> fresh)
FROM fields
GROUP BY account_id, peer_id, burst
HAVING bool_or(old <> fresh)
ORDER BY max(at)
"""
_BACKFILL_CHATS = """
WITH titles AS (
SELECT account_id, chat_id, message_id, event, ts, title,
raw->'chat'->>'type' AS chat_type,
lag(title) OVER (
PARTITION BY account_id, chat_id ORDER BY ts, message_id
) AS previous
FROM chat_history
WHERE event IN ('meta', 'title') AND title IS NOT NULL
), changes AS (
SELECT * FROM titles t
WHERE previous IS NOT NULL AND previous <> title
AND ts > now() - interval '90 days'
AND EXISTS (
SELECT 1 FROM dialogs d
WHERE d.account_id = t.account_id AND d.chat_id = t.chat_id
)
), gaps AS (
SELECT *, ts - lag(ts) OVER (PARTITION BY account_id, chat_id ORDER BY ts)
AS gap
FROM changes
), bursts AS (
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '1 day')
OVER (PARTITION BY account_id, chat_id ORDER BY ts) AS burst
FROM gaps
), grouped AS (
SELECT account_id, chat_id, burst,
(array_agg(chat_type ORDER BY ts DESC))[1] AS chat_type,
max(CASE WHEN event = 'title' THEN message_id ELSE 0 END) AS message_id,
(array_agg(previous ORDER BY ts))[1] AS old,
(array_agg(title ORDER BY ts DESC))[1] AS fresh,
count(*) AS changes, min(ts) AS since, max(ts) AS at
FROM bursts GROUP BY 1, 2, 3
)
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, at, since,
message_count, detail)
SELECT account_id, 'profile', chat_id, feed_chat_kind(chat_id, chat_type),
message_id, at, since, changes::integer,
jsonb_build_object('title', jsonb_build_array(old, fresh))
FROM grouped
WHERE old <> fresh
ORDER BY at
"""
def upgrade() -> None:
op.execute("SET LOCAL jit = off")
op.execute("ALTER TABLE feed_items ADD COLUMN detail jsonb")
op.execute(_PEER_VALUE)
op.execute(_PEER_SCOPE)
op.execute(_MERGE_DETAIL)
op.execute(_UPSERT)
op.execute(_ON_PEER)
op.execute(_ON_TITLE)
op.execute(
"CREATE TRIGGER peer_history_feed AFTER INSERT ON peer_history "
"FOR EACH ROW EXECUTE FUNCTION feed_on_peer_change()"
)
op.execute(
"CREATE TRIGGER chat_history_feed AFTER INSERT ON chat_history "
"FOR EACH ROW WHEN (NEW.event IN ('meta', 'title') AND NEW.title IS NOT NULL) "
"EXECUTE FUNCTION feed_on_chat_title()"
)
op.execute(_BACKFILL_PEERS)
op.execute(_BACKFILL_CHATS)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS chat_history_feed ON chat_history")
op.execute("DROP TRIGGER IF EXISTS peer_history_feed ON peer_history")
op.execute("DROP FUNCTION IF EXISTS feed_on_chat_title()")
op.execute("DROP FUNCTION IF EXISTS feed_on_peer_change()")
op.execute(
"DROP FUNCTION IF EXISTS "
"feed_profile_upsert(integer, bigint, text, bigint, timestamptz, jsonb)"
)
op.execute("DROP FUNCTION IF EXISTS feed_merge_detail(jsonb, jsonb)")
op.execute("DROP FUNCTION IF EXISTS feed_peer_in_scope(integer, bigint)")
op.execute("DROP FUNCTION IF EXISTS feed_peer_value(text, text, text, text, text)")
op.execute("DELETE FROM feed_items WHERE kind = 'profile'")
op.execute("ALTER TABLE feed_items DROP COLUMN detail")
@@ -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,148 @@
"""message search index outside the hypertable, versions searchable
Revision ID: d8f1a6c3e5b7
Revises: c5e7a9b1d3f6
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d8f1a6c3e5b7"
down_revision: str | None = "c5e7a9b1d3f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_VECTOR = """
CREATE FUNCTION search_vector(body text) RETURNS tsvector
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT to_tsvector('russian', translate(coalesce(body, ''), 'Ёё', 'Ее'))
|| to_tsvector('simple', translate(coalesce(body, ''), 'Ёё', 'Ее'))
$$
"""
_TABLE = """
CREATE TABLE message_search (
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
date timestamptz NOT NULL,
sender_id bigint,
text text,
has_media boolean NOT NULL DEFAULT false,
edited_at timestamptz,
deleted_at timestamptz,
tsv tsvector GENERATED ALWAYS AS (search_vector(text)) STORED,
PRIMARY KEY (account_id, chat_id, message_id, date)
)
"""
_BACKFILL = """
INSERT INTO message_search (account_id, chat_id, message_id, date, sender_id,
text, has_media, edited_at, deleted_at)
SELECT account_id, chat_id, message_id, date, sender_id,
text, has_media, edited_at, deleted_at
FROM messages
"""
_SYNC = """
CREATE FUNCTION message_search_sync() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
DELETE FROM message_search
WHERE account_id = OLD.account_id AND chat_id = OLD.chat_id
AND message_id = OLD.message_id AND date = OLD.date;
RETURN NULL;
END IF;
INSERT INTO message_search (account_id, chat_id, message_id, date,
sender_id, text, has_media, edited_at,
deleted_at)
VALUES (NEW.account_id, NEW.chat_id, NEW.message_id, NEW.date,
NEW.sender_id, NEW.text, NEW.has_media, NEW.edited_at,
NEW.deleted_at)
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
sender_id = EXCLUDED.sender_id,
text = EXCLUDED.text,
has_media = EXCLUDED.has_media,
edited_at = EXCLUDED.edited_at,
deleted_at = EXCLUDED.deleted_at;
RETURN NULL;
END;
$$
"""
_CHANGED = (
"OLD.text IS DISTINCT FROM NEW.text "
"OR OLD.sender_id IS DISTINCT FROM NEW.sender_id "
"OR OLD.has_media IS DISTINCT FROM NEW.has_media "
"OR OLD.edited_at IS DISTINCT FROM NEW.edited_at "
"OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at"
)
def upgrade() -> None:
op.execute(_VECTOR)
op.execute(_TABLE)
op.execute(_BACKFILL)
op.execute("CREATE INDEX ix_message_search_tsv ON message_search USING gin (tsv)")
op.execute(
"CREATE INDEX ix_message_search_recent ON message_search "
"(account_id, date DESC, chat_id DESC, message_id DESC)"
)
op.execute(
"CREATE INDEX ix_message_search_sender ON message_search "
"(account_id, sender_id, date DESC)"
)
for flag in ("deleted", "edited"):
op.execute(
f"CREATE INDEX ix_message_search_{flag} ON message_search "
"(account_id, date DESC, chat_id DESC, message_id DESC) "
f"WHERE {flag}_at IS NOT NULL"
)
op.execute("ALTER TABLE message_search ALTER COLUMN tsv SET STATISTICS 1000")
op.execute(_SYNC)
op.execute(
"CREATE TRIGGER messages_search_insert AFTER INSERT ON messages "
"FOR EACH ROW EXECUTE FUNCTION message_search_sync()"
)
op.execute(
"CREATE TRIGGER messages_search_update AFTER UPDATE ON messages "
f"FOR EACH ROW WHEN ({_CHANGED}) EXECUTE FUNCTION message_search_sync()"
)
op.execute(
"CREATE TRIGGER messages_search_delete AFTER DELETE ON messages "
"FOR EACH ROW EXECUTE FUNCTION message_search_sync()"
)
op.execute(
"CREATE INDEX ix_message_versions_tsv ON message_versions USING gin "
"(search_vector(text))"
)
op.execute("DROP INDEX IF EXISTS ix_media_tsv")
op.execute(
"CREATE INDEX ix_media_tsv ON media USING gin (search_vector(extracted_text))"
)
op.execute("DROP INDEX IF EXISTS ix_messages_tsv")
for index in ("ix_message_versions_tsv", "ix_media_tsv"):
op.execute(f"ALTER INDEX {index} ALTER COLUMN 1 SET STATISTICS 1000")
op.execute("ANALYZE message_search, message_versions, media")
def downgrade() -> None:
op.execute(
"CREATE INDEX ix_messages_tsv ON messages USING gin "
"(to_tsvector('russian', coalesce(text, '')))"
)
op.execute("DROP INDEX IF EXISTS ix_media_tsv")
op.execute(
"CREATE INDEX ix_media_tsv ON media USING gin "
"(to_tsvector('russian', coalesce(extracted_text, '')))"
)
op.execute("DROP INDEX IF EXISTS ix_message_versions_tsv")
op.execute("DROP TRIGGER IF EXISTS messages_search_delete ON messages")
op.execute("DROP TRIGGER IF EXISTS messages_search_update ON messages")
op.execute("DROP TRIGGER IF EXISTS messages_search_insert ON messages")
op.execute("DROP FUNCTION IF EXISTS message_search_sync()")
op.execute("DROP TABLE IF EXISTS message_search")
op.execute("DROP FUNCTION IF EXISTS search_vector(text)")
@@ -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,97 @@
"""chat stats last deleted
Revision ID: f1c4a8e2d6b3
Revises: d8f1a6c3e5b7
Create Date: 2026-10-19 04:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "f1c4a8e2d6b3"
down_revision: str | None = "d8f1a6c3e5b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_APPLY = """
CREATE OR REPLACE FUNCTION chat_stats_apply() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO chat_stats AS cs (account_id, chat_id, message_count,
last_date, last_message_id,
last_text, last_sender_id{column})
VALUES (NEW.account_id, NEW.chat_id, 1,
CASE WHEN NEW.date <= now() + interval '1 day'
THEN NEW.date END,
CASE WHEN NEW.date <= now() + interval '1 day'
THEN NEW.message_id END,
NEW.text, NEW.sender_id{value})
ON CONFLICT (account_id, chat_id) DO UPDATE SET
message_count = cs.message_count + 1,
last_date = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_date ELSE cs.last_date END,
last_message_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_message_id
ELSE cs.last_message_id END,
last_text = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_text ELSE cs.last_text END,
last_sender_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_sender_id
ELSE cs.last_sender_id END{merge};
ELSE
UPDATE chat_stats
SET last_text = NEW.text, last_sender_id = NEW.sender_id{update}
WHERE account_id = NEW.account_id
AND chat_id = NEW.chat_id
AND last_message_id = NEW.message_id;
END IF;
RETURN NULL;
END;
$$
"""
_DELETED_PARTS = {
"column": ", last_deleted",
"value": ", NEW.deleted_at IS NOT NULL",
"merge": (
",\n last_deleted = CASE WHEN chat_stats_newer(cs, EXCLUDED)"
" THEN EXCLUDED.last_deleted ELSE cs.last_deleted END"
),
"update": ", last_deleted = NEW.deleted_at IS NOT NULL",
"deleted": " OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at",
}
_PLAIN_PARTS = dict.fromkeys(_DELETED_PARTS, "")
_UPDATE_TRIGGER = (
"CREATE TRIGGER messages_chat_stats_update AFTER UPDATE ON messages "
"FOR EACH ROW WHEN (OLD.text IS DISTINCT FROM NEW.text "
"OR OLD.sender_id IS DISTINCT FROM NEW.sender_id{deleted}) "
"EXECUTE FUNCTION chat_stats_apply()"
)
_BACKFILL = """
UPDATE chat_stats cs SET last_deleted = true
FROM messages m
WHERE m.account_id = cs.account_id AND m.chat_id = cs.chat_id
AND m.message_id = cs.last_message_id AND m.date = cs.last_date
AND m.deleted_at IS NOT NULL
"""
def upgrade() -> None:
op.execute(
"ALTER TABLE chat_stats ADD COLUMN last_deleted boolean NOT NULL DEFAULT false"
)
op.execute(_BACKFILL)
op.execute(_APPLY.format(**_DELETED_PARTS))
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
op.execute(_UPDATE_TRIGGER.format(**_DELETED_PARTS))
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
op.execute(_UPDATE_TRIGGER.format(**_PLAIN_PARTS))
op.execute(_APPLY.format(**_PLAIN_PARTS))
op.execute("ALTER TABLE chat_stats DROP COLUMN last_deleted")
+1
View File
@@ -20,6 +20,7 @@ dependencies = [
"dishka>=1.4.0", "dishka>=1.4.0",
"greenlet>=3.1.0", "greenlet>=3.1.0",
"fastmcp>=3.3", "fastmcp>=3.3",
"httpx2>=2.13.1",
] ]
[build-system] [build-system]
+10
View File
@@ -21,10 +21,12 @@ from api.routers import (
avatars, avatars,
backfill, backfill,
bots, bots,
capture,
chats, chats,
custom_emoji, custom_emoji,
discover, discover,
events, events,
feed,
files, files,
folders, folders,
media, media,
@@ -38,7 +40,9 @@ from api.routers import (
storage, storage,
stories, stories,
watches, watches,
webhooks,
) )
from api.webhooks import outbox
from dependencies.container import container from dependencies.container import container
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
from utils.env import env from utils.env import env
@@ -58,11 +62,14 @@ mcp_app = mcp.http_app(path="/")
@asynccontextmanager @asynccontextmanager
async def lifespan(app_: Starlette) -> AsyncGenerator[None]: async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
pool = await container.get(asyncpg.Pool) pool = await container.get(asyncpg.Pool)
hub.alert_listeners.append(outbox.wake)
await hub.start(pool) await hub.start(pool)
purger = asyncio.create_task( purger = asyncio.create_task(
purge_loop(pool, await container.get(ContentAddressedStorage)) purge_loop(pool, await container.get(ContentAddressedStorage))
) )
deliverer = asyncio.create_task(outbox.run(pool))
yield yield
deliverer.cancel()
purger.cancel() purger.cancel()
await hub.stop() await hub.stop()
await app_.state.dishka_container.close() await app_.state.dishka_container.close()
@@ -91,6 +98,7 @@ app.include_router(folders.router)
app.include_router(backfill.router) app.include_router(backfill.router)
app.include_router(search.router) app.include_router(search.router)
app.include_router(chats.router) app.include_router(chats.router)
app.include_router(feed.router)
app.include_router(media.router) app.include_router(media.router)
app.include_router(avatars.router) app.include_router(avatars.router)
app.include_router(custom_emoji.router) app.include_router(custom_emoji.router)
@@ -103,9 +111,11 @@ app.include_router(peers.router)
app.include_router(discover.router) app.include_router(discover.router)
app.include_router(annotations.router) app.include_router(annotations.router)
app.include_router(watches.router) app.include_router(watches.router)
app.include_router(webhooks.router)
app.include_router(shares.router) app.include_router(shares.router)
app.include_router(storage.router) app.include_router(storage.router)
app.include_router(files.router) app.include_router(files.router)
app.include_router(capture.router)
app.mount("/mcp", mcp_app) app.mount("/mcp", mcp_app)
+140 -23
View File
@@ -1,3 +1,4 @@
import re
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -23,20 +24,24 @@ from utils.files import resolve_mime
from utils.read import ( from utils.read import (
annotations, annotations,
chats, chats,
feed,
media, media,
peers, peers,
people,
presence, presence,
presence_stats, presence_stats,
social, social,
watches, watches,
) )
from utils.read.accounts import self_user_id, unknown_account_hint from utils.read.accounts import self_user_id, unknown_account_hint
from utils.read.feed import CURSOR_PATTERN, FeedChatKind, FeedFilter, FeedKind
from utils.read.models import DEFAULT_LIMIT, Page from utils.read.models import DEFAULT_LIMIT, Page
from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
from utils.read.read_receipts import read_info from utils.read.read_receipts import read_info
from utils.search.models import SearchFilters from utils.search.models import ChatKind, SearchFilters
from utils.search.repository import search_messages from utils.search.repository import search_messages
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize_params
INSTRUCTIONS = """\ INSTRUCTIONS = """\
beavergram archives Telegram data (chats, messages, media, presence, stories, beavergram archives Telegram data (chats, messages, media, presence, stories,
@@ -67,17 +72,25 @@ Typical flows:
"📝 [your private note, NOT in Telegram]" are the user's own annotations "📝 [your private note, NOT in Telegram]" are the user's own annotations
attached locally via the web UI; they never existed in Telegram. Treat them attached locally via the web UI; they never existed in Telegram. Treat them
as private notes from the user to you, not as chat content. as private notes from the user to you, not as chat content.
- Find something: `search_messages_tool` (full-text over message text and STT - Find something: `search_messages_tool` (words by prefix over message text,
transcripts; supports chat/sender/date filters and regex). STT transcripts and earlier versions of edited messages; filters by chat,
- Forensics: `get_deleted_messages` and `get_message_versions` recover content sender, chat kind, media, deleted, edited, dates, regex).
removed or edited in Telegram but kept in the archive. - People: `get_person` gives one person in full: profile changes, shared groups,
joins and leaves, deleted message count, the private chat.
- Forensics: `get_feed` lists what was deleted or edited in Telegram across
all chats, newest first, with the text before and after each edit; a bulk
deletion is one item with `message_count`. Its `profile` kind lists name,
username and photo changes of people and renames of chats. `get_deleted_messages` and
`get_message_versions` recover the content of one chat or one message.
- Files: `download_media` gets any message attachment (photo, video, voice, - Files: `download_media` gets any message attachment (photo, video, voice,
document, ...). It pulls the file from Telegram if the archive lacks it and document, ...). It pulls the file from Telegram if the archive lacks it and
returns a public link valid 24h that needs no auth (`curl -O <link>`), plus 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 the content itself for small images and text files. `get_media` returns
metadata only. metadata only.
- Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and - Monitoring: `set_watch` creates a local rule (kinds `keyword`,
`list_alerts` reads what those rules fired. `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: Watched bots:
- a bot whose token the user added is just another archived account: it has its - a bot whose token the user added is just another archived account: it has its
@@ -87,7 +100,8 @@ Watched bots:
Writes: everything is read-only except `set_watch` and the share links Writes: everything is read-only except `set_watch` and the share links
`download_media` creates (visible and revocable in the web UI). `download_media` creates (visible and revocable in the web UI).
Paging: list/search tools accept `limit` and `offset`. Paging: list tools accept `limit` and `offset`; `search_messages_tool` returns
`next_cursor` to pass back as `cursor`.
""" """
mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS) mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS)
@@ -126,27 +140,49 @@ async def search_messages_tool(
query: str | None = None, query: str | None = None,
chat_id: int | None = None, chat_id: int | None = None,
sender_id: int | None = None, sender_id: int | None = None,
kind: ChatKind | None = None,
has_media: bool | None = None, has_media: bool | None = None,
deleted: bool | None = None,
edited: bool | None = None,
date_from: datetime | None = None, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None,
regex: str | None = None, regex: str | None = None,
cursor: str | None = None,
limit: int = DEFAULT_LIMIT, limit: int = DEFAULT_LIMIT,
offset: int = 0, ) -> dict[str, Any]:
) -> list[dict[str, Any]]: """Search messages by words, newest first, including deleted ones.
"""Full-text search over message text and STT transcripts."""
Words match by prefix (3+ letters) and by stem, Russian and English.
`"exact phrase"`, `-word` and `or` work as in web search. Besides the
current text it searches voice transcripts and earlier versions of edited
messages: a hit with `source="version"` matched only an old text, given in
`version_text`. Every hit carries `deleted_at` and `edited_at`.
Filters: `chat_id`, `sender_id` (peer id), `kind` (private, group,
channel), `has_media`, `deleted` / `edited` (true for only those, false to
exclude them), `date_from` / `date_to`, `regex` over the current text. With
no `query` it lists messages that pass the filters.
Paging: pass `next_cursor` from the previous result as `cursor`; it is null
on the last page.
"""
filters = SearchFilters( filters = SearchFilters(
account_id=account_id, account_id=account_id,
query=query, query=query,
chat_id=chat_id, chat_id=chat_id,
sender_id=sender_id, sender_id=sender_id,
kind=kind,
has_media=has_media, has_media=has_media,
deleted=deleted,
edited=edited,
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
regex=regex, regex=regex,
cursor=cursor,
limit=limit, limit=limit,
offset=offset,
) )
return _dump(await search_messages(await _pool(), filters)) page = await search_messages(await _pool(), filters)
return page.model_dump(mode="json", exclude={"terms"})
@mcp.tool @mcp.tool
@@ -249,6 +285,41 @@ async def get_deleted_messages(
) )
@mcp.tool
async def get_feed(
account_id: int,
kind: FeedKind = FeedKind.ALL,
since: datetime | None = None,
chat_id: int | None = None,
chat_kind: FeedChatKind | None = None,
limit: int = DEFAULT_LIMIT,
cursor: str | None = None,
) -> dict[str, Any]:
"""Deleted and edited messages and profile changes across chats, newest first.
`kind` picks deleted, edited, profile, messages (deleted and edited) or all;
`since` keeps items at or after that time; `chat_id` or `chat_kind` (dm,
group, channel) narrow it down. Edited items carry `before` and `after`
texts. A deleted item with `message_count` above 1 is a bulk deletion in
one chat; read its messages with `get_deleted_messages` for that chat.
Profile items cover people in private chats or contacts and chats in the
dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or
`title` to `[old, new]`, changes a day apart or closer are merged. Pass
`next_cursor` back as `cursor` for the next page.
"""
if cursor is not None and re.fullmatch(CURSOR_PATTERN, cursor) is None:
msg = "cursor must be a next_cursor returned by get_feed"
raise ToolError(msg)
page = await feed.list_feed(
await _pool(),
account_id,
FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since),
limit=limit,
cursor=cursor,
)
return page.model_dump(mode="json")
@mcp.tool @mcp.tool
async def get_message_versions( async def get_message_versions(
account_id: int, chat_id: int, message_id: int account_id: int, chat_id: int, message_id: int
@@ -382,6 +453,21 @@ async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]
return _dump(await peers.get_peer_history(await _pool(), account_id, peer_id)) 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 @mcp.tool
async def get_stories( async def get_stories(
account_id: int, account_id: int,
@@ -420,13 +506,37 @@ async def get_annotations(
@mcp.tool @mcp.tool
async def set_watch( async def set_watch(
account_id: int, account_id: int,
kind: str, kind: WatchKind,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
name: str | None = None,
enabled: bool = True, enabled: bool = True,
) -> dict[str, Any]: ) -> 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( 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") return watch.model_dump(mode="json")
@@ -441,12 +551,19 @@ async def list_watches(account_id: int) -> list[dict[str, Any]]:
async def list_alerts( async def list_alerts(
account_id: int, account_id: int,
seen: bool | None = None, seen: bool | None = None,
before: int | None = None,
limit: int = DEFAULT_LIMIT, limit: int = DEFAULT_LIMIT,
offset: int = 0, ) -> dict[str, Any]:
) -> list[dict[str, Any]]: """List fired alerts from watch rules, newest first.
"""List fired alerts from watch rules."""
return _dump( Each alert names its watch (id, kind, name), the event (`message`, `edit`,
await watches.list_alerts( `transcript`, `delete`, `online`), the chat, the message preview with its
await _pool(), account_id, Page(limit=limit, offset=offset), seen=seen 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")
+46 -2
View File
@@ -2,7 +2,7 @@ import asyncio
import json import json
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import TYPE_CHECKING, Any
import asyncpg import asyncpg
@@ -10,6 +10,10 @@ from utils.events import BG_EVENTS_CHANNEL
from utils.listen import Listener from utils.listen import Listener
from utils.read import chats as chats_read from utils.read import chats as chats_read
from utils.read import presence as presence_read from utils.read import presence as presence_read
from utils.read import watches as watches_read
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,8 +31,15 @@ class EventHub:
def __init__(self) -> None: def __init__(self) -> None:
self._subscribers: set[Subscriber] = set() self._subscribers: set[Subscriber] = set()
self._pool: asyncpg.Pool | None = None self._pool: asyncpg.Pool | None = None
self._listener = Listener("realtime", {BG_EVENTS_CHANNEL: self._on_notify}) 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._tasks: set[asyncio.Task] = set()
self.alert_listeners: list[Callable[[], None]] = []
def subscribe(self, account_id: int, chat_id: int | None) -> Subscriber: def subscribe(self, account_id: int, chat_id: int | None) -> Subscriber:
sub = Subscriber(account_id, chat_id) sub = Subscriber(account_id, chat_id)
@@ -52,6 +63,39 @@ class EventHub:
self._tasks.add(task) self._tasks.add(task)
task.add_done_callback(self._tasks.discard) 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: async def _dispatch(self, payload: str) -> None:
try: try:
event = json.loads(payload) event = json.loads(payload)
+21 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.read import annotations from utils.read import annotations
from utils.read.models import DEFAULT_LIMIT, AnnotationView, Page from utils.read.models import DEFAULT_LIMIT, AnnotationView, NoteTag, NoteView, Page
router = APIRouter( router = APIRouter(
prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute
@@ -42,6 +42,26 @@ async def list_annotations(
) )
@router.get("/notes")
async def list_notes(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
tag: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[NoteView]:
return await annotations.list_notes(
pool, account_id, Page(limit=limit, offset=offset), tag=tag
)
@router.get("/tags")
async def list_tags(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[NoteTag]:
return await annotations.list_tags(pool, account_id)
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_annotation( async def create_annotation(
pool: FromDishka[asyncpg.Pool], body: AnnotationCreate pool: FromDishka[asyncpg.Pool], body: AnnotationCreate
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from utils.read import capture
from utils.read.models import CaptureGap, CaptureStatus
router = APIRouter(prefix="/api/capture", tags=["capture"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
@router.get("/status")
async def capture_status(
pool: FromDishka[asyncpg.Pool], account_id: AccountId
) -> CaptureStatus:
return await capture.capture_status(pool, account_id)
@router.get("/gaps")
async def capture_gaps(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
) -> list[CaptureGap]:
return await capture.capture_gaps(
pool, account_id, date_from=date_from, date_to=date_to
)
+70
View File
@@ -0,0 +1,70 @@
from datetime import datetime
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from utils.read import feed
from utils.read.feed import CURSOR_PATTERN, FeedChatKind, FeedFilter, FeedKind
from utils.read.models import DEFAULT_LIMIT, FeedMessagesPage, FeedPage, FeedUnseen
router = APIRouter(prefix="/api/feed", tags=["feed"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
Limit = Annotated[int, Query(ge=1)]
Cursor = Annotated[str | None, Query(pattern=CURSOR_PATTERN)]
class SeenRequest(BaseModel):
account_id: int
at: datetime | None = None
@router.get("")
async def list_feed(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
kind: Annotated[FeedKind, Query()] = FeedKind.ALL,
chat_id: Annotated[int | None, Query()] = None,
chat_kind: Annotated[FeedChatKind | None, Query()] = None,
since: Annotated[datetime | None, Query()] = None,
cursor: Cursor = None,
limit: Limit = DEFAULT_LIMIT,
) -> FeedPage:
return await feed.list_feed(
pool,
account_id,
FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since),
limit=limit,
cursor=cursor,
)
@router.get("/unseen")
async def feed_unseen(
pool: FromDishka[asyncpg.Pool], account_id: AccountId
) -> FeedUnseen:
return await feed.unseen(pool, account_id)
@router.post("/seen")
async def feed_seen(pool: FromDishka[asyncpg.Pool], body: SeenRequest) -> FeedUnseen:
return await feed.mark_seen(pool, body.account_id, body.at)
@router.get("/{item_id}/messages")
async def feed_item_messages(
pool: FromDishka[asyncpg.Pool],
item_id: int,
account_id: AccountId,
cursor: Cursor = None,
limit: Limit = DEFAULT_LIMIT,
) -> FeedMessagesPage:
page = await feed.item_messages(
pool, account_id, item_id, limit=limit, cursor=cursor
)
if page is None:
raise HTTPException(status_code=404, detail="Feed item not found")
return page
+29 -2
View File
@@ -4,8 +4,14 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from utils.read import peers from utils.read import peers, people
from utils.read.models import PeerHistoryView, PeerView from utils.read.models import (
PeerGroups,
PeerHistoryView,
PeerOverview,
PeerView,
ProfileChange,
)
router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute) 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 pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
) -> list[PeerHistoryView]: ) -> list[PeerHistoryView]:
return await peers.get_peer_history(pool, account_id, peer_id) 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)
+11
View File
@@ -9,6 +9,7 @@ from api.timezone import checked_tz
from utils.read import presence, presence_stats from utils.read import presence, presence_stats
from utils.read.models import ( from utils.read.models import (
DEFAULT_LIMIT, DEFAULT_LIMIT,
MAX_LIMIT,
Page, Page,
PresenceHourly, PresenceHourly,
PresenceSample, PresenceSample,
@@ -54,6 +55,16 @@ async def current_presence(
return await presence.current_presence(pool, account_id, peer_id) return await presence.current_presence(pool, account_id, peer_id)
@router.get("/online")
async def online_now(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_ids: Annotated[str, Query()],
) -> list[PresenceSample]:
parsed = [int(part) for part in peer_ids.split(",") if part.strip()]
return await presence.online_now(pool, account_id, parsed[:MAX_LIMIT])
@router.get("/hourly") @router.get("/hourly")
async def presence_hourly( async def presence_hourly(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
+2 -2
View File
@@ -4,7 +4,7 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from utils.search.models import SearchFilters, SearchHit from utils.search.models import SearchFilters, SearchPage
from utils.search.repository import search_messages from utils.search.repository import search_messages
router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRoute) router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRoute)
@@ -13,5 +13,5 @@ router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRout
@router.get("") @router.get("")
async def search( async def search(
pool: FromDishka[asyncpg.Pool], filters: Annotated[SearchFilters, Query()] pool: FromDishka[asyncpg.Pool], filters: Annotated[SearchFilters, Query()]
) -> list[SearchHit]: ) -> SearchPage:
return await search_messages(pool, filters) return await search_messages(pool, filters)
+19 -2
View File
@@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from utils.files import content_disposition, story_file_name from utils.files import content_disposition, sniff_media_kind, story_file_name
from utils.read import peers from utils.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, StoryView from utils.read.models import DEFAULT_LIMIT, Page, StoryView
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
@@ -17,17 +17,34 @@ AccountId = Annotated[int, Query()]
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"} _STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
async def _with_kind(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
story: StoryView,
) -> StoryView:
if story.media_kind or not story.storage_key:
return story
if not storage.exists(story.storage_key):
return story
kind = sniff_media_kind(storage.head(story.storage_key))
await peers.set_story_kind(pool, account_id, story.peer_id, story.story_id, kind)
return story.model_copy(update={"media_kind": kind})
@router.get("/stories") @router.get("/stories")
async def list_stories( async def list_stories(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
account_id: AccountId, account_id: AccountId,
peer_id: Annotated[int | None, Query()] = None, peer_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT, limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0, offset: Annotated[int, Query()] = 0,
) -> list[StoryView]: ) -> list[StoryView]:
return await peers.get_stories( stories = await peers.get_stories(
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
) )
return [await _with_kind(pool, storage, account_id, story) for story in stories]
@router.get("/stories/{peer_id}/{story_id}/media") @router.get("/stories/{peer_id}/{story_id}/media")
+69 -21
View File
@@ -6,55 +6,88 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.read import watches 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) 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 account_id: int
kind: str kind: str
params: dict[str, Any] = {}
enabled: bool = True
class WatchUpdate(BaseModel): class SeenAll(BaseModel):
params: dict[str, Any] = {} account_id: int
enabled: bool = True 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") @router.get("/watches")
async def list_watches( async def list_watches(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()] pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[WatchView]: ) -> list[WatchItem]:
return await watches.list_watches(pool, account_id) rows = await watches.list_watches(pool, account_id)
return await watches.describe_watches(pool, account_id, rows)
@router.post("/watches", status_code=201) @router.post("/watches", status_code=201)
async def create_watch(pool: FromDishka[asyncpg.Pool], body: WatchCreate) -> WatchView: async def create_watch(pool: FromDishka[asyncpg.Pool], body: WatchCreate) -> WatchItem:
return await watches.create_watch( watch = await watches.create_watch(
pool, body.account_id, body.kind, body.params, enabled=body.enabled 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}") @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) watch = await watches.get_watch(pool, watch_id)
if watch is None: if watch is None:
raise HTTPException(status_code=404, detail="watch not found") raise HTTPException(status_code=404, detail="watch not found")
return watch return await _item(pool, watch)
@router.put("/watches/{watch_id}") @router.put("/watches/{watch_id}")
async def update_watch( async def update_watch(
pool: FromDishka[asyncpg.Pool], watch_id: int, body: WatchUpdate 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( 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: if watch is None:
raise HTTPException(status_code=404, detail="watch not found") raise HTTPException(status_code=404, detail="watch not found")
return watch return await _item(pool, watch)
@router.delete("/watches/{watch_id}", status_code=204) @router.delete("/watches/{watch_id}", status_code=204)
@@ -68,14 +101,29 @@ async def list_alerts(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()], account_id: Annotated[int, Query()],
seen: Annotated[bool | None, Query()] = None, seen: Annotated[bool | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT, before: Annotated[int | None, Query()] = None,
offset: Annotated[int, Query()] = 0, limit: Annotated[int, Query()] = watches.ALERT_PAGE,
) -> list[AlertView]: ) -> AlertPage:
return await watches.list_alerts( 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) @router.post("/alerts/{alert_id}/seen", status_code=204)
async def mark_alert_seen(pool: FromDishka[asyncpg.Pool], alert_id: int) -> None: async def mark_alert_seen(pool: FromDishka[asyncpg.Pool], alert_id: int) -> None:
if not await watches.mark_alert_seen(pool, alert_id): 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)
+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()
+2
View File
@@ -25,9 +25,11 @@ async def on_deleted_messages(client: PyroClient, messages: list[Message]) -> No
ctx.pending_deletes.remember(chat_id, ids) ctx.pending_deletes.remember(chat_id, ids)
if box: if box:
await repository.mark_deleted_box(ctx.pool, ctx.account_id, box) await repository.mark_deleted_box(ctx.pool, ctx.account_id, box)
await ctx.watches.on_deleted(None, box)
await notify_bg_event(ctx.pool, "delete", ctx.account_id, message_ids=box) await notify_bg_event(ctx.pool, "delete", ctx.account_id, message_ids=box)
for chat_id, ids in channels.items(): for chat_id, ids in channels.items():
await repository.mark_deleted_channel(ctx.pool, ctx.account_id, chat_id, ids) 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( await notify_bg_event(
ctx.pool, "delete", ctx.account_id, chat_id=chat_id, message_ids=ids ctx.pool, "delete", ctx.account_id, chat_id=chat_id, message_ids=ids
) )
+3 -1
View File
@@ -5,6 +5,7 @@ from userbot.modules.capture.chat_meta import meta_from_chat
from userbot.modules.capture.message import message_row from userbot.modules.capture.message import message_row
from userbot.modules.client import PyroClient from userbot.modules.client import PyroClient
from userbot.modules.media import capture_media from userbot.modules.media import capture_media
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event from utils.events import notify_bg_event
@@ -22,8 +23,9 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
chat = message.chat chat = message.chat
chat_id = chat.id or 0 chat_id = chat.id or 0
meta = meta_from_chat(chat, ctx.contacts.ids) meta = meta_from_chat(chat, ctx.contacts.ids)
await ctx.watches.on_edit(seen_message(chat_id, message))
toggles = ctx.resolve(meta) toggles = ctx.resolve(meta)
if not toggles.track_edits_deletes: if not (toggles.messages and toggles.track_edits_deletes):
return return
changed = await repository.add_version( changed = await repository.add_version(
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date) ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
+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.client import PyroClient
from userbot.modules.stt import is_transcribable from userbot.modules.stt import is_transcribable
from userbot.modules.stt.gate import safe_transcribe from userbot.modules.stt.gate import safe_transcribe
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event from utils.events import notify_bg_event
@@ -20,7 +21,7 @@ async def on_message(client: PyroClient, message: Message) -> None:
): ):
return return
meta = meta_from_chat(message.chat, ctx.contacts.ids) 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) toggles = ctx.resolve(meta)
if not toggles.messages: if not toggles.messages:
return return
+5 -3
View File
@@ -13,19 +13,21 @@ async def on_user_status(client: PyroClient, user: User) -> None:
ctx = client.capture ctx = client.capture
if ctx is None or user.status is None: if ctx is None or user.status is None:
return return
at = datetime.now(UTC)
status = user.status.name.lower()
if ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).presence: if ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).presence:
await repository.insert_status( await repository.insert_status(
ctx.pool, ctx.pool,
ctx.account_id, ctx.account_id,
user.id, user.id,
datetime.now(UTC), at,
user.status.name.lower(), status,
user.last_online_date, user.last_online_date,
user.next_offline_date, user.next_offline_date,
raw_json(user.raw), raw_json(user.raw),
) )
await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id) await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id)
await ctx.watches.on_status(user.id, is_online=user.status.name.lower() == "online") await ctx.watches.on_status(user.id, status, at, user.last_online_date)
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute] handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
@@ -20,4 +20,4 @@ async def handle(
await repository.set_extracted_text( await repository.set_extracted_text(
ctx.pool, ctx.account_id, meta.chat_id, update.msg_id, update.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)
@@ -89,17 +89,15 @@ async def _capture_chat(message: Message, ctx: CaptureContext) -> None:
meta = (chat.title, photo_unique_id) meta = (chat.title, photo_unique_id)
if not await ctx.chat_meta.changed(ctx.pool, ctx.account_id, chat.id, meta): if not await ctx.chat_meta.changed(ctx.pool, ctx.account_id, chat.id, meta):
return 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.pool,
ctx.account_id, ctx.account_id,
chat.id, chat.id,
message.id, message.id,
"meta",
chat.title, chat.title,
photo_unique_id, photo_unique_id,
None,
message.date, message.date,
raw_json(message), raw_json(message),
) )
@@ -7,7 +7,23 @@ INSERT INTO chat_history
(account_id, chat_id, message_id, event, title, photo_unique_id, actor_id, (account_id, chat_id, message_id, event, title, photo_unique_id, actor_id,
ts, raw) ts, raw)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb) 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 = """ _INSERT_MEMBERSHIP = """
@@ -19,13 +35,14 @@ ON CONFLICT (account_id, chat_id, message_id, user_id) DO NOTHING
_LATEST_TITLE = """ _LATEST_TITLE = """
SELECT title FROM chat_history 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 ORDER BY ts DESC LIMIT 1
""" """
_LATEST_PHOTO = """ _LATEST_PHOTO = """
SELECT photo_unique_id FROM chat_history 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 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 async def insert_membership( # noqa: PLR0913
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int, account_id: int,
@@ -1,17 +1,11 @@
from datetime import UTC, datetime
from pyrogram import Client from pyrogram import Client
from pyrogram.errors import BadRequest, Forbidden from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import User from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext 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.context import JobContext
from userbot.modules.jobs.registry import register from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level from userbot.modules.profiles.snapshots import save_group, save_user
from userbot.modules.profiles.repository import write_profile
from userbot.modules.raw_json import raw_json
MEMBER_CAP = 200 MEMBER_CAP = 200
@@ -25,52 +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),
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 _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None: async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None:
chat = await client.get_chat(chat_id) chat = await client.get_chat(chat_id)
if chat is None: if chat is not None:
return await save_group(ctx, chat)
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
)
async def _enrich_members(client: Client, ctx: CaptureContext, chat_id: int) -> None: async def _enrich_members(client: Client, ctx: CaptureContext, chat_id: int) -> None:
try: try:
async for member in client.get_chat_members(chat_id, limit=MEMBER_CAP): async for member in client.get_chat_members(chat_id, limit=MEMBER_CAP):
if isinstance(member.user, User): if isinstance(member.user, User):
await _save_user(ctx, member.user) await save_user(ctx, member.user)
except (BadRequest, Forbidden): except (BadRequest, Forbidden):
return return
@@ -96,7 +55,7 @@ async def _enrich_senders(client: Client, ctx: CaptureContext, chat_id: int) ->
if not ids: if not ids:
return return
for user in await _fetch_users(client, ids): for user in await _fetch_users(client, ids):
await _save_user(ctx, user) await save_user(ctx, user)
@register("enrich_chat") @register("enrich_chat")
@@ -1,11 +1,8 @@
from pyrogram.types import User from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.jobs.context import JobContext from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level from userbot.modules.profiles.snapshots import save_user
from userbot.modules.profiles.repository import write_profile
from userbot.modules.raw_json import raw_json
@register("sync_contacts") @register("sync_contacts")
@@ -21,26 +18,7 @@ async def sync_contacts(ctx: JobContext) -> None:
for user in contacts: for user in contacts:
if not isinstance(user, User): if not isinstance(user, User):
continue continue
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user) await save_user(capture, user)
await write_profile(
ctx.pool,
ctx.account_id,
user.id,
fields,
raw_json(user),
history=capture.resolve_peer(
user.id, is_bot=bool(user.is_bot)
).profile_history,
)
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool,
ctx.account_id,
user.id,
"peer",
photo_unique_id,
photo_file_id,
)
processed += 1 processed += 1
await capture.contacts.refresh() await capture.contacts.refresh()
await ctx.report_progress({"processed": processed, "done": True}) await ctx.report_progress({"processed": processed, "done": True})
@@ -2,14 +2,10 @@ from pyrogram import Client
from pyrogram.errors import BadRequest, Forbidden from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import User from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
from userbot.modules.jobs.context import JobContext from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level from userbot.modules.profiles.snapshots import save_group, save_private, save_user
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 utils.logging import logger from utils.logging import logger
SAVE_EVERY = 100 SAVE_EVERY = 100
@@ -30,28 +26,8 @@ async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) ->
continue continue
users = result if isinstance(result, list) else [result] users = result if isinstance(result, list) else [result]
for user in users: for user in users:
if not isinstance(user, User): if isinstance(user, User):
continue await save_user(ctx, user)
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
await write_profile(
ctx.pool,
ctx.account_id,
user.id,
fields,
raw_json(user),
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,
)
@register("sync_dialogs") @register("sync_dialogs")
@@ -1,4 +1,4 @@
from dataclasses import dataclass from dataclasses import dataclass, field
from pyrogram import Client, raw from pyrogram import Client, raw
from pyrogram.types import Chat, User from pyrogram.types import Chat, User
@@ -12,6 +12,11 @@ class ProfileFields:
phone: str | None = None phone: str | None = None
photo_unique_id: str | None = None photo_unique_id: str | None = None
is_deleted_account: bool = False 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: 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, phone=user.phone_number if user else None,
photo_unique_id=photo_unique_id, photo_unique_id=photo_unique_id,
is_deleted_account=bool(getattr(raw_user, "deleted", False)), 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 return fields, photo_file_id, photo_unique_id
@@ -52,6 +58,7 @@ def snapshot_from_high_level(
phone=user.phone_number, phone=user.phone_number,
photo_unique_id=photo_unique_id, photo_unique_id=photo_unique_id,
is_deleted_account=bool(user.is_deleted), is_deleted_account=bool(user.is_deleted),
unknown=MIN_UNKNOWN if user.is_min else frozenset(),
) )
return fields, photo_file_id, photo_unique_id 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, first_name=chat.first_name,
last_name=chat.last_name, last_name=chat.last_name,
username=chat.username, username=chat.username,
phone=None,
photo_unique_id=photo_unique_id, photo_unique_id=photo_unique_id,
is_deleted_account=False, unknown=CHAT_UNKNOWN,
) )
return fields, photo_file_id, photo_unique_id return fields, photo_file_id, photo_unique_id
@@ -9,26 +9,46 @@ WHERE account_id = $1 AND peer_id = $2
""" """
_UPSERT_PEER = """ _UPSERT_PEER = """
INSERT INTO peers INSERT INTO peers AS p
(account_id, peer_id, first_name, last_name, username, phone, (account_id, peer_id, first_name, last_name, username, phone,
photo_unique_id, is_deleted_account, raw, updated_at) photo_unique_id, is_deleted_account, raw, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, now()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, now())
ON CONFLICT (account_id, peer_id) DO UPDATE SET ON CONFLICT (account_id, peer_id) DO UPDATE SET
first_name = EXCLUDED.first_name, first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name, last_name = EXCLUDED.last_name,
username = EXCLUDED.username, username = CASE WHEN 'username' = ANY($10::text[])
phone = EXCLUDED.phone, 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, 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, raw = EXCLUDED.raw,
updated_at = now() updated_at = now()
""" """
_INSERT_HISTORY = """ _INSERT_CHANGE = """
INSERT INTO peer_history INSERT INTO peer_history
(account_id, peer_id, observed_at, first_name, last_name, username, phone, (account_id, peer_id, observed_at, first_name, last_name, username, phone,
photo_unique_id, is_deleted_account, raw) 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 ON CONFLICT DO NOTHING
""" """
@@ -57,17 +77,22 @@ async def write_profile( # noqa: PLR0913
raw: str, raw: str,
*, *,
history: bool, history: bool,
) -> None: ) -> bool:
args = (
fields.first_name,
fields.last_name,
fields.username,
fields.phone,
fields.photo_unique_id,
fields.is_deleted_account,
raw,
)
async with pool.acquire() as conn, conn.transaction(): async with pool.acquire() as conn, conn.transaction():
await conn.execute(_UPSERT_PEER, account_id, peer_id, *args) await conn.execute(
if history: _UPSERT_PEER,
await conn.execute(_INSERT_HISTORY, account_id, peer_id, *args) 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,16 +1,34 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from pyrogram.enums import ChatType from pyrogram.enums import ChatType
from pyrogram.types import Chat from pyrogram.types import Chat, User
from userbot.modules.avatars import note_avatar from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history from userbot.modules.groups.repository import record_chat_meta
from userbot.modules.profiles.parse import snapshot_from_chat from userbot.modules.profiles.parse import snapshot_from_chat, snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile from userbot.modules.profiles.repository import write_profile
from userbot.modules.raw_json import raw_json 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: async def save_private(ctx: CaptureContext, chat: Chat) -> bool:
chat_id = chat.id or 0 chat_id = chat.id or 0
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat) fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
@@ -35,15 +53,13 @@ async def save_group(ctx: CaptureContext, chat: Chat) -> None:
photo = chat.photo photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_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.pool,
ctx.account_id, ctx.account_id,
chat_id, chat_id,
0, 0,
"meta",
chat.title, chat.title,
photo_unique_id, photo_unique_id,
None,
datetime.now(UTC), datetime.now(UTC),
raw_json(chat), raw_json(chat),
) )
@@ -6,7 +6,7 @@ _UPSERT_STORY = """
INSERT INTO stories INSERT INTO stories
(account_id, peer_id, story_id, date, expire_date, caption, media_kind, (account_id, peer_id, story_id, date, expire_date, caption, media_kind,
storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at) storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, now()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, false, $13::jsonb, now())
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
date = EXCLUDED.date, date = EXCLUDED.date,
expire_date = EXCLUDED.expire_date, expire_date = EXCLUDED.expire_date,
@@ -17,10 +17,17 @@ ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
downloaded = stories.downloaded OR EXCLUDED.downloaded, downloaded = stories.downloaded OR EXCLUDED.downloaded,
views = EXCLUDED.views, views = EXCLUDED.views,
pinned = EXCLUDED.pinned, pinned = EXCLUDED.pinned,
deleted = EXCLUDED.deleted, deleted = false,
raw = EXCLUDED.raw raw = EXCLUDED.raw
""" """
_MARK_DELETED = """
INSERT INTO stories
(account_id, peer_id, story_id, downloaded, pinned, deleted, raw, observed_at)
VALUES ($1, $2, $3, false, false, true, '{}'::jsonb, now())
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET deleted = true
"""
async def is_downloaded( async def is_downloaded(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
@@ -51,7 +58,6 @@ async def upsert_story( # noqa: PLR0913
raw: str, raw: str,
*, *,
pinned: bool, pinned: bool,
deleted: bool,
downloaded: bool, downloaded: bool,
) -> None: ) -> None:
await pool.execute( await pool.execute(
@@ -68,6 +74,11 @@ async def upsert_story( # noqa: PLR0913
downloaded, downloaded,
views, views,
pinned, pinned,
deleted,
raw, raw,
) )
async def mark_deleted(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
) -> None:
await pool.execute(_MARK_DELETED, account_id, peer_id, story_id)
@@ -17,13 +17,18 @@ def story_peer_id(story: Story) -> int:
async def save_story(client: Client, capture: CaptureContext, story: Story) -> None: async def save_story(client: Client, capture: CaptureContext, story: Story) -> None:
peer_id = story_peer_id(story) peer_id = story_peer_id(story)
if story.deleted:
await repository.mark_deleted(
capture.pool, capture.account_id, peer_id, story.id
)
return
storage_key: str | None = None storage_key: str | None = None
file_size: int | None = None file_size: int | None = None
downloaded = False downloaded = False
stored = await repository.is_downloaded( stored = await repository.is_downloaded(
capture.pool, capture.account_id, peer_id, story.id capture.pool, capture.account_id, peer_id, story.id
) )
if not (stored or story.deleted or story.media is None): if not (stored or story.media is None):
data = await download_bytes(client, story) data = await download_bytes(client, story)
if data is not None: if data is not None:
storage_key = capture.storage.put(data) storage_key = capture.storage.put(data)
@@ -43,6 +48,5 @@ async def save_story(client: Client, capture: CaptureContext, story: Story) -> N
story.views, story.views,
raw_json(story.raw), raw_json(story.raw),
pinned=bool(story.pinned), pinned=bool(story.pinned),
deleted=bool(story.deleted),
downloaded=downloaded, downloaded=downloaded,
) )
+156 -34
View File
@@ -1,60 +1,182 @@
from __future__ import annotations 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 ( from userbot.modules.watches.evaluator import (
KIND_KEYWORD, Rules,
KIND_PEER_ONLINE, Seen,
build_rules,
match_chat_scope,
match_keyword, match_keyword,
match_peer_online, match_new_message,
) )
from utils.logging import logger from utils.logging import logger
from utils.read import watches as watches_read from utils.read import watches as watches_read
from utils.read.accounts import self_user_id
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Coroutine
import asyncpg 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: class WatchCache:
def __init__(self, pool: asyncpg.Pool, account_id: int) -> None: def __init__(self, pool: asyncpg.Pool, account_id: int) -> None:
self._pool = pool self._pool = pool
self._account_id = account_id self._account_id = account_id
self.watches: list[WatchView] = [] self.rules = Rules()
self._online: set[int] = set() self._self_id: int | None = None
self._last_seen: dict[int, datetime] = {}
async def refresh(self) -> None: async def refresh(self) -> None:
rows = await watches_read.list_watches(self._pool, self._account_id) rows = await watches_read.list_watches(self._pool, self._account_id)
self.watches = [watch for watch in rows if watch.enabled] self.rules = build_rules(rows)
logger.info(f"[green]Watches cached:[/] {len(self.watches)}") 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: def _own(self, seen: Seen) -> bool:
if not text: return seen.outgoing or (
return self._self_id is not None and seen.sender_id == self._self_id
for watch in self.watches: )
if watch.kind == KIND_KEYWORD and match_keyword(
text, chat_id, watch.params async def _guard(self, work: Coroutine[Any, Any, None]) -> None:
): try:
await watches_read.insert_alert( await work
self._pool, except Exception:
self._account_id, logger.exception("[red]Watch evaluation failed[/]")
watch.id,
{"chat_id": chat_id, "message_id": message_id}, async def _fire(self, watch_id: int, payload: dict[str, Any], key: str) -> None:
dedup_key=f"{chat_id}:{message_id}", 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: async def on_message(self, seen: Seen) -> None:
if not is_online: if self._own(seen):
self._online.discard(peer_id)
return return
if peer_id in self._online: await self._guard(self._on_message(seen))
return
self._online.add(peer_id) async def _on_message(self, seen: Seen) -> None:
for watch in self.watches: await self._keyword("message", seen)
if watch.kind == KIND_PEER_ONLINE and match_peer_online( for watch_id, params in self.rules.new_message:
peer_id, watch.params if match_new_message(params, seen):
): await self._fire(
await watches_read.insert_alert( watch_id,
self._pool, self._account_id, watch.id, {"peer_id": peer_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 import re
from typing import Any from dataclasses import dataclass, field
from datetime import datetime
KIND_KEYWORD = "keyword" from utils.logging import logger
KIND_PEER_ONLINE = "peer_online" 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: @dataclass(frozen=True, slots=True)
target_chat = params.get("chat_id") class Seen:
if target_chat is not None and target_chat != chat_id: chat_id: int
return False message_id: int
pattern = params.get("pattern") sender_id: int | None
if not pattern: text: str | None
return False outgoing: bool = False
if params.get("regex"): 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: try:
return re.search(pattern, text) is not None params = parse_params(watch.kind, watch.params)
except re.error: except WatchParamsError as exc:
return False logger.warning(f"[yellow]Watch {watch.id} skipped:[/] {exc}")
return pattern.casefold() in text.casefold() 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: def _in_scope(chat_id: int | None, sender_id: int | None, seen: Seen) -> bool:
return params.get("peer_id") == peer_id 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),
)
+77 -4
View File
@@ -2,8 +2,17 @@ from datetime import datetime
from enum import StrEnum from enum import StrEnum
from typing import Any from typing import Any
from sqlalchemy import BigInteger, Column, DateTime, LargeBinary, Text, func from sqlalchemy import (
from sqlalchemy.dialects.postgresql import JSONB BigInteger,
Column,
DateTime,
ForeignKey,
Integer,
LargeBinary,
Text,
func,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
@@ -347,7 +356,7 @@ class ChatHistory(SQLModel, table=True):
title: str | None = None title: str | None = None
photo_unique_id: str | None = None photo_unique_id: str | None = None
actor_id: int | None = Field(default=None, sa_column=Column(BigInteger)) 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( raw: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False) default_factory=dict, sa_column=Column(JSONB, nullable=False)
) )
@@ -445,6 +454,7 @@ class Watch(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
account_id: int account_id: int
kind: str kind: str
name: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
params: dict[str, Any] = Field( params: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False) default_factory=dict, sa_column=Column(JSONB, nullable=False)
) )
@@ -469,7 +479,11 @@ class Alert(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
account_id: int 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)) ts: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False))
payload: dict[str, Any] = Field( payload: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False) default_factory=dict, sa_column=Column(JSONB, nullable=False)
@@ -546,3 +560,62 @@ class FileShareHit(SQLModel, table=True):
ip: str | None = None ip: str | None = None
user_agent: str | None = None user_agent: str | None = None
counted: bool = False 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 host: str = "0.0.0.0" # noqa: S104
port: int = 8080 port: int = 8080
static_dir: str = "static" static_dir: str = "static"
public_url: str | None = None
class AuthSettings(BaseSettings): class AuthSettings(BaseSettings):
+5
View File
@@ -191,6 +191,11 @@ def story_file_name(peer_id: int, story_id: int, media_kind: str | None) -> str:
return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}" return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}"
def sniff_media_kind(head: bytes) -> str:
is_video = head[4:8] == b"ftyp" or head.startswith(b"\x1aE\xdf\xa3")
return "video" if is_video else "photo"
def resolve_mime( def resolve_mime(
kind: str | None, mime: str | None, file_name: str | None = None kind: str | None, mime: str | None, file_name: str | None = None
) -> str: ) -> str:
+10 -2
View File
@@ -1,4 +1,5 @@
import json import json
from datetime import timedelta
from typing import Any from typing import Any
import asyncpg import asyncpg
@@ -7,15 +8,22 @@ JOBS_CHANGED_CHANNEL = "jobs_changed"
async def enqueue( 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: ) -> int:
params_json = json.dumps(params) params_json = json.dumps(params)
existing = await pool.fetchval( existing = await pool.fetchval(
"SELECT id FROM jobs WHERE account_id = $1 AND kind = $2 " "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, account_id,
kind, kind,
params_json, params_json,
cooldown,
) )
if existing is not None: if existing is not None:
return existing return existing
+2 -2
View File
@@ -1,8 +1,8 @@
from utils.policy.models import CaptureToggles, ChatKind from utils.policy.models import CaptureToggles, ChatKind
DEFAULTS: dict[ChatKind, CaptureToggles] = { DEFAULTS: dict[ChatKind, CaptureToggles] = {
ChatKind.CHANNEL: CaptureToggles(), ChatKind.CHANNEL: CaptureToggles(track_edits_deletes=True),
ChatKind.GROUP: CaptureToggles(messages=True), ChatKind.GROUP: CaptureToggles(messages=True, track_edits_deletes=True),
ChatKind.DM: CaptureToggles( ChatKind.DM: CaptureToggles(
messages=True, messages=True,
media=True, media=True,
+72 -1
View File
@@ -1,9 +1,34 @@
import re
from collections import Counter
import asyncpg import asyncpg
from utils.read.models import AnnotationView, Page from utils.read.chats import get_chats
from utils.read.models import AnnotationView, NoteTag, NoteView, Page
_COLS = "id, account_id, chat_id, message_id, text, created_at, updated_at" _COLS = "id, account_id, chat_id, message_id, text, created_at, updated_at"
TAG = re.compile(r"(?<!\w)#(\w+)")
WORD = re.compile(r"\w+")
_NOTES = """
SELECT a.id, a.account_id, a.chat_id, a.message_id, a.text,
a.created_at, a.updated_at,
m.text AS message_text, m.date AS message_date,
COALESCE(m.has_media, false) AS message_has_media,
m.deleted_at IS NOT NULL AS message_deleted
FROM annotations a
LEFT JOIN LATERAL (
SELECT text, date, has_media, deleted_at FROM messages
WHERE account_id = a.account_id AND chat_id = a.chat_id
AND message_id = a.message_id
LIMIT 1
) m ON true
WHERE a.account_id = $1 AND ($4::text IS NULL OR a.text ~* $4)
ORDER BY a.created_at DESC, a.id DESC
LIMIT $2 OFFSET $3
"""
async def list_annotations( async def list_annotations(
pool: asyncpg.Pool, pool: asyncpg.Pool,
@@ -70,3 +95,49 @@ async def update_annotation(
async def delete_annotation(pool: asyncpg.Pool, annotation_id: int) -> bool: async def delete_annotation(pool: asyncpg.Pool, annotation_id: int) -> bool:
result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id) result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id)
return result.endswith("1") return result.endswith("1")
def _tag_pattern(tag: str | None) -> str | None:
if tag is None:
return None
return f"(^|[^[:alnum:]_])#{tag}($|[^[:alnum:]_])"
async def list_notes(
pool: asyncpg.Pool, account_id: int, page: Page, *, tag: str | None = None
) -> list[NoteView]:
if tag is not None and not WORD.fullmatch(tag):
return []
rows = await pool.fetch(
_NOTES, account_id, page.capped_limit, page.offset, _tag_pattern(tag)
)
chats = {
chat.chat_id: chat
for chat in await get_chats(
pool, account_id, list({row["chat_id"] for row in rows})
)
}
notes: list[NoteView] = []
for row in rows:
chat = chats.get(row["chat_id"])
notes.append(
NoteView(
**dict(row),
chat_title=chat.title if chat else None,
chat_has_avatar=chat.has_avatar if chat else False,
)
)
return notes
async def list_tags(pool: asyncpg.Pool, account_id: int) -> list[NoteTag]:
rows = await pool.fetch(
"SELECT text FROM annotations WHERE account_id = $1", account_id
)
counts: Counter[str] = Counter()
for row in rows:
counts.update({tag.lower() for tag in TAG.findall(row["text"])})
return [
NoteTag(tag=tag, count=count)
for tag, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))
]
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime
import asyncpg
from utils.read.models import CaptureGap, CaptureStatus
_STATUS = """
SELECT started_at, seen_at, seen_at >= now() - interval '150 seconds' AS live
FROM capture_uptime WHERE account_id = $1
ORDER BY started_at DESC LIMIT 1
"""
_GAPS = """
SELECT seen_at AS start, next_start AS "end"
FROM (
SELECT seen_at,
lead(started_at) OVER (ORDER BY started_at) AS next_start
FROM capture_uptime WHERE account_id = $1
) stretches
WHERE next_start - seen_at > interval '30 minutes'
AND ($2::timestamptz IS NULL OR next_start >= $2)
AND ($3::timestamptz IS NULL OR seen_at <= $3)
ORDER BY seen_at
"""
async def capture_status(pool: asyncpg.Pool, account_id: int) -> CaptureStatus:
row = await pool.fetchrow(_STATUS, account_id)
if row is None:
return CaptureStatus(live=False, since=None, last_seen=None)
return CaptureStatus(
live=row["live"], since=row["started_at"], last_seen=row["seen_at"]
)
async def capture_gaps(
pool: asyncpg.Pool,
account_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[CaptureGap]:
rows = await pool.fetch(_GAPS, account_id, date_from, date_to)
return [CaptureGap(**dict(row)) for row in rows]
+92 -1
View File
@@ -1,3 +1,5 @@
from datetime import datetime
import asyncpg import asyncpg
from utils.policy.models import FolderSpec from utils.policy.models import FolderSpec
@@ -78,7 +80,8 @@ _CHAT_ROWS = """
WITH ids AS ({ids}) WITH ids AS ({ids})
SELECT ids.chat_id, SELECT ids.chat_id,
COALESCE(cs.message_count, 0) AS message_count, COALESCE(cs.message_count, 0) AS message_count,
cs.last_date, cs.last_text, cs.last_sender_id, cs.last_date, cs.last_text, cs.last_sender_id, cs.last_message_id,
COALESCE(cs.last_deleted, false) AS last_deleted,
COALESCE(named.title, COALESCE(named.title,
NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''), NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''),
p.username) AS title, p.username) AS title,
@@ -147,6 +150,8 @@ def _chat_item(row: asyncpg.Record) -> ChatListItem:
last_date=row["last_date"], last_date=row["last_date"],
last_text=row["last_text"], last_text=row["last_text"],
last_sender_id=row["last_sender_id"], last_sender_id=row["last_sender_id"],
last_message_id=row["last_message_id"],
last_deleted=row["last_deleted"],
) )
@@ -257,6 +262,7 @@ async def get_chat_history( # noqa: PLR0913
views.append(_build_album(members, media_by_key)) views.append(_build_album(members, media_by_key))
index = end index = end
await _apply_read_status(pool, account_id, chat_id, views) await _apply_read_status(pool, account_id, chat_id, views)
await _apply_notes(pool, account_id, chat_id, views)
return views return views
@@ -276,6 +282,7 @@ async def get_message(
raw = load_raw(row["raw"]) raw = load_raw(row["raw"])
view = build_message_view(row, raw, _single_media(row, raw, media_by_key)) view = build_message_view(row, raw, _single_media(row, raw, media_by_key))
await _apply_read_status(pool, account_id, chat_id, [view]) await _apply_read_status(pool, account_id, chat_id, [view])
await _apply_notes(pool, account_id, chat_id, [view])
return view return view
@@ -299,6 +306,23 @@ async def _apply_read_status(
) )
async def _apply_notes(
pool: asyncpg.Pool, account_id: int, chat_id: int, views: list[MessageView]
) -> None:
if not views:
return
rows = await pool.fetch(
"SELECT DISTINCT message_id FROM annotations "
"WHERE account_id = $1 AND chat_id = $2 AND message_id = ANY($3::bigint[])",
account_id,
chat_id,
[view.message_id for view in views],
)
noted = {row["message_id"] for row in rows}
for view in views:
view.has_note = view.message_id in noted
def _build_album( def _build_album(
members: list[tuple[asyncpg.Record, dict]], members: list[tuple[asyncpg.Record, dict]],
media_by_key: dict[tuple[int, int], asyncpg.Record], media_by_key: dict[tuple[int, int], asyncpg.Record],
@@ -331,6 +355,12 @@ async def get_deleted_messages(
f"ORDER BY deleted_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}", f"ORDER BY deleted_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
*params, *params,
) )
return await _single_views(pool, account_id, rows)
async def _single_views(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> list[MessageView]:
media_by_key = await _media_map(pool, account_id, rows) media_by_key = await _media_map(pool, account_id, rows)
views: list[MessageView] = [] views: list[MessageView] = []
for row in rows: for row in rows:
@@ -341,6 +371,67 @@ async def get_deleted_messages(
return views return views
async def _rows_at(
pool: asyncpg.Pool, account_id: int, keys: list[tuple[int, int, datetime]]
) -> list[asyncpg.Record]:
if not keys:
return []
params: list[object] = [account_id]
clauses: list[str] = []
for key in keys:
params.extend(key)
n = len(params)
clauses.append(
f"(chat_id = ${n - 2} AND message_id = ${n - 1} AND date = ${n})"
)
return await pool.fetch(
f"SELECT {_MESSAGE_COLS} FROM messages " # noqa: S608
f"WHERE account_id = $1 AND ({' OR '.join(clauses)})",
*params,
)
async def get_messages_at(
pool: asyncpg.Pool, account_id: int, keys: list[tuple[int, int, datetime | None]]
) -> dict[tuple[int, int], MessageView]:
dated = list(
dict.fromkeys((chat, message, date) for chat, message, date in keys if date)
)
rows = await _rows_at(pool, account_id, dated)
views = {
(view.chat_id, view.message_id): view
for view in await _single_views(pool, account_id, rows)
}
for chat_id, message_id, _ in keys:
if (chat_id, message_id) not in views:
view = await get_message(pool, account_id, chat_id, message_id)
if view is not None:
views[chat_id, message_id] = view
return views
async def get_deleted_between( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
since: datetime,
until: datetime,
limit: int,
before: tuple[datetime, int] | None = None,
) -> list[MessageView]:
params: list[object] = [account_id, chat_id, since, until, limit]
where = "account_id = $1 AND chat_id = $2 AND deleted_at BETWEEN $3 AND $4"
if before is not None:
params.extend(before)
where += " AND (date, message_id) < ($6, $7)"
rows = await pool.fetch(
f"SELECT {_MESSAGE_COLS} FROM messages WHERE {where} " # noqa: S608
"ORDER BY date DESC, message_id DESC LIMIT $5",
*params,
)
return await _single_views(pool, account_id, rows)
async def get_message_versions( async def get_message_versions(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> list[MessageVersionView]: ) -> list[MessageVersionView]:
+291
View File
@@ -0,0 +1,291 @@
import json
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
import asyncpg
from utils.read import chats, peers
from utils.read.accounts import self_user_id
from utils.read.models import (
DEFAULT_LIMIT,
MAX_LIMIT,
ChatListItem,
FeedChat,
FeedItem,
FeedMessagesPage,
FeedPage,
FeedUnseen,
MessageView,
PeerView,
)
CURSOR_PATTERN = r"^\d+\.\d+$"
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
_MICROSECOND = timedelta(microseconds=1)
_ITEM_COLS = (
"id, kind, chat_id, chat_kind, message_id, message_date, at, since, "
"message_count, detail"
)
_VERSIONS = """
SELECT v.chat_id, v.message_id, v.observed_at, v.edit_date, v.text,
p.text AS before, p.observed_at IS NOT NULL AS has_before
FROM unnest($2::bigint[], $3::bigint[], $4::timestamptz[])
AS k(chat_id, message_id, observed_at)
JOIN message_versions v
ON v.account_id = $1 AND v.chat_id = k.chat_id
AND v.message_id = k.message_id AND v.observed_at = k.observed_at
LEFT JOIN LATERAL (
SELECT text, observed_at FROM message_versions p
WHERE p.account_id = $1 AND p.chat_id = v.chat_id
AND p.message_id = v.message_id AND p.observed_at < v.observed_at
ORDER BY p.observed_at DESC LIMIT 1
) p ON true
"""
_UNSEEN = """
SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted,
count(*) FILTER (WHERE kind = 'edited') AS edited
FROM feed_items
WHERE account_id = $1 AND at > coalesce($2, '-infinity'::timestamptz)
"""
_MARK_SEEN = """
INSERT INTO feed_seen (account_id, seen_at) VALUES ($1, coalesce($2, now()))
ON CONFLICT (account_id) DO UPDATE
SET seen_at = greatest(feed_seen.seen_at, EXCLUDED.seen_at)
"""
class FeedKind(StrEnum):
ALL = "all"
MESSAGES = "messages"
DELETED = "deleted"
EDITED = "edited"
PROFILE = "profile"
class FeedChatKind(StrEnum):
DM = "dm"
GROUP = "group"
CHANNEL = "channel"
@dataclass(slots=True)
class FeedFilter:
kind: FeedKind = FeedKind.ALL
chat_id: int | None = None
chat_kind: FeedChatKind | None = None
since: datetime | None = None
def encode_cursor(at: datetime, key: int) -> str:
return f"{(at - _EPOCH) // _MICROSECOND}.{key}"
def decode_cursor(cursor: str) -> tuple[datetime, int]:
micros, _, key = cursor.partition(".")
return _EPOCH + int(micros) * _MICROSECOND, int(key)
def _where(
account_id: int, feed: FeedFilter, cursor: str | None
) -> tuple[str, list[object]]:
params: list[object] = [account_id]
clauses = ["account_id = $1"]
if feed.kind is FeedKind.MESSAGES:
clauses.append("kind <> 'profile'")
exact = feed.kind not in {FeedKind.ALL, FeedKind.MESSAGES}
conditions = (
("kind = {}", feed.kind.value if exact else None),
("chat_id = {}", feed.chat_id),
("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None),
("at >= {}", feed.since),
)
for template, value in conditions:
if value is not None:
params.append(value)
clauses.append(template.format(f"${len(params)}"))
if cursor is not None:
params.extend(decode_cursor(cursor))
clauses.append(f"(at, id) < (${len(params) - 1}, ${len(params)})")
return " AND ".join(clauses), params
def _peer_name(peer: PeerView) -> str | None:
name = " ".join(part for part in (peer.first_name, peer.last_name) if part)
return name or peer.username
async def _versions(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> dict[tuple[int, int, datetime], asyncpg.Record]:
edited = [row for row in rows if row["kind"] == FeedKind.EDITED]
if not edited:
return {}
found = await pool.fetch(
_VERSIONS,
account_id,
[row["chat_id"] for row in edited],
[row["message_id"] for row in edited],
[row["at"] for row in edited],
)
return {
(row["chat_id"], row["message_id"], row["observed_at"]): row for row in found
}
async def _sender_names(
pool: asyncpg.Pool,
account_id: int,
messages: dict[tuple[int, int], MessageView],
chat_views: dict[int, ChatListItem],
) -> dict[int, str | None]:
ids = {m.sender_id for m in messages.values() if m.sender_id is not None}
found = await peers.get_peers(pool, account_id, list(ids))
names: dict[int, str | None] = {
chat_id: chat.title for chat_id, chat in chat_views.items()
}
names.update({peer.peer_id: _peer_name(peer) for peer in found})
return names
async def _build_items(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> list[FeedItem]:
if not rows:
return []
messages = await chats.get_messages_at(
pool,
account_id,
[
(row["chat_id"], row["message_id"], row["message_date"])
for row in rows
if row["kind"] != FeedKind.PROFILE
],
)
versions = await _versions(pool, account_id, rows)
chat_views = {
chat.chat_id: chat
for chat in await chats.get_chats(
pool, account_id, list({row["chat_id"] for row in rows})
)
}
names = await _sender_names(pool, account_id, messages, chat_views)
self_id = await self_user_id(pool, account_id)
items: list[FeedItem] = []
for row in rows:
message = messages.get((row["chat_id"], row["message_id"]))
chat = chat_views.get(row["chat_id"])
version = versions.get((row["chat_id"], row["message_id"], row["at"]))
sender_id = message.sender_id if message else None
items.append(
FeedItem(
id=row["id"],
kind=row["kind"],
at=row["at"],
since=row["since"],
message_count=row["message_count"],
chat=FeedChat(
chat_id=row["chat_id"],
title=chat.title if chat else None,
kind=row["chat_kind"],
has_avatar=chat.has_avatar if chat else False,
),
message_id=row["message_id"],
message=message,
sender_id=sender_id,
sender_name=names.get(sender_id) if sender_id is not None else None,
outgoing=self_id is not None and sender_id == self_id,
before=version["before"] if version else None,
after=version["text"] if version else None,
has_before=bool(version and version["has_before"]),
edit_date=version["edit_date"] if version else None,
detail=json.loads(row["detail"]) if row["detail"] else None,
)
)
return items
async def list_feed(
pool: asyncpg.Pool,
account_id: int,
feed: FeedFilter,
*,
limit: int = DEFAULT_LIMIT,
cursor: str | None = None,
) -> FeedPage:
size = max(1, min(limit, MAX_LIMIT))
where, params = _where(account_id, feed, cursor)
params.append(size + 1)
rows = await pool.fetch(
f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608
f"ORDER BY at DESC, id DESC LIMIT ${len(params)}",
*params,
)
page = rows[:size]
more = len(rows) > size
return FeedPage(
items=await _build_items(pool, account_id, page),
next_cursor=encode_cursor(page[-1]["at"], page[-1]["id"]) if more else None,
)
async def unseen(pool: asyncpg.Pool, account_id: int) -> FeedUnseen:
seen_at = await pool.fetchval(
"SELECT seen_at FROM feed_seen WHERE account_id = $1", account_id
)
counts = await pool.fetchrow(_UNSEEN, account_id, seen_at)
latest = await list_feed(
pool, account_id, FeedFilter(kind=FeedKind.MESSAGES), limit=1
)
return FeedUnseen(
count=counts["deleted"] + counts["edited"],
deleted=counts["deleted"],
edited=counts["edited"],
seen_at=seen_at,
latest=latest.items[0] if latest.items else None,
)
async def mark_seen(
pool: asyncpg.Pool, account_id: int, at: datetime | None
) -> FeedUnseen:
await pool.execute(_MARK_SEEN, account_id, at)
return await unseen(pool, account_id)
async def item_messages(
pool: asyncpg.Pool,
account_id: int,
item_id: int,
*,
limit: int = DEFAULT_LIMIT,
cursor: str | None = None,
) -> FeedMessagesPage | None:
row = await pool.fetchrow(
"SELECT chat_id, since, at FROM feed_items "
"WHERE id = $1 AND account_id = $2 AND kind = 'deleted'",
item_id,
account_id,
)
if row is None:
return None
size = max(1, min(limit, MAX_LIMIT))
views = await chats.get_deleted_between(
pool,
account_id,
row["chat_id"],
row["since"],
row["at"],
size + 1,
decode_cursor(cursor) if cursor else None,
)
page = views[:size]
more = len(views) > size
return FeedMessagesPage(
messages=page,
next_cursor=encode_cursor(page[-1].date, page[-1].message_id) if more else None,
)
+203
View File
@@ -38,6 +38,8 @@ class ChatListItem(BaseModel):
last_date: datetime | None last_date: datetime | None
last_text: str | None last_text: str | None
last_sender_id: int | None last_sender_id: int | None
last_message_id: int | None = None
last_deleted: bool = False
class DiscoverItem(BaseModel): class DiscoverItem(BaseModel):
@@ -204,6 +206,7 @@ class MessageView(BaseModel):
is_animated_emoji: bool = False is_animated_emoji: bool = False
read: bool = False read: bool = False
read_at: datetime | None = None read_at: datetime | None = None
has_note: bool = False
class MessageVersionView(BaseModel): class MessageVersionView(BaseModel):
@@ -212,6 +215,50 @@ class MessageVersionView(BaseModel):
text: str | None text: str | None
class FeedChat(BaseModel):
chat_id: int
title: str | None
kind: str
has_avatar: bool
class FeedItem(BaseModel):
id: int
kind: str
at: datetime
since: datetime
message_count: int
chat: FeedChat
message_id: int
message: MessageView | None
sender_id: int | None
sender_name: str | None
outgoing: bool
before: str | None = None
after: str | None = None
has_before: bool = False
edit_date: datetime | None = None
detail: dict[str, list[str | None]] | None = None
class FeedPage(BaseModel):
items: list[FeedItem]
next_cursor: str | None
class FeedUnseen(BaseModel):
count: int
deleted: int
edited: int
seen_at: datetime | None
latest: FeedItem | None
class FeedMessagesPage(BaseModel):
messages: list[MessageView]
next_cursor: str | None
class MediaView(BaseModel): class MediaView(BaseModel):
id: int id: int
account_id: int account_id: int
@@ -474,6 +521,54 @@ class PeerHistoryView(BaseModel):
is_deleted_account: bool 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): class ChatLinkView(BaseModel):
message_id: int message_id: int
date: datetime | None date: datetime | None
@@ -519,16 +614,62 @@ class AnnotationView(BaseModel):
updated_at: datetime updated_at: datetime
class NoteView(AnnotationView):
chat_title: str | None
chat_has_avatar: bool
message_text: str | None
message_date: datetime | None
message_has_media: bool
message_deleted: bool
class NoteTag(BaseModel):
tag: str
count: int
class CaptureStatus(BaseModel):
live: bool
since: datetime | None
last_seen: datetime | None
class CaptureGap(BaseModel):
start: datetime
end: datetime
class WatchView(BaseModel): class WatchView(BaseModel):
id: int id: int
account_id: int account_id: int
kind: str kind: str
name: str | None
params: dict[str, Any] params: dict[str, Any]
enabled: bool enabled: bool
created_at: datetime created_at: datetime
updated_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): class AlertView(BaseModel):
id: int id: int
account_id: int account_id: int
@@ -539,6 +680,68 @@ class AlertView(BaseModel):
created_at: datetime 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): class FileShareView(BaseModel):
id: int id: int
account_id: int account_id: int
+34 -9
View File
@@ -2,6 +2,8 @@ import asyncpg
from utils.read.models import Page, PeerHistoryView, PeerView, StoryView from utils.read.models import Page, PeerHistoryView, PeerView, StoryView
HISTORY_LIMIT = 500
_PEER_COLS = ( _PEER_COLS = (
"peer_id, first_name, last_name, username, phone, photo_unique_id, " "peer_id, first_name, last_name, username, phone, photo_unique_id, "
"is_deleted_account, updated_at, " "is_deleted_account, updated_at, "
@@ -9,6 +11,23 @@ _PEER_COLS = (
"AND a.owner_id = peers.peer_id) AS has_avatar" "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( async def get_peer(
pool: asyncpg.Pool, account_id: int, peer_id: int pool: asyncpg.Pool, account_id: int, peer_id: int
@@ -39,14 +58,7 @@ async def get_peers(
async def get_peer_history( async def get_peer_history(
pool: asyncpg.Pool, account_id: int, peer_id: int pool: asyncpg.Pool, account_id: int, peer_id: int
) -> list[PeerHistoryView]: ) -> list[PeerHistoryView]:
rows = await pool.fetch( rows = await pool.fetch(_HISTORY, account_id, peer_id, HISTORY_LIMIT)
"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,
)
return [PeerHistoryView(**dict(row)) for row in rows] return [PeerHistoryView(**dict(row)) for row in rows]
@@ -64,7 +76,7 @@ async def get_stories(
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608 "SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608
"storage_key, downloaded, views, pinned, deleted FROM stories " "storage_key, downloaded, views, pinned, deleted FROM stories "
f"WHERE {where} " f"WHERE {where} "
f"ORDER BY date DESC NULLS LAST LIMIT ${len(params) - 1} " f"ORDER BY date DESC NULLS LAST, story_id DESC LIMIT ${len(params) - 1} "
f"OFFSET ${len(params)}", f"OFFSET ${len(params)}",
*params, *params,
) )
@@ -83,3 +95,16 @@ async def get_story(
story_id, story_id,
) )
return StoryView(**dict(row)) if row else None return StoryView(**dict(row)) if row else None
async def set_story_kind(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int, kind: str
) -> None:
await pool.execute(
"UPDATE stories SET media_kind = $4 "
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
account_id,
peer_id,
story_id,
kind,
)
+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)
)
+23
View File
@@ -48,6 +48,29 @@ async def current_presence(
return PresenceSample(**dict(row)) if row is not None else None return PresenceSample(**dict(row)) if row is not None else None
_ONLINE = """
SELECT peer_id, ts, status, last_online_date, next_offline_date
FROM (
SELECT DISTINCT ON (peer_id)
peer_id, ts, status, last_online_date, next_offline_date
FROM presence
WHERE account_id = $1 AND peer_id = ANY($2::bigint[])
AND ts > now() - interval '1 hour'
ORDER BY peer_id, ts DESC
) latest
WHERE status = 'online' AND next_offline_date > now()
"""
async def online_now(
pool: asyncpg.Pool, account_id: int, peer_ids: list[int]
) -> list[PresenceSample]:
if not peer_ids:
return []
rows = await pool.fetch(_ONLINE, account_id, peer_ids)
return [PresenceSample(**dict(row)) for row in rows]
async def presence_hourly( async def presence_hourly(
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int, account_id: int,
+299 -27
View File
@@ -4,13 +4,70 @@ from typing import Any
import asyncpg 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" WATCHES_CHANGED_CHANNEL = "watches_changed"
ALERTS_CHANGED_CHANNEL = "alerts_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" _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: def _to_watch(row: asyncpg.Record) -> WatchView:
@@ -25,6 +82,10 @@ def _to_alert(row: asyncpg.Record) -> AlertView:
return AlertView(**data) 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]: async def list_watches(pool: asyncpg.Pool, account_id: int) -> list[WatchView]:
rows = await pool.fetch( rows = await pool.fetch(
f"SELECT {_WATCH_COLS} FROM watches WHERE account_id = $1 " # noqa: S608 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 return _to_watch(row) if row else None
async def create_watch( async def create_watch( # noqa: PLR0913
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int, account_id: int,
kind: str, kind: str,
params: dict[str, Any], params: dict[str, Any],
*, *,
name: str | None = None,
enabled: bool = True, enabled: bool = True,
) -> WatchView: ) -> WatchView:
row = await pool.fetchrow( row = await pool.fetchrow(
"INSERT INTO watches (account_id, kind, params, enabled) " # noqa: S608 "INSERT INTO watches (account_id, kind, name, params, enabled) " # noqa: S608
f"VALUES ($1, $2, $3::jsonb, $4) RETURNING {_WATCH_COLS}", f"VALUES ($1, $2, $3, $4::jsonb, $5) RETURNING {_WATCH_COLS}",
account_id, account_id,
kind, kind,
name,
json.dumps(params), json.dumps(params),
enabled, enabled,
) )
@@ -62,13 +125,22 @@ async def create_watch(
return _to_watch(row) return _to_watch(row)
async def update_watch( async def update_watch( # noqa: PLR0913
pool: asyncpg.Pool, watch_id: int, params: dict[str, Any], *, enabled: bool pool: asyncpg.Pool,
watch_id: int,
kind: str,
params: dict[str, Any],
*,
name: str | None,
enabled: bool,
) -> WatchView | None: ) -> WatchView | None:
row = await pool.fetchrow( 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}", f"WHERE id = $1 RETURNING {_WATCH_COLS}",
watch_id, watch_id,
kind,
name,
json.dumps(params), json.dumps(params),
enabled, enabled,
) )
@@ -79,29 +151,210 @@ async def update_watch(
async def delete_watch(pool: asyncpg.Pool, watch_id: int) -> bool: async def delete_watch(pool: asyncpg.Pool, watch_id: int) -> bool:
result = await pool.execute("DELETE FROM watches WHERE id = $1", watch_id) account_id = await pool.fetchval(
if not result.endswith("1"): "DELETE FROM watches WHERE id = $1 RETURNING account_id", watch_id
)
if account_id is None:
return False return False
await pool.execute(f"NOTIFY {WATCHES_CHANGED_CHANNEL}") await pool.execute(f"NOTIFY {WATCHES_CHANGED_CHANNEL}")
await _notify(pool, ALERTS_CHANGED_CHANNEL, {"account_id": account_id})
return True 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( async def list_alerts(
pool: asyncpg.Pool, account_id: int, page: Page, *, seen: bool | None = None pool: asyncpg.Pool,
) -> list[AlertView]: 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] params: list[object] = [account_id]
where = "account_id = $1" where = "a.account_id = $1"
if seen is not None: if seen is not None:
params.append(seen) params.append(seen)
where += f" AND seen = ${len(params)}" where += f" AND a.seen = ${len(params)}"
params.append(page.capped_limit) if before is not None:
params.append(page.offset) params.append(before)
where += f" AND a.id < ${len(params)}"
params.append(size + 1)
rows = await pool.fetch( rows = await pool.fetch(
f"SELECT {_ALERT_COLS} FROM alerts WHERE {where} " # noqa: S608 f"{_ALERT_ROWS} WHERE {where} ORDER BY a.id DESC LIMIT ${len(params)}", *params
f"ORDER BY ts DESC LIMIT ${len(params) - 1} OFFSET ${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( async def insert_alert(
@@ -113,10 +366,7 @@ async def insert_alert(
dedup_key: str | None = None, dedup_key: str | None = None,
) -> AlertView | None: ) -> AlertView | None:
row = await pool.fetchrow( row = await pool.fetchrow(
"INSERT INTO alerts (account_id, watch_id, ts, payload, seen, dedup_key) " # noqa: S608 _INSERT_ALERT,
"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}",
account_id, account_id,
watch_id, watch_id,
datetime.now(UTC), datetime.now(UTC),
@@ -125,10 +375,32 @@ async def insert_alert(
) )
if row is None: if row is None:
return 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) return _to_alert(row)
async def mark_alert_seen(pool: asyncpg.Pool, alert_id: int) -> bool: 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) account_id = await pool.fetchval(
return result.endswith("1") "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'"
)
+65
View File
@@ -0,0 +1,65 @@
import re
from collections.abc import Iterator
from typing import Any
from utils.search.models import Found, FoundWhere
HIDDEN_KEYS = ("chat", "from_user", "sender_chat")
SKIPPED_KEYS = ("_", "file_id", "file_unique_id")
ENUM = re.compile(r"^[A-Z][A-Za-z]+\.[A-Z_]+$")
_HEADS: dict[str, FoundWhere] = {
"text": "text",
"caption": "text",
"web_page": "preview",
}
_LEAVES: dict[str, FoundWhere] = {"url": "link", "file_name": "file"}
type Path = tuple[str | int, ...]
def _scalars(node: object, path: Path = ()) -> Iterator[tuple[Path, str, object]]:
if isinstance(node, dict):
items = ((k, v) for k, v in node.items() if k not in SKIPPED_KEYS)
elif isinstance(node, list):
items = enumerate(node)
else:
return
for key, value in items:
if isinstance(value, dict | list):
yield from _scalars(value, (*path, key))
elif isinstance(value, str | int | float) and not isinstance(value, bool):
yield (*path, key), str(value), node
def _where(path: Path) -> FoundWhere:
head, leaf = str(path[0]), str(path[-1])
if head == "reply_markup":
return "button"
if leaf in _LEAVES:
return _LEAVES[leaf]
if head.startswith("forward"):
return "forward"
if head.startswith("reply_to"):
return "reply"
return _HEADS.get(head, "data")
def find(raw: dict[str, Any], needle: str) -> Found | None:
folded = needle.casefold()
fallback = None
for path, value, owner in _scalars(raw):
if folded not in value.casefold() or ENUM.match(value):
continue
where = _where(path)
button = owner.get("text") if isinstance(owner, dict) else None
found = Found(
where=where,
path=".".join(map(str, path)),
value=value,
button=button if where == "button" else None,
)
if where != "text":
return found
fallback = fallback or found
return fallback
+55 -5
View File
@@ -1,23 +1,59 @@
from datetime import datetime from datetime import UTC, datetime, timedelta
from typing import Literal from typing import Literal
from pydantic import BaseModel from pydantic import BaseModel, field_validator
DEFAULT_LIMIT = 50 DEFAULT_LIMIT = 50
MAX_LIMIT = 500 MAX_LIMIT = 500
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
_MICROSECOND = timedelta(microseconds=1)
ChatKind = Literal["private", "group", "channel"]
HitSource = Literal["text", "stt", "version", "data"]
FoundWhere = Literal[
"text", "button", "link", "forward", "reply", "file", "preview", "data"
]
def encode_cursor(date: datetime, chat_id: int, message_id: int) -> str:
return f"{(date - _EPOCH) // _MICROSECOND}_{chat_id}_{message_id}"
def decode_cursor(cursor: str) -> tuple[datetime, int, int]:
micros, chat_id, message_id = (int(part) for part in cursor.split("_"))
return _EPOCH + micros * _MICROSECOND, chat_id, message_id
class SearchFilters(BaseModel): class SearchFilters(BaseModel):
account_id: int account_id: int
query: str | None = None query: str | None = None
chat_id: int | None = None chat_id: int | None = None
sender_id: int | None = None sender_id: int | None = None
kind: ChatKind | None = None
has_media: bool | None = None has_media: bool | None = None
deleted: bool | None = None
edited: bool | None = None
date_from: datetime | None = None date_from: datetime | None = None
date_to: datetime | None = None date_to: datetime | None = None
regex: str | None = None regex: str | None = None
everywhere: bool = False
cursor: str | None = None
limit: int = DEFAULT_LIMIT limit: int = DEFAULT_LIMIT
offset: int = 0
@field_validator("cursor")
@classmethod
def _valid_cursor(cls, value: str | None) -> str | None:
if value is not None:
decode_cursor(value)
return value
class Found(BaseModel):
where: FoundWhere
path: str
value: str
button: str | None = None
class SearchHit(BaseModel): class SearchHit(BaseModel):
@@ -26,5 +62,19 @@ class SearchHit(BaseModel):
date: datetime date: datetime
sender_id: int | None sender_id: int | None
text: str | None text: str | None
source: Literal["text", "stt"] has_media: bool
extracted_text: str | None media_kind: str | None
edited_at: datetime | None
deleted_at: datetime | None
source: HitSource
extracted_text: str | None = None
version_text: str | None = None
version_at: datetime | None = None
found: Found | None = None
class SearchPage(BaseModel):
hits: list[SearchHit]
next_cursor: str | None
terms: list[str]
scanned_to: datetime | None = None
+262 -71
View File
@@ -1,88 +1,279 @@
import json
import re
from datetime import UTC, datetime, timedelta
from time import monotonic
import asyncpg import asyncpg
from utils.search.models import MAX_LIMIT, SearchFilters, SearchHit from utils.read.message_view import load_raw
from utils.search.found import ENUM, HIDDEN_KEYS, SKIPPED_KEYS, find
_TEXT_TSV = "to_tsvector('russian', coalesce(m.text, ''))" from utils.search.models import (
_STT_TSV = "to_tsvector('russian', coalesce(md.extracted_text, ''))" MAX_LIMIT,
_TSQUERY = "websearch_to_tsquery('russian', ${})" ChatKind,
SearchFilters,
_HITS = "{cols} FROM messages m WHERE {where}" # text branch SearchHit,
_STT_HITS = ( SearchPage,
"{cols} FROM messages m " decode_cursor,
"JOIN media md ON md.account_id = m.account_id " encode_cursor,
"AND md.chat_id = m.chat_id AND md.message_id = m.message_id "
"WHERE {where}"
) )
_TEXT_COLS = (
"SELECT m.chat_id, m.message_id, m.date, m.sender_id, m.text, " MIN_NEEDLE = 3
"'text' AS source, NULL::text AS extracted_text" _WINDOW = timedelta(days=7)
_BUDGET_SECONDS = 2.5
_BOTTOM = -(2**63)
_LIKE_SPECIAL = re.compile(r"([\\%_])")
_PARSE = """
SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text,
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
|| regexp_replace(websearch_to_tsquery('simple', q)::text,
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
)::text AS tsquery,
tsvector_to_array(search_vector(q)) AS terms
FROM translate($1, 'Ёё', 'Ее') AS q
"""
_CHANNELS = """
SELECT coalesce(array_agg(chat_id), '{}'::bigint[]) FROM (
SELECT DISTINCT ON (chat_id) chat_id,
coalesce(raw->'chat'->>'type', raw->>'type') AS type
FROM chat_history
WHERE account_id = $1
AND coalesce(raw->'chat'->>'type', raw->>'type') IS NOT NULL
ORDER BY chat_id, ts DESC
) typed WHERE type = 'ChatType.CHANNEL'
"""
_KEY_ORDER = "s.date DESC, s.chat_id DESC, s.message_id DESC"
_COLS = (
"s.chat_id, s.message_id, s.date, s.sender_id, s.text, s.has_media, "
"s.edited_at, s.deleted_at"
) )
_STT_COLS = (
"SELECT m.chat_id, m.message_id, m.date, m.sender_id, m.text, " _TEXT = (
"'stt' AS source, md.extracted_text" f"SELECT {_COLS}, 0 AS rank, NULL::text AS extracted_text, " # noqa: S608
"NULL::text AS version_text, NULL::timestamptz AS version_at "
"FROM message_search s WHERE {where}"
)
_PROBE = (
"CROSS JOIN LATERAL (SELECT * FROM message_search s "
"WHERE s.chat_id = {source}.chat_id AND s.message_id = {source}.message_id "
"AND {where} LIMIT 1) s "
)
_STT = (
f"SELECT {_COLS}, 1 AS rank, md.extracted_text, NULL::text, " # noqa: S608
"NULL::timestamptz FROM media md "
f"{_PROBE.replace('{source}', 'md')}"
"WHERE md.account_id = {account} "
"AND search_vector(md.extracted_text) @@ {tsquery}"
)
_VERSION = (
"SELECT DISTINCT ON (s.date, s.chat_id, s.message_id) " # noqa: S608
f"{_COLS}, 2 AS rank, NULL::text, v.text, v.observed_at "
"FROM message_versions v "
f"{_PROBE.replace('{source}', 'v')}"
"WHERE v.account_id = {account} "
"AND search_vector(v.text) @@ {tsquery}"
)
_WITH_MEDIA = (
"SELECT h.*, md.kind AS media_kind FROM ({hits}) h "
"LEFT JOIN media md ON md.account_id = {account} "
"AND md.chat_id = h.chat_id AND md.message_id = h.message_id "
"ORDER BY h.date DESC, h.chat_id DESC, h.message_id DESC"
)
_SOURCES = ("text", "stt", "version")
_OLDEST = "SELECT min(date) FROM message_search WHERE account_id = $1"
_VALUE_MATCH = (
'strict $.** ? (@.type() == "object").keyvalue() ? ('
+ "".join(f'@.key != "{key}" && ' for key in SKIPPED_KEYS)
+ '(@.value.type() == "string" || @.value.type() == "number") && '
f"!(@.value.string() like_regex {json.dumps(ENUM.pattern)}) && "
'@.value.string() like_regex {needle} flag "iq")'
)
_EVERYWHERE = (
f"SELECT {_COLS}, s.raw - {{hidden}}::text[] AS raw " # noqa: S608
"FROM messages s WHERE {where} "
"AND CASE WHEN s.raw::text ILIKE {like} "
"THEN jsonb_path_exists(s.raw - {hidden}::text[], {path}::jsonpath) "
f"ELSE false END ORDER BY {_KEY_ORDER} LIMIT {{limit}}"
) )
def _common_conditions(filters: SearchFilters, params: list[object]) -> list[str]: class _Binder:
params.append(filters.account_id) def __init__(self) -> None:
conds = [f"m.account_id = ${len(params)}"] self.params: list[object] = []
if filters.chat_id is not None:
params.append(filters.chat_id) def __call__(self, value: object) -> str:
conds.append(f"m.chat_id = ${len(params)}") self.params.append(value)
if filters.sender_id is not None: return f"${len(self.params)}"
params.append(filters.sender_id)
conds.append(f"m.sender_id = ${len(params)}")
if filters.has_media is not None: def _kind(kind: ChatKind, channels: list[int], bind: _Binder) -> str:
params.append(filters.has_media) if kind == "private":
conds.append(f"m.has_media = ${len(params)}") return "s.chat_id > 0"
if filters.date_from is not None: member = f"s.chat_id = ANY({bind(channels)}::bigint[])"
params.append(filters.date_from) return member if kind == "channel" else f"s.chat_id < 0 AND NOT {member}"
conds.append(f"m.date >= ${len(params)}")
if filters.date_to is not None:
params.append(filters.date_to) def _conditions(
conds.append(f"m.date <= ${len(params)}") filters: SearchFilters, channels: list[int], bind: _Binder
) -> list[str]:
conds = [f"s.account_id = {bind(filters.account_id)}"]
compared = (
("s.chat_id =", filters.chat_id),
("s.sender_id =", filters.sender_id),
("s.has_media =", filters.has_media),
("s.date >=", filters.date_from),
("s.date <=", filters.date_to),
("s.text ~", filters.regex),
)
conds += [f"{left} {bind(value)}" for left, value in compared if value is not None]
flags = (("s.deleted_at", filters.deleted), ("s.edited_at", filters.edited))
conds += [
f"{column} IS {'NOT ' if flag else ''}NULL"
for column, flag in flags
if flag is not None
]
if filters.kind is not None:
conds.append(_kind(filters.kind, channels, bind))
if filters.cursor is not None:
date, chat_id, message_id = decode_cursor(filters.cursor)
conds.append(
"(s.date, s.chat_id, s.message_id) < "
f"({bind(date)}, {bind(chat_id)}, {bind(message_id)})"
)
return conds return conds
async def search_messages( def _branch(sql: str, limit: str, order: str = _KEY_ORDER, **parts: str) -> str:
pool: asyncpg.Pool, filters: SearchFilters return f"({sql.format(**parts)} ORDER BY {order} LIMIT {limit})"
) -> list[SearchHit]:
query = filters.query.strip() if filters.query else None
params: list[object] = []
common = _common_conditions(filters, params)
if filters.regex is not None:
params.append(filters.regex)
common.append(f"m.text ~ ${len(params)}")
q_idx: int | None = None
if query is not None:
params.append(query)
q_idx = len(params)
text_conds = [*common] async def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage:
if q_idx is not None: async with pool.acquire() as conn, conn.transaction():
text_conds.append(f"{_TEXT_TSV} @@ {_TSQUERY.format(q_idx)}") await conn.execute("SET LOCAL jit = off")
if filters.everywhere:
return await _everywhere(conn, filters)
return await _search(conn, filters)
branches = [_HITS.format(cols=_TEXT_COLS, where=" AND ".join(text_conds))]
if q_idx is not None:
stt_conds = [*common, f"{_STT_TSV} @@ {_TSQUERY.format(q_idx)}"]
branches.append(_STT_HITS.format(cols=_STT_COLS, where=" AND ".join(stt_conds)))
params.append(min(filters.limit, MAX_LIMIT)) async def _channels(conn: asyncpg.Connection, filters: SearchFilters) -> list[int]:
limit_idx = len(params) if filters.kind in {"group", "channel"}:
params.append(filters.offset) return await conn.fetchval(_CHANNELS, filters.account_id)
offset_idx = len(params) return []
page = f"ORDER BY date DESC LIMIT ${limit_idx} OFFSET ${offset_idx}"
if len(branches) == 1:
sql = f"SELECT * FROM ({branches[0]}) hits {page}" # noqa: S608 def _data_hit(row: asyncpg.Record, needle: str) -> SearchHit:
else: data = dict(row)
union = " UNION ALL ".join(f"({b})" for b in branches) raw = load_raw(data.pop("raw"))
dedup = ( return SearchHit(**data, source="data", found=find(raw, needle))
"SELECT DISTINCT ON (chat_id, message_id) * " # noqa: S608
f"FROM ({union}) hits ORDER BY chat_id, message_id, source DESC"
async def _everywhere(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
needle = (filters.query or "").strip()
floor = filters.date_from or await conn.fetchval(_OLDEST, filters.account_id)
if len(needle) < MIN_NEEDLE or floor is None:
return SearchPage(hits=[], next_cursor=None, terms=[])
channels = await _channels(conn, filters)
quoted = json.dumps(needle, ensure_ascii=False)
like = _LIKE_SPECIAL.sub(r"\\\1", quoted[1:-1])
path = _VALUE_MATCH.format(needle=quoted)
size = min(filters.limit, MAX_LIMIT)
cursor = filters.cursor
top = decode_cursor(cursor)[0] if cursor else None
hits: list[SearchHit] = []
started = monotonic()
while True:
lower = max((top or filters.date_to or datetime.now(UTC)) - _WINDOW, floor)
bind = _Binder()
conds = _conditions(
filters.model_copy(update={"cursor": cursor}), channels, bind
) )
sql = f"SELECT * FROM ({dedup}) d {page}" # noqa: S608 conds.append(f"s.date >= {bind(lower)}")
if top is not None:
conds.append(f"s.date <= {bind(top)}")
limit = size - len(hits)
sql = _EVERYWHERE.format(
where=" AND ".join(conds),
hidden=bind(list(HIDDEN_KEYS)),
like=bind(f"%{like}%"),
path=bind(path),
limit=bind(limit),
)
account = bind(filters.account_id)
rows = await conn.fetch(
_WITH_MEDIA.format(hits=sql, account=account), *bind.params
)
hits += [_data_hit(row, needle) for row in rows]
if len(rows) >= limit:
last = rows[-1]
top = last["date"]
cursor = encode_cursor(top, last["chat_id"], last["message_id"])
elif lower > floor:
top = lower
cursor = encode_cursor(lower, _BOTTOM, _BOTTOM)
else:
return SearchPage(hits=hits, next_cursor=None, terms=[], scanned_to=floor)
if len(hits) >= size or monotonic() - started > _BUDGET_SECONDS:
return SearchPage(hits=hits, next_cursor=cursor, terms=[], scanned_to=top)
rows = await pool.fetch(sql, *params)
return [SearchHit(**dict(row)) for row in rows] async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
query = (filters.query or "").strip()
terms: list[str] = []
tsquery = None
if query:
parsed = await conn.fetchrow(_PARSE, query)
terms = list(parsed["terms"])
if not parsed["tsquery"]:
return SearchPage(hits=[], next_cursor=None, terms=terms)
tsquery = parsed["tsquery"]
channels = await _channels(conn, filters)
bind = _Binder()
where = " AND ".join(_conditions(filters, channels, bind))
size = min(filters.limit, MAX_LIMIT)
limit = bind(size + 1)
account = bind(filters.account_id)
if tsquery is None:
sql = _branch(_TEXT, limit, where=where)
else:
match = f"{bind(tsquery)}::tsquery"
branches = (
_branch(_TEXT, limit, where=f"{where} AND s.tsv @@ {match}"),
_branch(_STT, limit, where=where, account=account, tsquery=match),
_branch(
_VERSION,
limit,
f"{_KEY_ORDER}, v.observed_at DESC",
where=where,
account=account,
tsquery=match,
),
)
sql = (
"SELECT DISTINCT ON (date, chat_id, message_id) * FROM (" # noqa: S608
f"{' UNION ALL '.join(branches)}) s "
f"ORDER BY {_KEY_ORDER}, rank LIMIT {limit}"
)
rows = await conn.fetch(_WITH_MEDIA.format(hits=sql, account=account), *bind.params)
hits = [
SearchHit(**{**dict(row), "source": _SOURCES[row["rank"]]})
for row in rows[:size]
]
next_cursor = None
if len(rows) > size:
last = hits[-1]
next_cursor = encode_cursor(last.date, last.chat_id, last.message_id)
return SearchPage(hits=hits, next_cursor=next_cursor, terms=terms)
+4
View File
@@ -24,6 +24,10 @@ class ContentAddressedStorage:
def get(self, key: str) -> bytes: def get(self, key: str) -> bytes:
return self._path(key).read_bytes() return self._path(key).read_bytes()
def head(self, key: str, size: int = 16) -> bytes:
with self._path(key).open("rb") as file:
return file.read(size)
def exists(self, key: str) -> bool: def exists(self, key: str) -> bool:
return self._path(key).exists() return self._path(key).exists()
+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
+32 -5
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import contextlib import contextlib
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
import asyncpg import asyncpg
from pyrogram import Client, enums 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.capture.chat_meta import meta_from_chat
from userbot.modules.client import PyroClient from userbot.modules.client import PyroClient
from userbot.modules.jobs import JobConsumer from userbot.modules.jobs import JobConsumer
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event from utils.events import notify_bg_event
from utils.jobs import enqueue from utils.jobs import enqueue
from utils.logging import logger from utils.logging import logger
@@ -29,6 +31,7 @@ TOKEN_MISSING = "Токен не найден на диске" # noqa: S105
INVALID_TOKEN_ERRORS = ("ACCESS_TOKEN_INVALID", "ACCESS_TOKEN_EXPIRED") INVALID_TOKEN_ERRORS = ("ACCESS_TOKEN_INVALID", "ACCESS_TOKEN_EXPIRED")
BACKLOG_ATTEMPTS = 3 BACKLOG_ATTEMPTS = 3
BACKLOG_RETRY_PAUSE = 60 BACKLOG_RETRY_PAUSE = 60
ENRICH_COOLDOWN = timedelta(hours=6)
@dataclass @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: if ctx is None or message.empty or message.chat is None or message.date is None:
return return
meta = meta_from_chat(message.chat, ctx.contacts.ids) meta = meta_from_chat(message.chat, ctx.contacts.ids)
await ctx.watches.on_text( seen = seen_message(meta.chat_id, message)
meta.chat_id, message.id, message.text or message.caption if message.edit_date is None:
) await ctx.watches.on_message(seen)
else:
await ctx.watches.on_edit(seen)
toggles = ctx.resolve(meta) toggles = ctx.resolve(meta)
if not toggles.messages: if not toggles.messages:
return return
@@ -67,14 +72,24 @@ def _handlers(pool: asyncpg.Pool) -> tuple:
) )
if message.chat.type is not enums.ChatType.PRIVATE: if message.chat.type is not enums.ChatType.PRIVATE:
await enqueue( 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 async def on_member(client: Client, update) -> None: # noqa: ANN001
ctx = getattr(client, "capture", None) ctx = getattr(client, "capture", None)
if ctx is None or update.chat is None: if ctx is None or update.chat is None:
return 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 ( return (
MessageHandler(on_message), MessageHandler(on_message),
@@ -98,6 +113,12 @@ class BotRegistry:
def running(self) -> list[int]: def running(self) -> list[int]:
return list(self._running) return list(self._running)
@property
def connected_accounts(self) -> list[int]:
return [
bot.account_id for bot in self._running.values() if bot.client.is_connected
]
async def sync(self) -> None: async def sync(self) -> None:
async with self._lock: async with self._lock:
await self._adopt_dropped_in() await self._adopt_dropped_in()
@@ -115,6 +136,12 @@ class BotRegistry:
for bot_id in list(self._running): for bot_id in list(self._running):
await self._stop(bot_id) 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: async def _adopt_dropped_in(self) -> None:
unknown = tokens.stored_bot_ids() - await bots.known_bot_ids(self._pool) unknown = tokens.stored_bot_ids() - await bots.known_bot_ids(self._pool)
for bot_id in unknown: for bot_id in unknown:
+30 -4
View File
@@ -6,15 +6,18 @@ import asyncpg
import uvloop import uvloop
from dependencies.container import container from dependencies.container import container
from userbot.modules.uptime import repository as uptime
from utils.listen import Listener from utils.listen import Listener
from utils.logging import logger, setup_logging from utils.logging import logger, setup_logging
from utils.read.bots import BOTS_CHANGED_CHANNEL from utils.read.bots import BOTS_CHANGED_CHANNEL
from utils.read.watches import WATCHES_CHANGED_CHANNEL
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
from watcher.registry import BotRegistry from watcher.registry import BotRegistry
setup_logging() setup_logging()
RETRY_INTERVAL = 300 RETRY_INTERVAL = 300
UPTIME_INTERVAL = 60
async def _retry_loop(registry: BotRegistry) -> None: async def _retry_loop(registry: BotRegistry) -> None:
@@ -26,13 +29,34 @@ async def _retry_loop(registry: BotRegistry) -> None:
logger.exception("[red]Bot sync failed[/]") logger.exception("[red]Bot sync failed[/]")
async def _keep_uptime(registry: BotRegistry, pool: asyncpg.Pool) -> None:
while True:
for account_id in registry.connected_accounts:
with contextlib.suppress(Exception):
await uptime.heartbeat(pool, account_id)
await asyncio.sleep(UPTIME_INTERVAL)
def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener: def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener:
def on_change(_payload: str) -> None: def on_change(_payload: str) -> None:
task = asyncio.create_task(registry.sync()) task = asyncio.create_task(registry.sync())
tasks.add(task) tasks.add(task)
task.add_done_callback(tasks.discard) task.add_done_callback(tasks.discard)
return Listener("watcher", {BOTS_CHANGED_CHANNEL: on_change}, lambda: on_change("")) 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: async def runner() -> None:
@@ -44,6 +68,7 @@ async def runner() -> None:
stop = asyncio.Event() stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set) asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
retry = asyncio.create_task(_retry_loop(registry)) retry = asyncio.create_task(_retry_loop(registry))
beat = asyncio.create_task(_keep_uptime(registry, pool))
try: try:
await registry.sync() await registry.sync()
if not registry.running: if not registry.running:
@@ -52,9 +77,10 @@ async def runner() -> None:
logger.info("[green]Bot watcher running.[/]") logger.info("[green]Bot watcher running.[/]")
await stop.wait() await stop.wait()
finally: finally:
retry.cancel() for task in (retry, beat):
with contextlib.suppress(asyncio.CancelledError): task.cancel()
await retry with contextlib.suppress(asyncio.CancelledError):
await task
await listener.stop() await listener.stop()
await registry.close() await registry.close()
await container.close() await container.close()
+2
View File
@@ -149,6 +149,7 @@ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "fastmcp" }, { name = "fastmcp" },
{ name = "greenlet" }, { name = "greenlet" },
{ name = "httpx2" },
{ name = "kurigram" }, { name = "kurigram" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "rich" }, { name = "rich" },
@@ -166,6 +167,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi", specifier = ">=0.115.0" },
{ name = "fastmcp", specifier = ">=3.3" }, { name = "fastmcp", specifier = ">=3.3" },
{ name = "greenlet", specifier = ">=3.1.0" }, { name = "greenlet", specifier = ">=3.1.0" },
{ name = "httpx2", specifier = ">=2.13.1" },
{ name = "kurigram", specifier = ">=2.2.23" }, { name = "kurigram", specifier = ">=2.2.23" },
{ name = "pydantic-settings", specifier = ">=2.7.0" }, { name = "pydantic-settings", specifier = ">=2.7.0" },
{ name = "rich", specifier = ">=13.9.0" }, { name = "rich", specifier = ">=13.9.0" },
+6 -5
View File
@@ -1,13 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts import type { PanelState } from "#lib/stores/ui.svelte.js";
// for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}
// interface PageState {} interface PageState {
inApp?: boolean;
panel?: PanelState;
}
// interface Platform {} // interface Platform {}
} }
} }
export {};
+32
View File
@@ -0,0 +1,32 @@
import { request } from "#lib/api/client.js";
import type { PresenceSample } from "#lib/api/types.js";
export interface CaptureStatus {
last_seen: string | null;
live: boolean;
since: string | null;
}
export interface CaptureGap {
end: string;
start: string;
}
export function getCaptureStatus(accountId: number): Promise<CaptureStatus> {
return request<CaptureStatus>("/capture/status", {
query: { account_id: accountId },
});
}
export function getCaptureGaps(accountId: number): Promise<CaptureGap[]> {
return request<CaptureGap[]>("/capture/gaps", {
query: { account_id: accountId },
});
}
export function getOnline(peerIds: number[]): Promise<PresenceSample[]> {
return request<PresenceSample[]>("/presence/online", {
account: true,
query: { peer_ids: peerIds.join(",") },
});
}
+55 -54
View File
@@ -1,7 +1,6 @@
import { request } from "#lib/api/client.js"; import { request } from "#lib/api/client.js";
import type { import type {
Account, Account,
Alert,
Annotation, Annotation,
AvatarHistoryView, AvatarHistoryView,
CallbackView, CallbackView,
@@ -10,6 +9,11 @@ import type {
ChatLinkView, ChatLinkView,
DayCount, DayCount,
DiscoverItem, DiscoverItem,
FeedChatKind,
FeedKind,
FeedMessagesPage,
FeedPage,
FeedUnseen,
Folder, Folder,
JobStatus, JobStatus,
JobView, JobView,
@@ -20,7 +24,8 @@ import type {
MessageAt, MessageAt,
MessageVersion, MessageVersion,
MessageView, MessageView,
PeerHistoryView, PeerGroups,
PeerOverview,
PeerView, PeerView,
PinnedView, PinnedView,
PolicyChatKind, PolicyChatKind,
@@ -31,11 +36,13 @@ import type {
PresenceSample, PresenceSample,
PresenceSession, PresenceSession,
PresenceStats, PresenceStats,
ProfileChange,
ReactionView, ReactionView,
ReadInfo, ReadInfo,
ResponseStats, ResponseStats,
ScopeTarget, ScopeTarget,
SearchHit, SearchPage,
SearchQuery,
StorageChat, StorageChat,
StorageFile, StorageFile,
StorageGroupName, StorageGroupName,
@@ -45,7 +52,6 @@ import type {
TrashItem, TrashItem,
TrashOutcome, TrashOutcome,
VolumeBucket, VolumeBucket,
Watch,
WatchedBot, WatchedBot,
} from "#lib/api/types.js"; } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
@@ -274,12 +280,53 @@ export function listDeleted(
}); });
} }
export interface FeedQuery {
chat_id?: number;
chat_kind?: FeedChatKind;
cursor?: string;
kind?: FeedKind;
limit?: number;
}
export function listFeed(query: FeedQuery = {}): Promise<FeedPage> {
return request<FeedPage>("/feed", { account: true, query: { ...query } });
}
export function getFeedUnseen(): Promise<FeedUnseen> {
return request<FeedUnseen>("/feed/unseen", { account: true });
}
export function markFeedSeen(at?: string): Promise<FeedUnseen> {
return request<FeedUnseen>("/feed/seen", {
body: { account_id: accounts.selectedId, at },
method: "POST",
});
}
export function listFeedMessages(
itemId: number,
query: { cursor?: string; limit?: number } = {}
): Promise<FeedMessagesPage> {
return request<FeedMessagesPage>(`/feed/${itemId}/messages`, {
account: true,
query: { ...query },
});
}
export function getPeer(peerId: number): Promise<PeerView> { export function getPeer(peerId: number): Promise<PeerView> {
return request<PeerView>(`/peers/${peerId}`, { account: true }); return request<PeerView>(`/peers/${peerId}`, { account: true });
} }
export function getPeerHistory(peerId: number): Promise<PeerHistoryView[]> { export function getPeerOverview(peerId: number): Promise<PeerOverview> {
return request<PeerHistoryView[]>(`/peers/${peerId}/history`, { 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, account: true,
}); });
} }
@@ -509,14 +556,8 @@ export function getMessageMedia(
}); });
} }
export function searchMessages( export function searchMessages(query: SearchQuery): Promise<SearchPage> {
query: string, return request<SearchPage>("/search", { account: true, query: { ...query } });
options: Page & { chat_id?: number } = {}
): Promise<SearchHit[]> {
return request<SearchHit[]>("/search", {
account: true,
query: { query, ...options },
});
} }
export function fetchMedia( export function fetchMedia(
@@ -547,46 +588,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( export function listAnnotations(
options: { chatId?: number; messageId?: number } = {} options: { chatId?: number; messageId?: number } = {}
): Promise<Annotation[]> { ): Promise<Annotation[]> {
+31
View File
@@ -0,0 +1,31 @@
import { request } from "#lib/api/client.js";
import type { Annotation } from "#lib/api/types.js";
export interface Note extends Annotation {
chat_has_avatar: boolean;
chat_title: string | null;
message_date: string | null;
message_deleted: boolean;
message_has_media: boolean;
message_text: string | null;
}
export interface NoteTag {
count: number;
tag: string;
}
export function listNotes(
tag: string | null,
offset: number,
limit: number
): Promise<Note[]> {
return request<Note[]>("/annotations/notes", {
account: true,
query: { limit, offset, tag },
});
}
export function listNoteTags(): Promise<NoteTag[]> {
return request<NoteTag[]>("/annotations/tags", { account: true });
}
+151 -30
View File
@@ -1,3 +1,5 @@
import type { LiveAlertEvent } from "#lib/api/watches.js";
export type ChatKind = "private" | "group"; export type ChatKind = "private" | "group";
export type PolicyScopeType = export type PolicyScopeType =
| "default_dm" | "default_dm"
@@ -5,7 +7,26 @@ export type PolicyScopeType =
| "default_channel" | "default_channel"
| "folder" | "folder"
| "chat"; | "chat";
export type SearchSource = "text" | "stt"; export type SearchSource = "text" | "stt" | "version" | "data";
export type FoundWhere =
| "text"
| "button"
| "link"
| "forward"
| "reply"
| "file"
| "preview"
| "data";
export interface SearchFound {
button: string | null;
path: string;
value: string;
where: FoundWhere;
}
export type SearchChatKind = "private" | "group" | "channel";
export type JobStatus = export type JobStatus =
| "pending" | "pending"
| "running" | "running"
@@ -41,6 +62,8 @@ export interface Chat {
is_contact: boolean; is_contact: boolean;
kind: ChatKind; kind: ChatKind;
last_date: string | null; last_date: string | null;
last_deleted: boolean;
last_message_id: number | null;
last_sender_id: number | null; last_sender_id: number | null;
last_text: string | null; last_text: string | null;
message_count: number; message_count: number;
@@ -192,6 +215,7 @@ export interface MessageView {
entities: EntityView[]; entities: EntityView[];
forward: ForwardView | null; forward: ForwardView | null;
has_media: boolean; has_media: boolean;
has_note: boolean;
inline_buttons: InlineButton[][]; inline_buttons: InlineButton[][];
is_animated_emoji: boolean; is_animated_emoji: boolean;
is_self_destruct: boolean; is_self_destruct: boolean;
@@ -220,6 +244,53 @@ export interface MessageVersion {
text: string | null; text: string | null;
} }
export type FeedKind = "all" | "deleted" | "edited" | "profile";
export type FeedChatKind = "dm" | "group" | "channel";
export interface FeedChat {
chat_id: number;
has_avatar: boolean;
kind: FeedChatKind;
title: string | null;
}
export interface FeedItem {
after: string | null;
at: string;
before: string | null;
chat: FeedChat;
detail: Record<string, [string | null, string | null]> | null;
edit_date: string | null;
has_before: boolean;
id: number;
kind: Exclude<FeedKind, "all">;
message: MessageView | null;
message_count: number;
message_id: number;
outgoing: boolean;
sender_id: number | null;
sender_name: string | null;
since: string;
}
export interface FeedPage {
items: FeedItem[];
next_cursor: string | null;
}
export interface FeedUnseen {
count: number;
deleted: number;
edited: number;
latest: FeedItem | null;
seen_at: string | null;
}
export interface FeedMessagesPage {
messages: MessageView[];
next_cursor: string | null;
}
export interface MediaVersion { export interface MediaVersion {
file_size: number | null; file_size: number | null;
id: number; id: number;
@@ -232,11 +303,40 @@ export interface MediaVersion {
export interface SearchHit { export interface SearchHit {
chat_id: number; chat_id: number;
date: string; date: string;
deleted_at: string | null;
edited_at: string | null;
extracted_text: string | null; extracted_text: string | null;
found: SearchFound | null;
has_media: boolean;
media_kind: string | null;
message_id: number; message_id: number;
sender_id: number | null; sender_id: number | null;
source: SearchSource; source: SearchSource;
text: string | null; text: string | null;
version_at: string | null;
version_text: string | null;
}
export interface SearchPage {
hits: SearchHit[];
next_cursor: string | null;
scanned_to: string | null;
terms: string[];
}
export interface SearchQuery {
chat_id?: number;
cursor?: string;
date_from?: string;
date_to?: string;
deleted?: boolean;
edited?: boolean;
everywhere?: boolean;
has_media?: boolean;
kind?: SearchChatKind;
limit?: number;
query?: string;
sender_id?: number;
} }
export interface MediaView { export interface MediaView {
@@ -449,14 +549,54 @@ export interface ReadInfo {
voice: boolean; voice: boolean;
} }
export interface PeerHistoryView { export type ProfileField = "name" | "username" | "phone" | "deleted" | "title";
first_name: string | null;
is_deleted_account: boolean; export interface ProfileChange {
last_name: string | null; actor_id: number | null;
observed_at: string; changed_at: string;
phone: string | null; field: ProfileField;
photo_unique_id: string | null; message_id: number | null;
username: string | 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 { export interface StoryView {
@@ -543,26 +683,6 @@ export interface Folder {
title: string; 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 { export interface JobView {
account_id: number; account_id: number;
attempts: number; attempts: number;
@@ -607,7 +727,8 @@ export type LiveEvent =
| LiveMessageEvent | LiveMessageEvent
| LiveDeleteEvent | LiveDeleteEvent
| LivePresenceEvent | LivePresenceEvent
| LiveReceiptEvent; | LiveReceiptEvent
| LiveAlertEvent;
export interface FileShare { export interface FileShare {
account_id: number; 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`);
}
@@ -2,6 +2,7 @@
import type { MessageView } from "#lib/api/types.js"; import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { accountName, peerName } from "#lib/format/peer.js"; import { accountName, peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
@@ -16,10 +17,12 @@
function nameOf(id: number | null): string { function nameOf(id: number | null): string {
if (id === null) { if (id === null) {
return "Someone"; return t("service.someone");
} }
if (id === ownId) { if (id === ownId) {
return accounts.selected ? accountName(accounts.selected) : "You"; return accounts.selected
? accountName(accounts.selected)
: t("service.you");
} }
const peer = peers.get(id); const peer = peers.get(id);
return peer ? peerName(peer) : String(id); return peer ? peerName(peer) : String(id);
@@ -44,35 +47,39 @@
const selfJoin = const selfJoin =
members.length === 1 && members[0] === message.sender_id; members.length === 1 && members[0] === message.sender_id;
if (selfJoin) { if (selfJoin) {
return `${actor} joined the group`; return t("service.joined", { actor });
} }
const names = members.map(nameOf).join(", "); const names = members.map(nameOf).join(", ");
return names ? `${actor} added ${names}` : `${actor} joined the group`; return names
? t("service.added", { actor, names })
: t("service.joined", { actor });
} }
case "left_chat_members": case "left_chat_members":
return `${actor} left the group`; return t("service.left", { actor });
case "pinned_message": case "pinned_message":
return `${actor} pinned a message`; return t("service.pinned", { actor });
case "group_chat_created": case "group_chat_created":
return `${actor} created the group`; return t("service.groupCreated", { actor });
case "channel_chat_created": case "channel_chat_created":
return "Channel created"; return t("service.channelCreated");
case "phone_call_ended": case "phone_call_ended":
return service.duration === null return service.duration === null
? "Call ended" ? t("service.callEnded")
: `Call ended · ${durationOf(service.duration)}`; : t("service.callEndedAfter", {
duration: durationOf(service.duration),
});
case "phone_call_started": case "phone_call_started":
return "Call started"; return t("service.callStarted");
case "poll_option_added": case "poll_option_added":
return `${actor} added a poll option`; return t("service.pollOption", { actor });
case "new_chat_title": case "new_chat_title":
return `${actor} changed the group name`; return t("service.titleChanged", { actor });
case "new_chat_photo": case "new_chat_photo":
return `${actor} changed the group photo`; return t("service.photoChanged", { actor });
case "delete_chat_photo": case "delete_chat_photo":
return `${actor} removed the group photo`; return t("service.photoRemoved", { actor });
default: default:
return service.kind.replace(/_/g, " ") || "Service message"; return t("service.unknown");
} }
}); });
@@ -0,0 +1,44 @@
<script lang="ts">
import { Popover } from "bits-ui";
import ChatCalendar from "#lib/components/profile/ChatCalendar.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
anchor: HTMLElement | null;
chatId: number;
month: { month: number; year: number } | null;
open: boolean;
}
let { anchor, chatId, month, open = $bindable() }: Props = $props();
</script>
<Popover.Root bind:open>
<Popover.Portal>
<Popover.Content
aria-label={t("chat.calendar")}
class="bg-menu-content calendar-popover"
customAnchor={anchor}
side="bottom"
sideOffset={6}
>
{#key month}
<ChatCalendar
{chatId}
initial={month}
onjump={() => {
open = false;
}}
/>
{/key}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<style lang="scss">
:global(.calendar-popover) {
width: min(20rem, calc(100vw - 1.5rem));
padding: 0;
background-color: var(--color-background);
}
</style>
+74 -15
View File
@@ -8,14 +8,17 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { peerName } from "#lib/format/peer.js"; import { peerName } from "#lib/format/peer.js";
import { formatPresence, isOnline } from "#lib/format/presence.js"; import { formatPresence, isOnline } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
import { withoutMessage } from "#lib/stores/feed.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { presence } from "#lib/stores/presence.svelte.js"; import { presence } from "#lib/stores/presence.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state";
interface Props { interface Props {
chatId: number; chatId: number;
@@ -26,8 +29,11 @@
const isDm = $derived(chatId > 0); const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId)); const chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId)); const discovered = $derived(discover.get(chatId));
const DONE_DURATION = 2500;
let peer = $state<PeerView | null>(null); let peer = $state<PeerView | null>(null);
let backfilling = $state(false); let backfilling = $state(false);
let backfilled = $state(false);
async function backfill() { async function backfill() {
if (backfilling) { if (backfilling) {
@@ -36,14 +42,27 @@
backfilling = true; backfilling = true;
try { try {
await enqueueBackfill(chatId, true); await enqueueBackfill(chatId, true);
toasts.success("Догружаем новые сообщения"); backfilled = true;
setTimeout(() => {
backfilled = false;
}, DONE_DURATION);
} catch { } catch {
toasts.error("Не удалось запустить бэкфилл"); toasts.error(t("chat.backfill.failed"));
} finally { } finally {
backfilling = false; backfilling = false;
} }
} }
function back() {
if (page.state.inApp) {
history.back();
return;
}
const target = new URL(page.url.href);
target.pathname = "/app";
goto(withoutMessage(target));
}
$effect(() => { $effect(() => {
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
return; return;
@@ -82,7 +101,9 @@
const fallbackTitle = $derived( const fallbackTitle = $derived(
chat?.title ?? chat?.title ??
discovered?.title ?? discovered?.title ??
(isDm ? "Удалённый аккаунт" : `Chat ${chatId}`) (isDm
? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: chatId }))
); );
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle); const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
const subtitle = $derived.by(() => { const subtitle = $derived.by(() => {
@@ -97,9 +118,11 @@
} }
const count = chat?.message_count ?? discovered?.message_count ?? 0; const count = chat?.message_count ?? discovered?.message_count ?? 0;
if (count > 0) { if (count > 0) {
return `${count} messages`; return t("chat.messages", { count });
} }
return discovered?.kind === "channel" ? "channel" : "group"; return discovered?.kind === "channel"
? t("chat.kind.channel")
: t("chat.kind.group");
}); });
const avatarKind = $derived(isDm ? "peer" : "chat"); const avatarKind = $derived(isDm ? "peer" : "chat");
const hasAvatar = $derived( const hasAvatar = $derived(
@@ -114,8 +137,8 @@
<header class="chat-header"> <header class="chat-header">
<div class="back"> <div class="back">
<Button <Button
aria-label="Назад" aria-label={t("common.back")}
onclick={() => goto("/app")} onclick={back}
round round
smaller smaller
variant="translucent" variant="translucent"
@@ -129,7 +152,7 @@
})} })}
<button <button
{...props} {...props}
aria-label="Открыть профиль" aria-label={t("chat.openProfile")}
class="peek" class="peek"
onclick={() => ui.openPanel("profile")} onclick={() => ui.openPanel("profile")}
type="button" type="button"
@@ -157,23 +180,36 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")} <ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")}
>Открыть профиль</ContextMenuItem >{t("chat.openProfile")}</ContextMenuItem
>
<ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")}
>{t("feed.changes")}</ContextMenuItem
>
<ContextMenuItem icon="stats" onselect={() => ui.openPanel("presence")}
>{t("chat.analytics")}</ContextMenuItem
>
<ContextMenuItem
icon={backfilled ? "check" : "cloud-download"}
onselect={backfill}
>{backfilled
? t("chat.backfill.started")
: t("chat.backfill")}</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="play-story" icon="play-story"
onselect={() => ui.openPanel("stories")} onselect={() => ui.openPanel("stories")}
>Сторис</ContextMenuItem >{t("settings.stories")}</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="settings" icon="settings"
onselect={() => policyUi.edit({ chatId }, title)} onselect={() => policyUi.edit({ chatId }, title)}
>Политика захвата</ContextMenuItem >{t("settings.policy")}</ContextMenuItem
> >
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
<div class="actions"> <div class="actions">
<Button <Button
aria-label="Поиск в чате" aria-label={t("chat.search")}
onclick={() => ui.openPanel("search")} onclick={() => ui.openPanel("search")}
round round
smaller smaller
@@ -182,7 +218,17 @@
<Icon name="search" /> <Icon name="search" />
</Button> </Button>
<Button <Button
aria-label="Аналитика" aria-label={t("feed.changes")}
class="wide-only"
onclick={() => ui.openPanel("changes")}
round
smaller
variant="translucent"
>
<Icon name="recent" />
</Button>
<Button
aria-label={t("chat.analytics")}
onclick={() => ui.openPanel("presence")} onclick={() => ui.openPanel("presence")}
round round
smaller smaller
@@ -191,14 +237,16 @@
<Icon name="stats" /> <Icon name="stats" />
</Button> </Button>
<Button <Button
aria-label="Догрузить новые сообщения" aria-label={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
class="wide-only"
loading={backfilling} loading={backfilling}
onclick={backfill} onclick={backfill}
round round
smaller smaller
title={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
variant="translucent" variant="translucent"
> >
<Icon name="cloud-download" /> <Icon name={backfilled ? "check" : "cloud-download"} />
</Button> </Button>
</div> </div>
</header> </header>
@@ -250,6 +298,12 @@
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
margin-left: auto; margin-left: auto;
@media (max-width: 600px) {
:global(.wide-only) {
display: none;
}
}
} }
.title { .title {
@@ -262,8 +316,13 @@
} }
.subtitle { .subtitle {
overflow: hidden;
display: block;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
&.online { &.online {
color: var(--color-primary); color: var(--color-primary);
+32 -4
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import ChatListItem from "#lib/components/ChatListItem.svelte"; import ChatListItem from "#lib/components/ChatListItem.svelte";
import FeedRow from "#lib/components/feed/FeedRow.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Skeleton from "#lib/components/ui/Skeleton.svelte"; import Skeleton from "#lib/components/ui/Skeleton.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js"; import { folders } from "#lib/stores/folders.svelte.js";
@@ -46,7 +48,7 @@
return; return;
} }
untrack(() => folders.load()).catch(() => untrack(() => folders.load()).catch(() =>
toasts.error("Failed to load folders") toasts.error(t("chats.foldersFailed"))
); );
}); });
@@ -60,7 +62,7 @@
scrollTop = 0; scrollTop = 0;
} }
untrack(() => chats.load(folderId)).catch(() => untrack(() => chats.load(folderId)).catch(() =>
toasts.error("Failed to load chats") toasts.error(t("chats.loadFailed"))
); );
}); });
@@ -74,6 +76,27 @@
} }
}); });
$effect(() => {
const id = activeChatId;
untrack(() => {
const index = list.findIndex((chat) => chat.chat_id === id);
if (index === -1 || viewport === null) {
return;
}
const row = viewport.querySelector<HTMLElement>(".Chat.selected");
if (row) {
row.scrollIntoView({ block: "nearest" });
return;
}
const top = index * rowHeight;
if (top < viewport.scrollTop) {
viewport.scrollTop = top;
} else if (top + rowHeight > viewport.scrollTop + viewport.clientHeight) {
viewport.scrollTop = top + rowHeight - viewport.clientHeight;
}
});
});
function measure(el: HTMLElement) { function measure(el: HTMLElement) {
({ scrollTop } = el); ({ scrollTop } = el);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) { if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
@@ -99,6 +122,9 @@
bind:this={viewport} bind:this={viewport}
bind:clientHeight={viewportHeight} bind:clientHeight={viewportHeight}
> >
{#if folders.selectedId === null}
<FeedRow />
{/if}
{#if chats.loading && list.length === 0} {#if chats.loading && list.length === 0}
{#each skeletonRows as index (index)} {#each skeletonRows as index (index)}
<div class="row-skeleton"> <div class="row-skeleton">
@@ -113,8 +139,10 @@
<EmptyState <EmptyState
description={folders.selectedId === null description={folders.selectedId === null
? undefined ? undefined
: "No chats match this folder yet"} : t("chats.emptyFolderHint")}
title={folders.selectedId === null ? "No chats yet" : "Empty folder"} title={folders.selectedId === null
? t("chats.empty")
: t("chats.emptyFolder")}
/> />
{:else} {:else}
<div style:padding-bottom="{padBottom}px" style:padding-top="{padTop}px"> <div style:padding-bottom="{padBottom}px" style:padding-top="{padTop}px">
+75 -16
View File
@@ -4,8 +4,11 @@
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { online } from "#lib/stores/online.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -26,8 +29,18 @@
const title = $derived( const title = $derived(
chat.title ?? chat.title ??
(chat.chat_id > 0 ? "Удалённый аккаунт" : `Chat ${chat.chat_id}`) (chat.chat_id > 0
? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: chat.chat_id }))
); );
const person = $derived(chat.chat_id > 0 && !chat.is_bot);
const isOnline = $derived(person && online.isOnline(chat.chat_id));
$effect(() => {
if (person) {
online.watch(chat.chat_id);
}
});
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat"); const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const showSender = $derived( const showSender = $derived(
@@ -45,7 +58,7 @@
return ""; return "";
} }
if (chat.last_sender_id === ownId) { if (chat.last_sender_id === ownId) {
return "You: "; return `${t("chat.you")}: `;
} }
const peer = peers.get(chat.last_sender_id as number); const peer = peers.get(chat.last_sender_id as number);
if (!peer) { if (!peer) {
@@ -55,7 +68,7 @@
}); });
const preview = $derived( const preview = $derived(
chat.last_text ?? (chat.message_count > 0 ? "Media" : "") chat.last_text ?? (chat.message_count > 0 ? t("chat.media") : "")
); );
</script> </script>
@@ -71,12 +84,14 @@
class:selected class:selected
use:ripple use:ripple
> >
<Avatar <span class="avatar" class:online={isOnline}>
avatar={{ id: chat.chat_id, kind: avatarKind }} <Avatar
colorKey={chat.chat_id} avatar={{ id: chat.chat_id, kind: avatarKind }}
hasAvatar={chat.has_avatar} colorKey={chat.chat_id}
name={title} hasAvatar={chat.has_avatar}
/> name={title}
/>
</span>
<div class="info"> <div class="info">
<div class="info-row"> <div class="info-row">
<h3 class="title">{title}</h3> <h3 class="title">{title}</h3>
@@ -87,7 +102,13 @@
{#if senderPrefix} {#if senderPrefix}
<span class="sender">{senderPrefix}</span> <span class="sender">{senderPrefix}</span>
{/if} {/if}
{preview} {#if chat.last_deleted}
<span class="deleted-mark" title={t("chat.deletedPreview")}
><Icon name="delete" size="1rem" /></span
><s class="deleted-text">{preview}</s>
{:else}
{preview}
{/if}
</span> </span>
</div> </div>
</div> </div>
@@ -95,24 +116,27 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onclick} <ContextMenuItem icon="open-in-new-tab" onselect={onclick}
>Открыть</ContextMenuItem >{t("chat.open")}</ContextMenuItem
> >
<ContextMenuItem icon="info" onselect={() => openWith("profile")} <ContextMenuItem icon="info" onselect={() => openWith("profile")}
>Профиль</ContextMenuItem >{t("chat.profile")}</ContextMenuItem
> >
<ContextMenuItem icon="search" onselect={() => openWith("search")} <ContextMenuItem icon="search" onselect={() => openWith("search")}
>Поиск в чате</ContextMenuItem >{t("chat.search")}</ContextMenuItem
>
<ContextMenuItem icon="recent" onselect={() => openWith("changes")}
>{t("feed.changes")}</ContextMenuItem
> >
<ContextMenuItem icon="stats" onselect={() => openWith("presence")} <ContextMenuItem icon="stats" onselect={() => openWith("presence")}
>Аналитика</ContextMenuItem >{t("chat.analytics")}</ContextMenuItem
> >
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")} <ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
>Сторис</ContextMenuItem >{t("settings.stories")}</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="settings" icon="settings"
onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)} onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)}
>Политика захвата</ContextMenuItem >{t("settings.policy")}</ContextMenuItem
> >
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
@@ -141,11 +165,15 @@
@media (hover: hover) { @media (hover: hover) {
&:hover { &:hover {
--online-ring: var(--color-chat-hover);
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
} }
&.selected { &.selected {
--online-ring: var(--color-chat-active);
background-color: var(--color-chat-active); background-color: var(--color-chat-active);
.title, .title,
@@ -157,6 +185,37 @@
} }
} }
.avatar {
position: relative;
display: flex;
flex-shrink: 0;
&.online::after {
content: "";
position: absolute;
right: 0.0625rem;
bottom: 0.0625rem;
width: 0.875rem;
height: 0.875rem;
border: 2px solid var(--online-ring, var(--color-background));
border-radius: 50%;
background-color: var(--color-green);
}
}
.deleted-mark {
display: inline-flex;
margin-inline-end: 0.1875rem;
vertical-align: -0.1875rem;
}
.deleted-text {
text-decoration: line-through;
}
.info { .info {
overflow: hidden; overflow: hidden;
flex: 1; flex: 1;
+49 -2
View File
@@ -8,14 +8,16 @@
jumboEmojiCount, jumboEmojiCount,
linkHref, linkHref,
} from "#lib/format/entities.js"; } from "#lib/format/entities.js";
import { markText } from "#lib/format/search.js";
interface Props { interface Props {
entities: EntityView[]; entities: EntityView[];
marks?: string[];
own?: boolean; own?: boolean;
text: string; text: string;
} }
let { text, entities, own = false }: Props = $props(); let { text, entities, marks = [], own = false }: Props = $props();
const nodes = $derived(buildEntityTree(text, entities)); const nodes = $derived(buildEntityTree(text, entities));
const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0); const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0);
@@ -32,13 +34,29 @@
)} )}
{#each items as node, i (i)} {#each items as node, i (i)}
{#if node.kind === "text"} {#if node.kind === "text"}
{node.text} {@render plain(node.text)}
{:else} {:else}
{@render entity(node)} {@render entity(node)}
{/if} {/if}
{/each} {/each}
{/snippet} {/snippet}
{#snippet plain(
value: string
)}
{#if marks.length === 0}
{value}
{:else}
{#each markText(value, marks) as segment, i (i)}
{#if segment.hit}
<mark class="found" class:own>{segment.text}</mark>
{:else}
{segment.text}
{/if}
{/each}
{/if}
{/snippet}
{#snippet entity( {#snippet entity(
node: EntityNode node: EntityNode
)} )}
@@ -186,6 +204,35 @@
background-color: var(--color-primary-tint); background-color: var(--color-primary-tint);
} }
.found {
padding: 0;
border-radius: 0.25rem;
color: inherit;
background-color: transparent;
animation: found-flash 3s ease;
--found-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
&.own {
--found-color: color-mix(
in srgb,
var(--color-own-links) 35%,
transparent
);
}
}
@keyframes found-flash {
0%,
70% {
background-color: var(--found-color);
}
100% {
background-color: transparent;
}
}
.spoiler { .spoiler {
cursor: pointer; cursor: pointer;
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js"; import { folders } from "#lib/stores/folders.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
@@ -10,7 +11,7 @@
} }
const tabs = $derived<Tab[]>([ const tabs = $derived<Tab[]>([
{ id: null, title: "All Chats" }, { id: null, title: t("folders.all") },
...folders.list.map((folder) => ({ ...folders.list.map((folder) => ({
id: folder.folder_id, id: folder.folder_id,
title: folder.title, title: folder.title,
@@ -93,7 +94,7 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}> <ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
Политика захвата {t("settings.policy")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { ForwardView } from "#lib/api/types.js"; import type { ForwardView } from "#lib/api/types.js";
import { peerName } from "#lib/format/peer.js"; import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
interface Props { interface Props {
@@ -14,20 +15,20 @@
); );
const name = $derived.by(() => { const name = $derived.by(() => {
if (forward.kind === "channel") { if (forward.kind === "channel") {
return forward.chat_title ?? "Channel"; return forward.chat_title ?? t("forward.channel");
} }
if (forward.kind === "hidden") { if (forward.kind === "hidden") {
return forward.from_name ?? "Hidden account"; return forward.from_name ?? t("forward.hidden");
} }
if (peer) { if (peer) {
return peerName(peer); return peerName(peer);
} }
return forward.from_name ?? "Unknown"; return forward.from_name ?? t("forward.unknown");
}); });
</script> </script>
<div class="ForwardHeader"> <div class="ForwardHeader">
<span class="label">Forwarded from</span> <span class="label">{t("forward.from")}</span>
<span class="name">{name}</span> <span class="name">{name}</span>
{#if forward.signature} {#if forward.signature}
<span class="signature">({forward.signature})</span> <span class="signature">({forward.signature})</span>
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import { tick } from "svelte";
import { chats } from "#lib/stores/chats.svelte.js";
import { search } from "#lib/stores/search.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const EDITABLE = "input, textarea, select, [contenteditable='true']";
const LAYERS = "[role='dialog'], [role='alertdialog'], [role='menu']";
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
async function focusIn(selector: string) {
await tick();
document.querySelector<HTMLInputElement>(selector)?.focus();
}
async function focusSearch() {
if (ui.leftView === "settings") {
ui.closeSettings();
}
await focusIn("#LeftColumn .left-header input");
}
function stepChat(step: number) {
const { list } = chats;
const index = list.findIndex((chat) => chat.chat_id === chatId);
if (index === -1 && step < 0) {
return;
}
const target = index === -1 ? list[0] : list[index + step];
if (target) {
goto(`/app/${target.chat_id}`);
}
}
function closeTop(editable: HTMLElement | null) {
if (editable) {
if (editable.closest("#LeftColumn .left-header")) {
search.close();
}
editable.blur();
} else if (ui.rightPanel !== null) {
ui.closePanel();
} else if (ui.leftView === "settings") {
ui.closeSettings();
} else if (chatId !== null) {
goto("/app");
}
}
function onCommand(event: KeyboardEvent): boolean {
const key = event.key.toLowerCase();
if (key === "k") {
focusSearch();
return true;
}
if (key === "f" && chatId !== null) {
ui.openPanel("search");
focusIn("#RightColumn input");
return true;
}
return false;
}
function onPlain(event: KeyboardEvent): boolean {
if (event.key === "/" && !event.altKey) {
focusSearch();
return true;
}
if (
event.altKey &&
(event.key === "ArrowUp" || event.key === "ArrowDown")
) {
stepChat(event.key === "ArrowUp" ? -1 : 1);
return true;
}
return false;
}
function onkeydown(event: KeyboardEvent) {
if (event.defaultPrevented || document.querySelector(LAYERS)) {
return;
}
const target = event.target as HTMLElement | null;
const editable = target?.closest<HTMLElement>(EDITABLE) ?? null;
const modified = event.ctrlKey || event.metaKey;
if (event.key === "Escape") {
closeTop(editable);
return;
}
const handled = modified
? !event.altKey && onCommand(event)
: editable === null && onPlain(event);
if (handled) {
event.preventDefault();
}
}
</script>
<svelte:window {onkeydown} />
@@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { InlineButton } from "#lib/api/types.js"; import type { InlineButton } from "#lib/api/types.js";
import { buttonHasMark } from "#lib/format/search.js";
import { safeHref } from "#lib/format/url.js"; import { safeHref } from "#lib/format/url.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
marks?: string[];
rows: InlineButton[][]; rows: InlineButton[][];
} }
let { rows }: Props = $props(); let { marks = [], rows }: Props = $props();
let copied = $state<string | null>(null); let copied = $state<string | null>(null);
let resetTimer: ReturnType<typeof setTimeout> | undefined; let resetTimer: ReturnType<typeof setTimeout> | undefined;
@@ -26,12 +29,14 @@
<div class="row"> <div class="row">
{#each row as button, colIndex (colIndex)} {#each row as button, colIndex (colIndex)}
{@const key = `${rowIndex}:${colIndex}`} {@const key = `${rowIndex}:${colIndex}`}
{@const lit = marks.length > 0 && buttonHasMark(button, marks)}
{#if button.kind === "url" && button.url} {#if button.kind === "url" && button.url}
<a <a
class="button" class="button"
href={safeHref(button.url)} href={safeHref(button.url)}
rel="noopener" rel="noopener"
target="_blank" target="_blank"
class:found={lit}
> >
<span class="label">{button.text}</span> <span class="label">{button.text}</span>
<span class="corner">↗</span> <span class="corner">↗</span>
@@ -41,13 +46,14 @@
class="button" class="button"
onclick={() => copy(key, button.data ?? "")} onclick={() => copy(key, button.data ?? "")}
type="button" type="button"
class:found={lit}
> >
<span class="label" <span class="label"
>{copied === key ? "Скопировано" : button.text}</span >{copied === key ? t("inline.copied") : button.text}</span
> >
</button> </button>
{:else} {:else}
<span class="button static"> <span class="button static" class:found={lit}>
<span class="label">{button.text}</span> <span class="label">{button.text}</span>
</span> </span>
{/if} {/if}
@@ -103,6 +109,27 @@
} }
} }
.found {
animation: found-flash 3s ease;
}
@keyframes found-flash {
0%,
70% {
background-color: color-mix(
in srgb,
var(--color-primary) 30%,
var(--color-message-reaction)
);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
100% {
background-color: var(--color-message-reaction);
box-shadow: inset 0 0 0 1px transparent;
}
}
.label { .label {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { auth } from "#lib/stores/auth.svelte.js"; import { auth } from "#lib/stores/auth.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -20,7 +21,7 @@
await accounts.load(); await accounts.load();
await goto("/app"); await goto("/app");
} catch { } catch {
toasts.error("Invalid token"); toasts.error(t("login.invalid"));
} finally { } finally {
busy = false; busy = false;
} }
@@ -33,16 +34,17 @@
<Icon name="lock" size="2.75rem" /> <Icon name="lock" size="2.75rem" />
</div> </div>
<h1>Beavergram</h1> <h1>Beavergram</h1>
<p class="subtitle">Enter your access token to continue.</p> <p class="subtitle">{t("login.subtitle")}</p>
<input <input
aria-label={t("login.token")}
autocomplete="current-password" autocomplete="current-password"
class="form-control" class="form-control"
placeholder="Access token" placeholder={t("login.token")}
type="password" type="password"
bind:value bind:value
> >
<Button disabled={busy || !value.trim()} loading={busy} type="submit"> <Button disabled={busy || !value.trim()} loading={busy} type="submit">
Log in {t("login.submit")}
</Button> </Button>
</form> </form>
</div> </div>
+53 -10
View File
@@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu } from "bits-ui"; import { DropdownMenu } from "bits-ui";
import AlertBadge from "#lib/components/alerts/AlertBadge.svelte";
import AccountAvatar from "#lib/components/settings/AccountAvatar.svelte"; import AccountAvatar from "#lib/components/settings/AccountAvatar.svelte";
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte"; import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { captureLine } from "#lib/format/capture.js";
import { accountName } from "#lib/format/peer.js"; import { accountName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js"; import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { capture } from "#lib/stores/capture.svelte.js";
import { theme } from "#lib/stores/theme.svelte.js"; import { theme } from "#lib/stores/theme.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -22,6 +25,7 @@
const entries: Entry[] = [ const entries: Entry[] = [
{ icon: "eye", label: "settings.watches", panel: "watches" }, { icon: "eye", label: "settings.watches", panel: "watches" },
{ icon: "unmute", label: "settings.alerts", panel: "alerts" }, { icon: "unmute", label: "settings.alerts", panel: "alerts" },
{ icon: "note", label: "notes.title", panel: "notes" },
{ icon: "data", label: "settings.storage", panel: "storage" }, { icon: "data", label: "settings.storage", panel: "storage" },
{ icon: "play-story", label: "settings.stories", panel: "stories-all" }, { icon: "play-story", label: "settings.stories", panel: "stories-all" },
{ icon: "cloud-download", label: "settings.jobs", panel: "jobs" }, { icon: "cloud-download", label: "settings.jobs", panel: "jobs" },
@@ -30,6 +34,12 @@
let adding = $state(false); let adding = $state(false);
const quiet = $derived(
capture.status !== null && !capture.status.live
? captureLine(capture.status, accounts.selected?.is_bot ?? false)
: null
);
function onkeydown(event: KeyboardEvent) { function onkeydown(event: KeyboardEvent) {
const match = DIGIT.exec(event.code); const match = DIGIT.exec(event.code);
if (!(event.altKey && match)) { if (!(event.altKey && match)) {
@@ -50,15 +60,22 @@
{#snippet child({ {#snippet child({
props, props,
})} })}
<Button <span class="menu-trigger">
{...props} <Button
aria-label={t("menu.open")} {...props}
round aria-label={quiet ? `${t("menu.open")}. ${quiet}` : t("menu.open")}
smaller round
variant="translucent" smaller
> title={quiet ?? undefined}
<Icon name="menu" /> variant="translucent"
</Button> >
<Icon name="menu" />
</Button>
<AlertBadge />
{#if quiet}
<span class="capture-dot"></span>
{/if}
</span>
{/snippet} {/snippet}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Portal> <DropdownMenu.Portal>
@@ -105,7 +122,12 @@
onSelect={() => ui.openPanel(entry.panel)} onSelect={() => ui.openPanel(entry.panel)}
> >
<Icon name={entry.icon} size="1.125rem" /> <Icon name={entry.icon} size="1.125rem" />
{t(entry.label)} {#if entry.panel === "alerts"}
<span class="account-name">{t(entry.label)}</span>
<AlertBadge inline />
{:else}
{t(entry.label)}
{/if}
</DropdownMenu.Item> </DropdownMenu.Item>
{/each} {/each}
<DropdownMenu.Separator class="bg-menu-separator" /> <DropdownMenu.Separator class="bg-menu-separator" />
@@ -127,6 +149,27 @@
<AddAccountDialog bind:open={adding} /> <AddAccountDialog bind:open={adding} />
<style lang="scss"> <style lang="scss">
.menu-trigger {
position: relative;
display: flex;
flex-shrink: 0;
}
.capture-dot {
pointer-events: none;
position: absolute;
right: 0.125rem;
bottom: 0.125rem;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--color-error);
box-shadow: 0 0 0 2px var(--color-background);
}
:global(.main-menu) { :global(.main-menu) {
width: 17rem; width: 17rem;
max-height: calc(100dvh - 5rem); max-height: calc(100dvh - 5rem);
+35 -19
View File
@@ -15,7 +15,9 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js"; import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -48,6 +50,7 @@
let loading = $state(false); let loading = $state(false);
let saving = $state(false); let saving = $state(false);
let token = 0; let token = 0;
const queued = createFlash<number>();
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : ""); const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
const isImage = $derived(mime.startsWith("image/") || kind === "photo"); const isImage = $derived(mime.startsWith("image/") || kind === "photo");
@@ -61,7 +64,9 @@
const canSave = $derived( const canSave = $derived(
currentMediaId !== null && (fileOnly || result?.state === "ready") currentMediaId !== null && (fileOnly || result?.state === "ready")
); );
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа"); const title = $derived(
fileName ?? mediaKindLabel(kind) ?? t("media.kind.other")
);
const caption = $derived(items[index]?.caption); const caption = $derived(items[index]?.caption);
function revoke() { function revoke() {
@@ -124,7 +129,7 @@
} }
} catch { } catch {
if (current === token) { if (current === token) {
toasts.error("Failed to load media"); toasts.error(t("media.loadFailed"));
} }
} finally { } finally {
if (current === token) { if (current === token) {
@@ -137,11 +142,12 @@
if (messageId === null) { if (messageId === null) {
return; return;
} }
const target = messageId;
try { try {
await fetchMedia(chatId, messageId); await fetchMedia(chatId, target);
toasts.success("Скачивание поставлено в очередь"); queued.show(target);
} catch { } catch {
toasts.error("Не удалось поставить в очередь"); toasts.error(t("media.queueFailed"));
} }
} }
@@ -153,7 +159,7 @@
try { try {
await downloadMedia(currentMediaId); await downloadMedia(currentMediaId);
} catch { } catch {
toasts.error("Не удалось скачать файл"); toasts.error(t("ui.downloadFailed"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -225,7 +231,7 @@
{#if canSave} {#if canSave}
{#if ontrash} {#if ontrash}
<button <button
aria-label="В корзину" aria-label={t("storage.toTrash")}
class="media-action" class="media-action"
onclick={trash} onclick={trash}
type="button" type="button"
@@ -239,7 +245,7 @@
})} })}
<button <button
{...props} {...props}
aria-label="Скачать файл" aria-label={t("ui.downloadFile")}
class="media-action" class="media-action"
onclick={save} onclick={save}
type="button" type="button"
@@ -249,15 +255,15 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="download" onselect={save}> <ContextMenuItem icon="download" onselect={save}>
Скачать файл {t("ui.downloadFile")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}> <ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке {t("ui.shareLink")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
<button <button
aria-label="Доступ по ссылке" aria-label={t("ui.shareLink")}
class="media-action" class="media-action"
onclick={share} onclick={share}
type="button" type="button"
@@ -265,7 +271,7 @@
<Icon name="allow-share" size="1.375rem" /> <Icon name="allow-share" size="1.375rem" />
</button> </button>
{/if} {/if}
<Dialog.Close aria-label="Закрыть" class="media-close"> <Dialog.Close aria-label={t("common.close")} class="media-close">
<Icon name="close" size="1.5rem" /> <Icon name="close" size="1.5rem" />
</Dialog.Close> </Dialog.Close>
</div> </div>
@@ -276,7 +282,7 @@
{#if loading} {#if loading}
<Spinner color="white" /> <Spinner color="white" />
{:else if result?.state === "ready" && isImage} {:else if result?.state === "ready" && isImage}
<img alt={kind} class="media-image" src={result.url}> <img alt={title} class="media-image" src={result.url}>
{:else if result?.state === "ready" && isVideo} {:else if result?.state === "ready" && isVideo}
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
@@ -301,24 +307,29 @@
{#if currentMediaId !== null} {#if currentMediaId !== null}
<button class="media-download" onclick={save} type="button"> <button class="media-download" onclick={save} type="button">
<Icon name="download" /> <Icon name="download" />
Скачать файл {t("ui.downloadFile")}
</button> </button>
{/if} {/if}
</div> </div>
{:else if result?.state === "not-downloaded"} {:else if result?.state === "not-downloaded"}
<div class="media-message"> <div class="media-message">
<p>Файл ещё не скачан в архив.</p> <p>{t("media.notDownloaded")}</p>
<Button fluid onclick={queueFetch} variant="primary"> <Button fluid onclick={queueFetch} variant="primary">
Скачать в архив {#if queued.current === messageId}
<Icon name="check" />
{t("chat.media.queued")}
{:else}
{t("media.downloadToArchive")}
{/if}
</Button> </Button>
</div> </div>
{:else if result?.state === "missing"} {:else if result?.state === "missing"}
<p class="media-message">Файл не найден.</p> <p class="media-message">{t("media.missing")}</p>
{/if} {/if}
</div> </div>
{#if hasNav} {#if hasNav}
<button <button
aria-label="Previous" aria-label={t("ui.previous")}
class="media-nav prev" class="media-nav prev"
disabled={index === 0} disabled={index === 0}
onclick={() => step(-1)} onclick={() => step(-1)}
@@ -327,7 +338,7 @@
<Icon name="previous" size="1.75rem" /> <Icon name="previous" size="1.75rem" />
</button> </button>
<button <button
aria-label="Next" aria-label={t("ui.next")}
class="media-nav next" class="media-nav next"
disabled={index === items.length - 1} disabled={index === items.length - 1}
onclick={() => step(1)} onclick={() => step(1)}
@@ -397,6 +408,11 @@
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-white); color: var(--color-white);
@media (max-width: 600px) {
top: auto;
bottom: 1.25rem;
}
} }
:global(.media-close) { :global(.media-close) {
+143 -65
View File
@@ -15,12 +15,16 @@
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import CopyItem from "#lib/components/ui/CopyItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import WebPage from "#lib/components/WebPage.svelte"; import WebPage from "#lib/components/WebPage.svelte";
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js"; import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
import { buttonHasMark, hasMark } from "#lib/format/search.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { notes } from "#lib/stores/notes.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { appear } from "#lib/transitions/appear.js"; import { appear } from "#lib/transitions/appear.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -28,7 +32,7 @@
interface Props { interface Props {
animate: boolean; animate: boolean;
firstInGroup: boolean; firstInGroup: boolean;
highlighted: boolean; highlight: string[] | null;
isGroupChat: boolean; isGroupChat: boolean;
lastInGroup: boolean; lastInGroup: boolean;
message: MessageView; message: MessageView;
@@ -44,7 +48,7 @@
isGroupChat, isGroupChat,
firstInGroup, firstInGroup,
lastInGroup, lastInGroup,
highlighted, highlight,
animate, animate,
onjump, onjump,
onmedia, onmedia,
@@ -52,6 +56,14 @@
}: Props = $props(); }: Props = $props();
const deleted = $derived(message.deleted_at !== null); const deleted = $derived(message.deleted_at !== null);
const marks = $derived(highlight ?? []);
const markedInside = $derived(
marks.length > 0 &&
(hasMark(message.text, marks) ||
message.inline_buttons.some((row) =>
row.some((button) => buttonHasMark(button, marks))
))
);
const hasText = $derived(Boolean(message.text)); const hasText = $derived(Boolean(message.text));
const special = $derived( const special = $derived(
Boolean( Boolean(
@@ -60,27 +72,47 @@
); );
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const senderChat = $derived(
message.sender_id !== null && message.sender_id < 0
? message.sender_id
: null
);
const sender = $derived( const sender = $derived(
message.sender_id === null ? undefined : peers.get(message.sender_id) message.sender_id === null || senderChat !== null
? undefined
: peers.get(message.sender_id)
); );
const senderName = $derived.by(() => { const senderName = $derived.by(() => {
if (own) { if (own) {
return accounts.selected ? accountName(accounts.selected) : "You"; return accounts.selected ? accountName(accounts.selected) : t("chat.you");
}
if (senderChat !== null) {
return chats.byId(senderChat)?.title ?? "";
} }
if (sender) { if (sender) {
return peerName(sender); return peerName(sender);
} }
return message.sender_id === null ? "" : String(message.sender_id); return message.sender_id === null ? "" : String(message.sender_id);
}); });
$effect(() => {
if (senderChat !== null) {
chats.ensure(senderChat);
}
});
const colorIndex = $derived(peerColorIndex(message.sender_id ?? 0)); const colorIndex = $derived(peerColorIndex(message.sender_id ?? 0));
const showName = $derived(isGroupChat && !own && firstInGroup); const showName = $derived(isGroupChat && !own && firstInGroup);
const avatarId = $derived(own ? ownId : message.sender_id); const avatarId = $derived(own ? ownId : message.sender_id);
const avatarHas = $derived( const avatarHas = $derived.by(() => {
own if (own) {
? ownId !== null && peers.get(ownId)?.has_avatar return ownId !== null && Boolean(peers.get(ownId)?.has_avatar);
: (sender?.has_avatar ?? false) }
); if (senderChat !== null) {
return chats.byId(senderChat)?.has_avatar ?? false;
}
return sender?.has_avatar ?? false;
});
const hasCallbacks = $derived( const hasCallbacks = $derived(
message.inline_buttons.some((row) => message.inline_buttons.some((row) =>
@@ -115,18 +147,16 @@
onjump(message.reply.message_id); onjump(message.reply.message_id);
} }
} }
async function copyText() { function messageLink(): string {
await navigator.clipboard.writeText(message.text ?? ""); return `${location.origin}/app/${message.chat_id}?message=${message.message_id}`;
toasts.success("Текст скопирован");
}
async function copyId() {
await navigator.clipboard.writeText(String(message.message_id));
toasts.success("ID скопирован");
} }
function openSenderProfile() { function openSenderProfile() {
if (message.sender_id !== null) { if (senderChat === message.chat_id) {
goto(`/app/${message.sender_id}`);
ui.openPanel("profile"); ui.openPanel("profile");
} else if (senderChat !== null) {
goto(`/app/${senderChat}`);
} else if (message.sender_id !== null) {
ui.openPerson(message.chat_id, message.sender_id);
} }
} }
</script> </script>
@@ -141,7 +171,7 @@
data-message-id={message.message_id} data-message-id={message.message_id}
class:deleted class:deleted
class:first-in-group={firstInGroup} class:first-in-group={firstInGroup}
class:highlighted class:highlighted={highlight !== null && !markedInside}
class:last-in-group={lastInGroup} class:last-in-group={lastInGroup}
class:own class:own
class:with-avatar={isGroupChat} class:with-avatar={isGroupChat}
@@ -150,20 +180,36 @@
{#if isGroupChat} {#if isGroupChat}
<div class="avatar-slot"> <div class="avatar-slot">
{#if lastInGroup && avatarId !== null} {#if lastInGroup && avatarId !== null}
<Avatar <button
avatar={{ id: avatarId, kind: "peer" }} aria-label={senderName}
colorKey={avatarId} class="sender-avatar"
hasAvatar={avatarHas} onclick={openSenderProfile}
name={senderName} type="button"
size={2.125} >
/> <Avatar
avatar={{
id: avatarId,
kind: senderChat === null ? "peer" : "chat",
}}
colorKey={avatarId}
hasAvatar={avatarHas}
name={senderName}
size={2.125}
/>
</button>
{/if} {/if}
</div> </div>
{/if} {/if}
<div class="message-content" class:has-appendix={lastInGroup}> <div class="message-content" class:has-appendix={lastInGroup}>
{#if showName} {#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}
{#if message.forward} {#if message.forward}
<ForwardHeader forward={message.forward} /> <ForwardHeader forward={message.forward} />
@@ -171,12 +217,6 @@
{#if message.reply} {#if message.reply}
<ReplyHeader {onjump} reply={message.reply} /> <ReplyHeader {onjump} reply={message.reply} />
{/if} {/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
deleted
</span>
{/if}
{#if message.poll} {#if message.poll}
<Poll poll={message.poll} /> <Poll poll={message.poll} />
{/if} {/if}
@@ -195,19 +235,26 @@
{own} {own}
/> />
{:else if message.has_media} {:else if message.has_media}
<MessageMedia {message} onopen={() => onmedia(0)} {own} /> <MessageMedia
items={menu}
{message}
onopen={() => onmedia(0)}
{onversions}
{own}
/>
{/if} {/if}
{/if} {/if}
{#if hasText} {#if hasText}
<div class="text"> <div class="text">
<EntityText <EntityText
entities={message.entities} entities={message.entities}
{marks}
{own} {own}
text={message.text ?? ""} text={message.text ?? ""}
/> />
</div> </div>
{:else if !(message.has_media || deleted || special)} {:else if !(message.has_media || deleted || special)}
<div class="text empty">(no text)</div> <div class="text empty">{t("feed.noText")}</div>
{/if} {/if}
{#if message.web_page} {#if message.web_page}
<WebPage {message} {onmedia} {own} /> <WebPage {message} {onmedia} {own} />
@@ -222,18 +269,23 @@
{#if linkCount > 0} {#if linkCount > 0}
<button class="detail-chip" onclick={openLinks} type="button"> <button class="detail-chip" onclick={openLinks} type="button">
<Icon name="link" size="0.875rem" /> <Icon name="link" size="0.875rem" />
{linkCount} {t("thread.links", { count: linkCount })}
{linkCount === 1 ? "ссылка" : "ссылки"}
</button> </button>
{/if} {/if}
<MessageMeta {message} {onversions} {own} /> <MessageMeta
{message}
noted={notes.has(message)}
onnote={openAnnotations}
{onversions}
{own}
/>
{#if message.inline_buttons.length} {#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} /> <InlineButtons {marks} rows={message.inline_buttons} />
{/if} {/if}
{#if hasCallbacks} {#if hasCallbacks}
<button class="detail-chip" onclick={openCallbacks} type="button"> <button class="detail-chip" onclick={openCallbacks} type="button">
<Icon name="bot-command" size="0.875rem" /> <Icon name="bot-command" size="0.875rem" />
Callback-данные {t("panel.callbacks")}
</button> </button>
{/if} {/if}
{#if lastInGroup} {#if lastInGroup}
@@ -252,42 +304,56 @@
<MessageReadItem {message} /> <MessageReadItem {message} />
{/if} {/if}
<ContextMenuItem icon="note" onselect={openAnnotations} <ContextMenuItem icon="note" onselect={openAnnotations}
>Заметки</ContextMenuItem >{t("panel.annotations")}</ContextMenuItem
> >
{#if message.reactions.length} {#if message.reactions.length}
<ContextMenuItem icon="heart" onselect={openReactions} <ContextMenuItem icon="heart" onselect={openReactions}
>Реакции</ContextMenuItem >{t("panel.reactions")}</ContextMenuItem
> >
{/if} {/if}
{#if linkCount > 0} {#if linkCount > 0}
<ContextMenuItem icon="link" onselect={openLinks}>Ссылки</ContextMenuItem> <ContextMenuItem icon="link" onselect={openLinks}
>{t("panel.links")}</ContextMenuItem
>
{/if} {/if}
{#if hasCallbacks} {#if hasCallbacks}
<ContextMenuItem icon="bot-command" onselect={openCallbacks} <ContextMenuItem icon="bot-command" onselect={openCallbacks}
>Callback-данные</ContextMenuItem >{t("panel.callbacks")}</ContextMenuItem
> >
{/if} {/if}
{#if message.edited_at} {#if message.edited_at}
<ContextMenuItem icon="edit" onselect={onversions} <ContextMenuItem icon="edit" onselect={onversions}
>Версии</ContextMenuItem >{t("panel.versions")}</ContextMenuItem
> >
{/if} {/if}
{#if (message.reply?.message_id ?? null) !== null} {#if (message.reply?.message_id ?? null) !== null}
<ContextMenuItem icon="reply" onselect={jumpToReply} <ContextMenuItem icon="reply" onselect={jumpToReply}
>Перейти к ответу</ContextMenuItem >{t("thread.toReply")}</ContextMenuItem
> >
{/if} {/if}
<CopyItem
done={t("chat.menu.linkCopied")}
icon="link"
label={t("chat.menu.copyLink")}
text={messageLink}
/>
{#if hasText} {#if hasText}
<ContextMenuItem icon="copy" onselect={copyText} <CopyItem
>Копировать текст</ContextMenuItem done={t("chat.menu.copied")}
> icon="copy"
label={t("thread.copyText")}
text={() => message.text ?? ""}
/>
{/if} {/if}
<ContextMenuItem icon="hashtag" onselect={copyId} <CopyItem
>Копировать ID</ContextMenuItem done={t("chat.menu.copied")}
> icon="hashtag"
label={t("thread.copyId")}
text={() => String(message.message_id)}
/>
{#if isGroupChat && !own && message.sender_id !== null} {#if isGroupChat && !own && message.sender_id !== null}
<ContextMenuItem icon="info" onselect={openSenderProfile} <ContextMenuItem icon="info" onselect={openSenderProfile}
>Профиль автора</ContextMenuItem >{t("thread.senderProfile")}</ContextMenuItem
> >
{/if} {/if}
{/snippet} {/snippet}
@@ -364,12 +430,35 @@
border-bottom-left-radius: 0; 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 { .sender-name {
overflow: hidden; overflow: hidden;
display: block;
max-width: 100%;
margin-bottom: 0.0625rem; margin-bottom: 0.0625rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
text-align: start;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -389,17 +478,6 @@
text-decoration: line-through; text-decoration: line-through;
} }
.deleted-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-error);
}
.detail-chip { .detail-chip {
display: inline-flex; display: inline-flex;
+274 -51
View File
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { tick, untrack } from "svelte"; import { tick, untrack } from "svelte";
import type { CaptureGap } from "#lib/api/capture.js";
import { listMessages } from "#lib/api/endpoints.js"; import { listMessages } from "#lib/api/endpoints.js";
import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js"; import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js";
import type { LiveEvent, MessageView } from "#lib/api/types.js"; import type { LiveEvent, MessageView } from "#lib/api/types.js";
import ActionMessage from "#lib/components/ActionMessage.svelte"; import ActionMessage from "#lib/components/ActionMessage.svelte";
import CalendarPopover from "#lib/components/CalendarPopover.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte"; import MediaViewer from "#lib/components/MediaViewer.svelte";
import MessageBubble from "#lib/components/MessageBubble.svelte"; import MessageBubble from "#lib/components/MessageBubble.svelte";
import MessageVersions from "#lib/components/MessageVersions.svelte"; import MessageVersions from "#lib/components/MessageVersions.svelte";
@@ -11,8 +13,12 @@
import TrackChat from "#lib/components/TrackChat.svelte"; import TrackChat from "#lib/components/TrackChat.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDay } from "#lib/format/datetime.js"; import { formatDay, formatSpanRange } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { capture } from "#lib/stores/capture.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js";
import { events } from "#lib/stores/events.svelte.js"; import { events } from "#lib/stores/events.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -31,6 +37,8 @@
const STICK_OFFSET = 9; const STICK_OFFSET = 9;
const IDLE_DELAY = 1500; const IDLE_DELAY = 1500;
const MAX_MESSAGES = 360; const MAX_MESSAGES = 360;
const SETTLE_DELAY = 400;
const HIGHLIGHT_MS = 3000;
let messages = $state<MessageView[]>([]); let messages = $state<MessageView[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -42,6 +50,7 @@
let suppressAppear = $state(false); let suppressAppear = $state(false);
let scrolling = $state(false); let scrolling = $state(false);
let stuckDay = $state<string | null>(null); let stuckDay = $state<string | null>(null);
let passedDays = $state.raw(new Set<string>());
let idleTimer: ReturnType<typeof setTimeout> | null = null; let idleTimer: ReturnType<typeof setTimeout> | null = null;
let mediaOpen = $state(false); let mediaOpen = $state(false);
@@ -49,16 +58,25 @@
let mediaIndex = $state(0); let mediaIndex = $state(0);
let versionsOpen = $state(false); let versionsOpen = $state(false);
let versionsMessageId = $state<number | null>(null); let versionsMessageId = $state<number | null>(null);
let highlightId = $state<number | null>(null); let highlight = $state<{ id: number; marks: string[] } | null>(null);
let calendarOpen = $state(false);
let calendarAnchor = $state<HTMLElement | null>(null);
let calendarMonth = $state<{ month: number; year: number } | null>(null);
let highlightTimer: ReturnType<typeof setTimeout> | null = null; let highlightTimer: ReturnType<typeof setTimeout> | null = null;
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const isGroupChat = $derived(chatId < 0); const isChannel = $derived(
Boolean(chats.byId(chatId)?.is_broadcast) ||
discover.get(chatId)?.kind === "channel"
);
const isGroupChat = $derived(chatId < 0 && !isChannel);
interface Row { interface Row {
dayKey: string; dayKey: string;
daySeparator: string | null; daySeparator: string | null;
firstInGroup: boolean; firstInGroup: boolean;
gapsAfter: CaptureGap[];
gapsBefore: CaptureGap[];
lastInGroup: boolean; lastInGroup: boolean;
message: MessageView; message: MessageView;
own: boolean; own: boolean;
@@ -68,6 +86,27 @@
return new Date(iso).toDateString(); return new Date(iso).toDateString();
} }
const gapSpans = $derived(
capture.gaps.map((gap) => ({
day: dayKey(gap.start),
end: Date.parse(gap.end),
gap,
start: Date.parse(gap.start),
}))
);
function gapsBetween(
prev: MessageView | undefined,
message: MessageView
): typeof gapSpans {
if (!prev || gapSpans.length === 0) {
return [];
}
const from = Date.parse(prev.date);
const to = Date.parse(message.date);
return gapSpans.filter((span) => span.start >= from && span.end <= to);
}
const rows = $derived.by<Row[]>(() => { const rows = $derived.by<Row[]>(() => {
const out: Row[] = []; const out: Row[] = [];
for (let i = 0; i < messages.length; i += 1) { for (let i = 0; i < messages.length; i += 1) {
@@ -75,15 +114,28 @@
const prev = messages[i - 1]; const prev = messages[i - 1];
const next = messages[i + 1]; const next = messages[i + 1];
const day = dayKey(message.date); const day = dayKey(message.date);
const gaps = gapsBetween(prev, message);
const samePrevDay = prev ? dayKey(prev.date) === day : false; const samePrevDay = prev ? dayKey(prev.date) === day : false;
const sameNextDay = next ? dayKey(next.date) === day : false; const sameNextDay = next ? dayKey(next.date) === day : false;
out.push({ out.push({
dayKey: day, dayKey: day,
daySeparator: samePrevDay ? null : formatDay(message.date), daySeparator: samePrevDay ? null : formatDay(message.date),
firstInGroup: firstInGroup:
!prev || prev.sender_id !== message.sender_id || !samePrevDay, !prev ||
prev.sender_id !== message.sender_id ||
!samePrevDay ||
gaps.length > 0,
gapsAfter: gaps
.filter((span) => span.day === day)
.map((span) => span.gap),
gapsBefore: gaps
.filter((span) => span.day !== day)
.map((span) => span.gap),
lastInGroup: lastInGroup:
!next || next.sender_id !== message.sender_id || !sameNextDay, !next ||
next.sender_id !== message.sender_id ||
!sameNextDay ||
gapsBetween(message, next).length > 0,
message, message,
own: ownId !== null && message.sender_id === ownId, own: ownId !== null && message.sender_id === ownId,
}); });
@@ -117,22 +169,91 @@
peers.ensure(ids); peers.ensure(ids);
} }
function jumpToMessage(messageId: number) { function memberIds(message: MessageView): number[] {
return [message.message_id, ...message.media.map((ref) => ref.message_id)];
}
function holds(message: MessageView, messageId: number): boolean {
return memberIds(message).includes(messageId);
}
function sameAlbum(a: MessageView, b: MessageView): boolean {
return (
a.media_group_id !== null &&
a.media_group_id === b.media_group_id &&
a.chat_id === b.chat_id
);
}
function albumPrimary(base: MessageView, incoming: MessageView) {
if (
base.message_id === incoming.message_id ||
(incoming.text && !base.text)
) {
return incoming;
}
if (base.text) {
return base;
}
return incoming.message_id < base.message_id ? incoming : base;
}
function joinAlbum(base: MessageView, incoming: MessageView): MessageView {
const media = new Map(base.media.map((ref) => [ref.message_id, ref]));
for (const ref of incoming.media) {
media.set(ref.message_id, ref);
}
return {
...albumPrimary(base, incoming),
media: [...media.values()].sort((a, b) => a.message_id - b.message_id),
};
}
function mergeAlbums(list: MessageView[]): MessageView[] {
const out: MessageView[] = [];
for (const message of list) {
const prev = out.at(-1);
if (prev && sameAlbum(prev, message)) {
out[out.length - 1] = joinAlbum(prev, message);
} else {
out.push(message);
}
}
return out;
}
function knownIds(): Set<number> {
return new Set(messages.flatMap(memberIds));
}
function ownerOf(messageId: number): number | null {
return messages.find((m) => holds(m, messageId))?.message_id ?? null;
}
function jumpToMessage(
messageId: number,
smooth = true,
marks: string[] = []
) {
const owner = ownerOf(messageId) ?? messageId;
const target = container?.querySelector<HTMLElement>( const target = container?.querySelector<HTMLElement>(
`[data-message-id="${messageId}"]` `[data-message-id="${owner}"]`
); );
if (!target) { if (!target) {
toasts.error("Message not loaded"); toasts.error(t("thread.notFound"));
return; return;
} }
target.scrollIntoView({ behavior: "smooth", block: "center" }); target.scrollIntoView({
highlightId = messageId; behavior: smooth ? "smooth" : "auto",
block: "center",
});
highlight = { id: owner, marks };
if (highlightTimer) { if (highlightTimer) {
clearTimeout(highlightTimer); clearTimeout(highlightTimer);
} }
highlightTimer = setTimeout(() => { highlightTimer = setTimeout(() => {
highlightId = null; highlight = null;
}, 1600); }, HIGHLIGHT_MS);
} }
function scrollToBottom() { function scrollToBottom() {
@@ -147,33 +268,43 @@
} }
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1; const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
const seps = container.querySelectorAll<HTMLElement>(".day-separator"); const seps = container.querySelectorAll<HTMLElement>(".day-separator");
let day: string | null = null; const stuck: string[] = [];
for (const sep of seps) { for (const sep of seps) {
if (sep.getBoundingClientRect().top <= limit) { if (sep.getBoundingClientRect().top <= limit && sep.dataset.day) {
day = sep.dataset.day ?? null; stuck.push(sep.dataset.day);
} }
} }
stuckDay = day; stuckDay = stuck.pop() ?? null;
passedDays = new Set(stuck);
} }
async function loadInitial() { async function loadInitial() {
loading = true; loading = true;
hasMore = true; hasMore = true;
hasNewer = false; hasNewer = false;
const pending = ui.jumpTarget?.chatId === chatId ? ui.jumpTarget : null;
try { try {
if (pending) {
ui.clearJump();
await loadAround(pending.messageId);
await tick();
jumpToMessage(pending.messageId, false, pending.marks);
return;
}
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
include_deleted: true, include_deleted: true,
limit: PAGE, limit: PAGE,
offset: 0, offset: 0,
}); });
messages = [...page].reverse(); messages = mergeAlbums([...page].reverse());
hasMore = page.length > 0; hasMore = page.length > 0;
ensurePeers(messages); ensurePeers(messages);
await tick(); await tick();
scrollToBottom(); scrollToBottom();
updateStuck(); updateStuck();
setTimeout(updateStuck, SETTLE_DELAY);
} catch { } catch {
toasts.error("Failed to load messages"); toasts.error(t("thread.loadFailed"));
} finally { } finally {
loading = false; loading = false;
} }
@@ -215,16 +346,18 @@
const prevTop = el.scrollTop; const prevTop = el.scrollTop;
try { try {
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
before_id: messages[0].message_id, before_id: Math.min(...memberIds(messages[0])),
include_deleted: true, include_deleted: true,
limit: PAGE, limit: PAGE,
}); });
const known = new Set(messages.map((m) => m.message_id)); const known = knownIds();
const fresh = page.filter((m) => !known.has(m.message_id)); const fresh = page.filter((m) =>
memberIds(m).some((id) => !known.has(id))
);
hasMore = fresh.length > 0; hasMore = fresh.length > 0;
if (fresh.length > 0) { if (fresh.length > 0) {
suppressAppear = true; suppressAppear = true;
messages = [...[...fresh].reverse(), ...messages]; messages = mergeAlbums([...[...fresh].reverse(), ...messages]);
ensurePeers(fresh); ensurePeers(fresh);
await tick(); await tick();
el.scrollTop = prevTop + (el.scrollHeight - prevHeight); el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
@@ -242,17 +375,20 @@
} }
loadingNewer = true; loadingNewer = true;
try { try {
const last = messages.at(-1);
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
after_id: messages.at(-1)?.message_id, after_id: last ? Math.max(...memberIds(last)) : undefined,
include_deleted: true, include_deleted: true,
limit: PAGE, limit: PAGE,
}); });
const known = new Set(messages.map((m) => m.message_id)); const known = knownIds();
const fresh = page.filter((m) => !known.has(m.message_id)); const fresh = page.filter((m) =>
memberIds(m).some((id) => !known.has(id))
);
hasNewer = fresh.length > 0; hasNewer = fresh.length > 0;
if (fresh.length > 0) { if (fresh.length > 0) {
suppressAppear = true; suppressAppear = true;
messages = [...messages, ...fresh]; messages = mergeAlbums([...messages, ...fresh]);
ensurePeers(fresh); ensurePeers(fresh);
await tick(); await tick();
await trimHead(); await trimHead();
@@ -278,7 +414,7 @@
limit: PAGE, limit: PAGE,
}), }),
]); ]);
messages = [...[...older].reverse(), ...newer]; messages = mergeAlbums([...[...older].reverse(), ...newer]);
hasMore = older.length > 0; hasMore = older.length > 0;
hasNewer = newer.length > 0; hasNewer = newer.length > 0;
ensurePeers(messages); ensurePeers(messages);
@@ -309,6 +445,13 @@
} }
} }
function openCalendar(event: MouseEvent, iso: string) {
const date = new Date(iso);
calendarAnchor = event.currentTarget as HTMLElement;
calendarMonth = { month: date.getUTCMonth(), year: date.getUTCFullYear() };
calendarOpen = true;
}
function openMedia(message: MessageView, index: number) { function openMedia(message: MessageView, index: number) {
mediaItems = viewerItemsFrom(message.message_id, message.media); mediaItems = viewerItemsFrom(message.message_id, message.media);
mediaIndex = index; mediaIndex = index;
@@ -331,13 +474,20 @@
} }
function replaceMessage(message: MessageView) { function replaceMessage(message: MessageView) {
messages = messages.map((m) => messages = messages.map((m) => {
m.message_id === message.message_id ? message : m if (sameAlbum(m, message)) {
); return joinAlbum(m, message);
}
return m.message_id === message.message_id ? message : m;
});
} }
async function appendMessage(message: MessageView) { async function appendMessage(message: MessageView) {
if (messages.some((m) => m.message_id === message.message_id)) { const last = messages.at(-1);
if (
messages.some((m) => holds(m, message.message_id)) ||
(last && sameAlbum(last, message))
) {
replaceMessage(message); replaceMessage(message);
return; return;
} }
@@ -357,7 +507,7 @@
function applyDelete(ids: number[]) { function applyDelete(ids: number[]) {
const now = new Date().toISOString(); const now = new Date().toISOString();
messages = messages.map((m) => messages = messages.map((m) =>
ids.includes(m.message_id) && m.deleted_at === null m.deleted_at === null && ids.some((id) => holds(m, id))
? { ...m, deleted_at: now } ? { ...m, deleted_at: now }
: m : m
); );
@@ -403,20 +553,24 @@
}); });
const fresh = [...page].reverse(); const fresh = [...page].reverse();
const known = new Map(messages.map((m) => [m.message_id, m])); const known = new Map(messages.map((m) => [m.message_id, m]));
const maxId = messages.reduce((acc, m) => Math.max(acc, m.message_id), 0); const maxId = Math.max(0, ...messages.flatMap(memberIds));
const stick = isNearBottom(); const stick = isNearBottom();
const appended: MessageView[] = []; const appended: MessageView[] = [];
for (const message of fresh) { for (const message of fresh) {
if (known.has(message.message_id)) { const current = known.get(message.message_id);
known.set(message.message_id, message); if (current) {
known.set(
message.message_id,
sameAlbum(current, message) ? joinAlbum(current, message) : message
);
} else if (message.message_id > maxId) { } else if (message.message_id > maxId) {
appended.push(message); appended.push(message);
} }
} }
messages = [ messages = mergeAlbums([
...messages.map((m) => known.get(m.message_id) ?? m), ...messages.map((m) => known.get(m.message_id) ?? m),
...appended, ...appended,
]; ]);
ensurePeers(fresh); ensurePeers(fresh);
await trimHead(); await trimHead();
if (appended.length > 0 && stick) { if (appended.length > 0 && stick) {
@@ -425,12 +579,17 @@
} }
} }
async function jumpToTarget(messageId: number) { async function goTo(messageId: number, marks: string[] = []) {
if (!messages.some((m) => m.message_id === messageId)) { try {
await loadAround(messageId); const loaded = ownerOf(messageId) !== null;
if (!loaded) {
await loadAround(messageId);
}
await tick();
jumpToMessage(messageId, loaded, marks);
} catch {
toasts.error(t("thread.loadFailed"));
} }
await tick();
jumpToMessage(messageId);
} }
$effect(() => { $effect(() => {
@@ -439,7 +598,7 @@
return; return;
} }
ui.clearJump(); ui.clearJump();
jumpToTarget(target.messageId); goTo(target.messageId, target.marks);
}); });
$effect(() => { $effect(() => {
@@ -460,7 +619,7 @@
</script> </script>
<div class="message-pane"> <div class="message-pane">
<PinnedBar {chatId} onjump={jumpToMessage} /> <PinnedBar {chatId} onjump={goTo} />
<div <div
class="message-list custom-scroll" class="message-list custom-scroll"
onscroll={onScroll} onscroll={onScroll}
@@ -480,26 +639,42 @@
<div class="loading-older"><Spinner /></div> <div class="loading-older"><Spinner /></div>
{/if} {/if}
{#each rows as row (row.message.message_id)} {#each rows as row (row.message.message_id)}
{#each row.gapsBefore as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.daySeparator} {#if row.daySeparator}
<div <div
class="day-separator" class="day-separator"
data-day={row.dayKey} data-day={row.dayKey}
class:idle={!scrolling && row.dayKey === stuckDay} class:idle={!scrolling && row.dayKey === stuckDay}
class:passed={passedDays.has(row.dayKey)}
> >
<span>{row.daySeparator}</span> <button
aria-label={t("chat.calendar")}
onclick={(event) => openCalendar(event, row.message.date)}
title={t("chat.calendar")}
type="button"
>
{row.daySeparator}
</button>
</div> </div>
{/if} {/if}
{#each row.gapsAfter as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.message.service} {#if row.message.service}
<ActionMessage message={row.message} onjump={jumpToMessage} /> <ActionMessage message={row.message} onjump={goTo} />
{:else} {:else}
<MessageBubble <MessageBubble
animate={!suppressAppear} animate={!suppressAppear}
firstInGroup={row.firstInGroup} firstInGroup={row.firstInGroup}
highlighted={highlightId === row.message.message_id} highlight={highlight?.id === row.message.message_id
? highlight.marks
: null}
{isGroupChat} {isGroupChat}
lastInGroup={row.lastInGroup} lastInGroup={row.lastInGroup}
message={row.message} message={row.message}
onjump={jumpToMessage} onjump={goTo}
onmedia={(index) => openMedia(row.message, index)} onmedia={(index) => openMedia(row.message, index)}
onversions={() => openVersions(row.message.message_id)} onversions={() => openVersions(row.message.message_id)}
own={row.own} own={row.own}
@@ -514,7 +689,7 @@
</div> </div>
{#if hasNewer} {#if hasNewer}
<button <button
aria-label="К последним сообщениям" aria-label={t("thread.toLatest")}
class="to-latest" class="to-latest"
onclick={goToLatest} onclick={goToLatest}
type="button" type="button"
@@ -524,6 +699,20 @@
{/if} {/if}
</div> </div>
{#snippet gapLine(
gap: CaptureGap
)}
<div class="capture-gap">
<span>{t("chat.gap", { range: formatSpanRange(gap.start, gap.end) })}</span>
</div>
{/snippet}
<CalendarPopover
anchor={calendarAnchor}
{chatId}
month={calendarMonth}
bind:open={calendarOpen}
/>
<MediaViewer <MediaViewer
{chatId} {chatId}
items={mediaItems} items={mediaItems}
@@ -613,10 +802,15 @@
justify-content: center; justify-content: center;
margin: 0.75rem 0; margin: 0.75rem 0;
span { button {
pointer-events: auto;
cursor: pointer;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border: 0;
border-radius: 1rem; border-radius: 1rem;
font: inherit;
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--color-white); color: var(--color-white);
@@ -624,10 +818,39 @@
background-color: var(--color-default-shadow); background-color: var(--color-default-shadow);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
} }
&.idle span { &.idle button,
&.passed button {
pointer-events: none;
opacity: 0; opacity: 0;
} }
} }
.capture-gap {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.75rem 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
&::before,
&::after {
content: "";
flex: 1;
border-top: 1px dashed var(--color-borders);
}
span {
flex-shrink: 0;
}
}
</style> </style>
+55 -35
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ContextMenu as Menu } from "bits-ui";
import type { Snippet } from "svelte";
import { visible } from "#lib/actions/visible.js"; import { visible } from "#lib/actions/visible.js";
import { downloadMedia } from "#lib/api/download.js"; import { downloadMedia } from "#lib/api/download.js";
import { fetchMedia } from "#lib/api/endpoints.js"; import { fetchMedia } from "#lib/api/endpoints.js";
@@ -14,24 +16,25 @@
import TgsSticker from "#lib/components/media/TgsSticker.svelte"; import TgsSticker from "#lib/components/media/TgsSticker.svelte";
import VideoNote from "#lib/components/media/VideoNote.svelte"; import VideoNote from "#lib/components/media/VideoNote.svelte";
import VoiceMessage from "#lib/components/media/VoiceMessage.svelte"; import VoiceMessage from "#lib/components/media/VoiceMessage.svelte";
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js"; import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
interface Props { interface Props {
items?: Snippet;
message: MessageView; message: MessageView;
onopen: () => void; onopen: () => void;
onversions?: () => void;
own: boolean; own: boolean;
} }
let { message, onopen, own }: Props = $props(); let { items, message, onopen, onversions, own }: Props = $props();
const POLL_TRIES = 5; const POLL_TRIES = 5;
const POLL_DELAY = 3000; const POLL_DELAY = 3000;
@@ -39,6 +42,7 @@
let loaded = $state(false); let loaded = $state(false);
let media = $state<InlineMedia | null>(null); let media = $state<InlineMedia | null>(null);
let queuing = $state(false); let queuing = $state(false);
let queued = $state(false);
let saving = $state(false); let saving = $state(false);
const ref = $derived(message.media[0] ?? null); const ref = $derived(message.media[0] ?? null);
@@ -71,7 +75,10 @@
const storedId = $derived( const storedId = $derived(
ready?.mediaId ?? (asFile ? ref?.id : null) ?? null ready?.mediaId ?? (asFile ? ref?.id : null) ?? null
); );
const fileName = $derived(ref?.file_name ?? mediaKindLabel(kind) ?? "Файл"); const fileName = $derived(
ref?.file_name ?? mediaKindLabel(kind) ?? t("chat.media.file")
);
const waiting = $derived(queuing || queued);
function delay(ms: number): Promise<void> { function delay(ms: number): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -101,16 +108,16 @@
} }
async function queue() { async function queue() {
if (queuing) { if (waiting) {
return; return;
} }
queuing = true; queuing = true;
try { try {
await fetchMedia(message.chat_id, message.message_id); await fetchMedia(message.chat_id, message.message_id);
toasts.success("Скачивание поставлено в очередь"); queued = true;
poll(); poll();
} catch { } catch {
toasts.error("Не удалось поставить в очередь"); toasts.error(t("chat.media.queueFailed"));
} finally { } finally {
queuing = false; queuing = false;
} }
@@ -124,7 +131,7 @@
try { try {
await downloadMedia(storedId); await downloadMedia(storedId);
} catch { } catch {
toasts.error("Не удалось скачать файл"); toasts.error(t("chat.media.downloadFailed"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -145,7 +152,7 @@
{#if message.is_self_destruct} {#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button"> <button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" /> <Icon name="timer" size="1.25rem" />
<span>Самоуничтожающееся медиа</span> <span>{t("chat.media.selfDestruct")}</span>
</button> </button>
{:else if asFile && ref} {:else if asFile && ref}
<FileChip media={ref} {own} /> <FileChip media={ref} {own} />
@@ -165,11 +172,11 @@
<AudioFile {own} title={fileName} url={ready.url} /> <AudioFile {own} title={fileName} url={ready.url} />
{:else if ready && isImage} {:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button"> <button class="media-thumb" onclick={onopen} type="button">
<img alt="attachment" src={ready.url}> <img alt={t("chat.media.photo")} src={ready.url}>
</button> </button>
{:else if ready && isStaticSticker} {:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button"> <button class="media-sticker" onclick={onopen} type="button">
<img alt="sticker" src={ready.url}> <img alt={fileName} src={ready.url}>
</button> </button>
{:else if ready && isVideoSticker} {:else if ready && isVideoSticker}
<video <video
@@ -205,46 +212,59 @@
</button> </button>
{:else if media?.state === "not-downloaded" && vk !== "other"} {:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button"> <button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" /> <Icon name={waiting ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Видео" : "Фото"}</span> <span
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small> >{vk === "video"
? t("chat.media.video")
: t("chat.media.photo")}</span
>
<small
>{waiting
? t("chat.media.queued")
: t("chat.media.tapToDownload")}</small
>
</button> </button>
{:else if media?.state === "not-downloaded"} {:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button"> <button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" /> <Icon name={waiting ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span> <span
>{waiting
? t("chat.media.queued")
: t("chat.media.download", { kind: label })}</span
>
</button> </button>
{:else} {:else}
<button class="media-chip" onclick={onopen} type="button"> <button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" /> <Icon name="photo" size="1.25rem" />
<span>Медиа</span> <span>{t("chat.media")}</span>
</button> </button>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen} <ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть на весь экран</ContextMenuItem >{t("chat.media.fullscreen")}</ContextMenuItem
>
<ContextMenuItem
icon="recent"
onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</ContextMenuItem
> >
{#if onversions}
<ContextMenuItem icon="recent" onselect={onversions}
>{t("chat.media.versions")}</ContextMenuItem
>
{/if}
{#if storedId === null} {#if storedId === null}
<ContextMenuItem icon="cloud-download" onselect={queue}> <ContextMenuItem icon="cloud-download" onselect={queue}
Скачать в архив >{t("chat.media.queue")}</ContextMenuItem
</ContextMenuItem> >
{:else} {:else}
<ContextMenuItem icon="download" onselect={save}> <ContextMenuItem icon="download" onselect={save}
Скачать файл >{t("chat.media.save")}</ContextMenuItem
</ContextMenuItem> >
<ContextMenuItem icon="allow-share" onselect={share}> <ContextMenuItem icon="allow-share" onselect={share}
Доступ по ссылке >{t("chat.media.share")}</ContextMenuItem
</ContextMenuItem> >
{/if}
{#if items}
<Menu.Separator class="bg-menu-separator" />
{@render items()}
{/if} {/if}
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
+126 -19
View File
@@ -1,30 +1,86 @@
<script lang="ts"> <script lang="ts">
import type { MessageView } from "#lib/api/types.js"; import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatTime } from "#lib/format/datetime.js"; import {
formatExact,
formatStamp,
formatTime,
} from "#lib/format/datetime.js";
import { readTitle } from "#lib/format/read.js"; import { readTitle } from "#lib/format/read.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
message: MessageView; message: MessageView;
noted?: boolean;
onnote?: () => void;
onversions?: () => void; onversions?: () => void;
own?: boolean; own?: boolean;
} }
let { message, onversions, own = false }: Props = $props(); let {
message,
noted = false,
onnote,
onversions,
own = false,
}: Props = $props();
const TIP_DURATION = 3000;
let tip = $state(false);
let tipTimer: ReturnType<typeof setTimeout> | undefined;
const exact = $derived(formatExact(message.date));
function toggleTip() {
tip = !tip;
clearTimeout(tipTimer);
if (tip) {
tipTimer = setTimeout(() => {
tip = false;
}, TIP_DURATION);
}
}
</script> </script>
<span class="MessageMeta"> <span class="MessageMeta">
{#if message.edited_at} {#if noted}
<button <button
class="edited" aria-label={t("chat.note")}
onclick={onversions} class="meta-button note"
title="View edit history" onclick={onnote}
title={t("chat.note")}
type="button" type="button"
> >
edited <Icon name="note" size="1rem" />
</button> </button>
{/if} {/if}
<span class="time">{formatTime(message.date)}</span> {#if message.deleted_at}
<span class="deleted" title={formatExact(message.deleted_at)}>
{t("chat.deletedAt", { when: formatStamp(message.deleted_at) })}
</span>
{/if}
{#if message.edited_at}
<button
class="meta-button edited"
onclick={onversions}
title={t("chat.editedAt", { when: formatExact(message.edited_at) })}
type="button"
>
{t("chat.edited")}
</button>
{/if}
<button
class="meta-button time"
onclick={toggleTip}
title={exact}
type="button"
>
{formatTime(message.date)}
</button>
{#if tip}
<span class="tip" role="status">{exact}</span>
{/if}
{#if own} {#if own}
<span class="ticks" title={readTitle(message)}> <span class="ticks" title={readTitle(message)}>
<Icon name={message.read ? "message-read" : "check"} size="1rem" /> <Icon name={message.read ? "message-read" : "check"} size="1rem" />
@@ -34,6 +90,8 @@
<style lang="scss"> <style lang="scss">
.MessageMeta { .MessageMeta {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
@@ -47,21 +105,70 @@
user-select: none; user-select: none;
} }
.time { .meta-button {
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 0;
border: 0;
border-radius: 0.25rem;
font: inherit;
color: inherit;
white-space: nowrap; white-space: nowrap;
background: transparent;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.note {
@media (hover: hover) {
&:hover {
color: var(--color-primary);
}
}
}
.edited,
.deleted {
font-style: italic;
white-space: nowrap;
}
.deleted::after {
content: "·";
margin-inline-start: 0.25rem;
font-style: normal;
}
.tip {
pointer-events: none;
position: absolute;
right: 0;
bottom: calc(100% + 0.375rem);
z-index: 1;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-style: normal;
color: var(--color-white);
white-space: nowrap;
background-color: var(--color-toast-background);
backdrop-filter: blur(10px);
box-shadow: 0 0.25rem 0.75rem var(--color-default-shadow);
} }
.ticks { .ticks {
display: inline-flex; display: inline-flex;
} }
.edited {
cursor: pointer;
padding: 0;
border: 0;
font-size: 0.75rem;
font-style: italic;
color: inherit;
background: transparent;
}
</style> </style>
@@ -6,6 +6,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -33,7 +34,7 @@
versions = text; versions = text;
mediaVersions = media; mediaVersions = media;
}) })
.catch(() => toasts.error("Failed to load edit history")) .catch(() => toasts.error(t("versions.failed")))
.finally(() => { .finally(() => {
loading = false; loading = false;
}); });
@@ -46,8 +47,8 @@
<Dialog.Overlay class="dialog-overlay" /> <Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Edit history</Dialog.Title> <Dialog.Title class="dialog-title">{t("versions.title")}</Dialog.Title>
<Dialog.Close aria-label="Close" class="dialog-close"> <Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
@@ -55,11 +56,11 @@
{#if loading} {#if loading}
<div class="centered"><Spinner /></div> <div class="centered"><Spinner /></div>
{:else if versions.length === 0 && mediaVersions.length === 0} {:else if versions.length === 0 && mediaVersions.length === 0}
<p class="empty">No versions recorded.</p> <p class="empty">{t("versions.none")}</p>
{:else} {:else}
{#if mediaVersions.length > 0} {#if mediaVersions.length > 0}
<div class="media-versions"> <div class="media-versions">
<span class="media-label">Media versions</span> <span class="media-label">{t("versions.media")}</span>
<div class="media-strip"> <div class="media-strip">
{#each mediaVersions as media (media.id)} {#each mediaVersions as media (media.id)}
<MediaVersionThumb version={media} /> <MediaVersionThumb version={media} />
@@ -70,12 +71,14 @@
{#each versions as version, index (version.observed_at)} {#each versions as version, index (version.observed_at)}
<div class="version"> <div class="version">
<div class="version-meta"> <div class="version-meta">
<span class="version-label">Version {index + 1}</span> <span class="version-label"
>{t("versions.version", { number: index + 1 })}</span
>
<span class="version-date" <span class="version-date"
>{formatFull(version.observed_at)}</span >{formatFull(version.observed_at)}</span
> >
</div> </div>
<div class="version-text">{version.text ?? "(no text)"}</div> <div class="version-text">{version.text ?? t("feed.noText")}</div>
</div> </div>
{/each} {/each}
{/if} {/if}
+3 -2
View File
@@ -3,6 +3,7 @@
import type { PinnedView } from "#lib/api/types.js"; import type { PinnedView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { mediaKindLabel } from "#lib/format/media.js"; import { mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
interface Props { interface Props {
@@ -19,7 +20,7 @@
? (pinned.text ?? ? (pinned.text ??
(pinned.media_kind (pinned.media_kind
? mediaKindLabel(pinned.media_kind) ? mediaKindLabel(pinned.media_kind)
: "Pinned message")) : t("pinned.message")))
: "" : ""
); );
@@ -56,7 +57,7 @@
> >
<Icon name="pin" size="1.125rem" /> <Icon name="pin" size="1.125rem" />
<span class="body"> <span class="body">
<span class="label">Pinned message</span> <span class="label">{t("pinned.message")}</span>
<span class="preview">{preview}</span> <span class="preview">{preview}</span>
</span> </span>
</button> </button>
+51 -26
View File
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte"; import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte";
import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte"; import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte";
import NotesPanel from "#lib/components/annotations/NotesPanel.svelte";
import BotsPanel from "#lib/components/bots/BotsPanel.svelte"; import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
import StoragePanel from "#lib/components/disk/StoragePanel.svelte"; import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
import TrashPanel from "#lib/components/disk/TrashPanel.svelte"; import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
import ChatChanges from "#lib/components/feed/ChatChanges.svelte";
import JobsPanel from "#lib/components/jobs/JobsPanel.svelte"; import JobsPanel from "#lib/components/jobs/JobsPanel.svelte";
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte"; import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte"; import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
@@ -19,39 +21,45 @@
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte"; import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte";
import { t } from "#lib/i18n/locale.svelte.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 { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { notes } from "#lib/stores/notes.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { storageUi } from "#lib/stores/storage.svelte.js"; import { storageUi } from "#lib/stores/storage.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
const titles: Record<RightPanel, string> = $derived({ const titles: Record<RightPanel, MessageKey> = {
alerts: "Алерты", alerts: "settings.alerts",
annotations: "Заметки", annotations: "panel.annotations",
bots: "Боты", bots: "settings.bots",
callbacks: "Callback-данные", callbacks: "panel.callbacks",
jobs: "Бэкфилл и задачи", changes: "feed.changes",
links: "Ссылки", jobs: "settings.jobs",
policy: "Политика захвата", links: "panel.links",
presence: "Аналитика", notes: "notes.title",
profile: "Профиль", policy: "settings.policy",
reactions: "Реакции", presence: "chat.analytics",
readers: t("read.readers.title"), profile: "chat.profile",
search: "Поиск", reactions: "panel.reactions",
shares: "Файлы по ссылке", readers: "read.readers.title",
storage: "Хранилище", search: "panel.search",
"storage-chat": "Файлы чата", shares: "settings.shares",
"storage-trash": "Корзина", storage: "settings.storage",
stories: "Сторис", "storage-chat": "panel.chatFiles",
"stories-all": "Все сторис", "storage-trash": "panel.trash",
versions: "Версии", stories: "settings.stories",
watches: "Отслеживания", "stories-all": "panel.allStories",
}); watches: "settings.watches",
webhooks: "watches.webhooks",
};
const parents: Partial<Record<RightPanel, RightPanel>> = { const parents: Partial<Record<RightPanel, RightPanel>> = {
"storage-chat": "storage", "storage-chat": "storage",
"storage-trash": "storage", "storage-trash": "storage",
webhooks: "watches",
}; };
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined); const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
@@ -59,18 +67,21 @@
if (ui.rightPanel === null) { if (ui.rightPanel === null) {
return ""; return "";
} }
if (ui.rightPanel === "notes" && notes.tag !== null) {
return t("notes.tag", { tag: notes.tag });
}
const chat = const chat =
ui.rightPanel === "storage-chat" && storageUi.chatId !== null ui.rightPanel === "storage-chat" && storageUi.chatId !== null
? chats.byId(storageUi.chatId) ? chats.byId(storageUi.chatId)
: undefined; : undefined;
return chat?.title ?? titles[ui.rightPanel]; return chat?.title ?? t(titles[ui.rightPanel]);
}); });
</script> </script>
<header class="right-header"> <header class="right-header">
{#if parent} {#if parent}
<Button <Button
aria-label="Назад" aria-label={t("common.back")}
onclick={() => ui.openPanel(parent)} onclick={() => ui.openPanel(parent)}
round round
smaller smaller
@@ -80,7 +91,7 @@
</Button> </Button>
{:else} {:else}
<Button <Button
aria-label="Закрыть" aria-label={t("common.close")}
onclick={() => ui.closePanel()} onclick={() => ui.closePanel()}
round round
smaller smaller
@@ -95,6 +106,10 @@
<div class="right-body custom-scroll"> <div class="right-body custom-scroll">
{#if ui.rightPanel === "profile"} {#if ui.rightPanel === "profile"}
<ProfilePanel /> <ProfilePanel />
{:else if ui.rightPanel === "changes"}
{#key accounts.selectedId}
<ChatChanges />
{/key}
{:else if ui.rightPanel === "policy"} {:else if ui.rightPanel === "policy"}
{#key `${accounts.selectedId}:${policyUi.revision}`} {#key `${accounts.selectedId}:${policyUi.revision}`}
<PolicyEditor /> <PolicyEditor />
@@ -118,11 +133,21 @@
{:else if ui.rightPanel === "stories-all"} {:else if ui.rightPanel === "stories-all"}
<AllStoriesArchive /> <AllStoriesArchive />
{:else if ui.rightPanel === "watches"} {: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"} {:else if ui.rightPanel === "alerts"}
<AlertsPanel /> <AlertsPanel />
{:else if ui.rightPanel === "annotations"} {:else if ui.rightPanel === "annotations"}
<AnnotationsPanel /> <AnnotationsPanel />
{:else if ui.rightPanel === "notes"}
{#key accounts.selectedId}
<NotesPanel />
{/key}
{:else if ui.rightPanel === "shares"} {:else if ui.rightPanel === "shares"}
<SharesPanel /> <SharesPanel />
{:else if ui.rightPanel === "bots"} {:else if ui.rightPanel === "bots"}
+15 -16
View File
@@ -2,9 +2,11 @@
import { enqueueBackfill, trackChat } from "#lib/api/endpoints.js"; import { enqueueBackfill, trackChat } from "#lib/api/endpoints.js";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -14,10 +16,11 @@
let { chatId }: Props = $props(); let { chatId }: Props = $props();
let busy = $state(false); let busy = $state(false);
const started = createFlash<true>();
const item = $derived(discover.get(chatId)); const item = $derived(discover.get(chatId));
const tracked = $derived(item?.tracked ?? false); const tracked = $derived(item?.tracked ?? false);
const title = $derived(item?.title ?? "Этот чат"); const title = $derived(item?.title ?? t("track.thisChat"));
$effect(() => { $effect(() => {
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
@@ -33,10 +36,10 @@
busy = true; busy = true;
try { try {
discover.set(await trackChat(chatId, true)); discover.set(await trackChat(chatId, true));
toasts.success("Отслеживание включено, история загружается"); started.show(true);
chats.refresh(); chats.refresh();
} catch { } catch {
toasts.error("Не удалось включить отслеживание"); toasts.error(t("track.failed"));
} finally { } finally {
busy = false; busy = false;
} }
@@ -49,9 +52,9 @@
busy = true; busy = true;
try { try {
await enqueueBackfill(chatId, true, true); await enqueueBackfill(chatId, true, true);
toasts.success("Полный бэкфилл запущен"); started.show(true);
} catch { } catch {
toasts.error("Не удалось запустить бэкфилл"); toasts.error(t("jobs.backfillFailed"));
} finally { } finally {
busy = false; busy = false;
} }
@@ -59,26 +62,22 @@
</script> </script>
<div class="track"> <div class="track">
<div class="title">Здесь пока нет сообщений</div> <div class="title">{t("track.empty")}</div>
<p class="description"> <p class="description">
{#if tracked} {tracked ? t("track.tracked", { title }) : t("track.hint")}
{title}
отслеживается — новые сообщения и статистика собираются автоматически.
{:else}
Включите отслеживание, чтобы собирать сообщения, медиа и статистику по
этому чату.
{/if}
</p> </p>
<div class="actions"> <div class="actions">
{#if tracked} {#if tracked}
<Button loading={busy} onclick={backfill} pill variant="secondary"> <Button loading={busy} onclick={backfill} pill variant="secondary">
<Icon name="cloud-download" /> <Icon name={started.current ? "check" : "cloud-download"} />
<span>Загрузить историю</span> <span
>{started.current ? t("track.loading") : t("track.loadHistory")}</span
>
</Button> </Button>
{:else} {:else}
<Button loading={busy} onclick={track} pill variant="primary"> <Button loading={busy} onclick={track} pill variant="primary">
<Icon name="stats" /> <Icon name="stats" />
<span>Начать отслеживать</span> <span>{t("track.start")}</span>
</Button> </Button>
{/if} {/if}
</div> </div>
@@ -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,634 @@
<script lang="ts"> <script lang="ts">
import { listAlerts, markAlertSeen } from "#lib/api/endpoints.js"; import { onMount, untrack } from "svelte";
import type { Alert } from "#lib/api/types.js"; 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,
alertHref,
alertLabel,
alertPreview,
alertTitle,
EVENT_ICONS,
} from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { replaceParam } from "#lib/params.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import Spinner from "../ui/Spinner.svelte"; import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
import AlertList from "./AlertList.svelte"; 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 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) { let filter = $state<Filter>(
loading = true; untrack(() => page.url.searchParams.get(ALERTS_PARAM)) === "unseen"
? "unseen"
: "all"
);
function writeParam(value: Filter | null) {
replaceParam(ALERTS_PARAM, value);
}
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 { 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 { } finally {
loading = false; if (mine === token) {
loading = false;
loadingMore = false;
}
} }
} }
$effect(() => { $effect(() => {
load(accounts.selectedId, unseenOnly); if (accounts.selectedId !== null) {
load(filter);
}
}); });
async function seen(alert: Alert) { onMount(() => {
await markAlertSeen(alert.id); writeParam(filter);
if (unseenOnly) { const stop = alerts.onAlert((alert) => {
alerts = alerts.filter((a) => a.id !== alert.id); if (!items.some((item) => item.id === alert.id)) {
} else { items = [alert, ...items];
alerts = alerts.map((a) => }
a.id === alert.id ? { ...a, seen: true } : a });
); 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);
if (isMobile()) {
ui.resetPanel();
}
await goto(alertHref(alert));
}
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> </script>
<div class="panel-content"> <div class="tabs">
<label class="toggle"> <button
<input type="checkbox" bind:checked={unseenOnly}> aria-pressed={filter === "all"}
<span>Только непрочитанные</span> class="tab"
</label> onclick={() => setFilter("all")}
type="button"
{#if loading} class:on={filter === "all"}
<Spinner /> >
{:else if alerts.length === 0} {t("alerts.all")}
<p class="empty">Алертов нет.</p> </button>
{:else} <button
<AlertList {alerts} onseen={seen} /> aria-pressed={filter === "unseen"}
{/if} class="tab"
onclick={() => setFilter("unseen")}
type="button"
class:on={filter === "unseen"}
>
{t("alerts.unseen")}
</button>
</div> </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"> <style lang="scss">
.panel-content { .tabs {
display: flex; display: flex;
flex-direction: column; flex-shrink: 0;
gap: 1rem; border-bottom: 1px solid var(--color-borders);
padding: 1rem; }
.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 { .toggle {
color: var(--color-text);
:global(.icon) {
color: var(--color-icon-secondary);
}
}
.switch {
display: flex; display: flex;
flex-shrink: 0;
align-items: center; 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); color: var(--color-text-secondary);
p {
margin: 0;
}
} }
.empty { .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; 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> </style>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import Button from "../ui/Button.svelte"; import Button from "../ui/Button.svelte";
interface Props { interface Props {
@@ -13,7 +14,7 @@
const { const {
initial = "", initial = "",
saving = false, saving = false,
submitLabel = "Добавить заметку", submitLabel,
oncancel, oncancel,
onsubmit, onsubmit,
}: Props = $props(); }: Props = $props();
@@ -41,15 +42,19 @@
}} }}
> >
<textarea <textarea
placeholder="Заметка к сообщению…" placeholder={t("notes.placeholder")}
rows={oncancel ? 2 : 3} rows={oncancel ? 2 : 3}
bind:value={text} bind:value={text}
></textarea> ></textarea>
<div class="actions"> <div class="actions">
{#if oncancel} {#if oncancel}
<Button onclick={oncancel} variant="translucent">Отмена</Button> <Button onclick={oncancel} variant="translucent">
{t("common.cancel")}
</Button>
{/if} {/if}
<Button disabled={saving || !valid} type="submit">{submitLabel}</Button> <Button disabled={saving || !valid} type="submit">
{submitLabel ?? t("notes.add")}
</Button>
</div> </div>
</form> </form>
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { Annotation } from "#lib/api/types.js"; import type { Annotation } from "#lib/api/types.js";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import Icon from "../ui/Icon.svelte"; import Icon from "../ui/Icon.svelte";
import AnnotationEditor from "./AnnotationEditor.svelte"; import AnnotationEditor from "./AnnotationEditor.svelte";
import NoteText from "./NoteText.svelte";
interface Props { interface Props {
annotations: Annotation[]; annotations: Annotation[];
@@ -28,14 +30,14 @@
onupdate(annotation, text); onupdate(annotation, text);
editingId = null; editingId = null;
}} }}
submitLabel="Сохранить" submitLabel={t("common.save")}
/> />
{:else} {:else}
<p class="text">{annotation.text}</p> <NoteText text={annotation.text} />
<div class="meta"> <div class="meta">
<span class="time">{formatFull(annotation.updated_at)}</span> <span class="time">{formatFull(annotation.updated_at)}</span>
<button <button
aria-label="Изменить" aria-label={t("notes.edit")}
class="action" class="action"
onclick={() => { onclick={() => {
editingId = annotation.id; editingId = annotation.id;
@@ -45,7 +47,7 @@
<Icon name="edit" size="1rem" /> <Icon name="edit" size="1rem" />
</button> </button>
<button <button
aria-label="Удалить" aria-label={t("common.delete")}
class="action" class="action"
onclick={() => ondelete(annotation)} onclick={() => ondelete(annotation)}
type="button" type="button"
@@ -74,12 +76,6 @@
border-radius: 0.75rem; border-radius: 0.75rem;
} }
.text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.meta { .meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -9,7 +9,9 @@
import type { Annotation } from "#lib/api/types.js"; import type { Annotation } from "#lib/api/types.js";
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { notes } from "#lib/stores/notes.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
import AnnotationEditor from "./AnnotationEditor.svelte"; import AnnotationEditor from "./AnnotationEditor.svelte";
@@ -40,6 +42,7 @@
.then((rows) => { .then((rows) => {
if (current === token) { if (current === token) {
items = rows; items = rows;
notes.mark(cid, mid, rows.length > 0);
} }
}) })
.finally(() => { .finally(() => {
@@ -58,6 +61,7 @@
try { try {
const created = await createAnnotation(chatId, messageId, text); const created = await createAnnotation(chatId, messageId, text);
items = [created, ...items]; items = [created, ...items];
notes.mark(chatId, messageId, true);
} finally { } finally {
saving = false; saving = false;
} }
@@ -71,11 +75,12 @@
async function remove(annotation: Annotation) { async function remove(annotation: Annotation) {
await deleteAnnotation(annotation.id); await deleteAnnotation(annotation.id);
items = items.filter((item) => item.id !== annotation.id); items = items.filter((item) => item.id !== annotation.id);
notes.mark(annotation.chat_id, annotation.message_id, items.length > 0);
} }
</script> </script>
{#if chatId === null || messageId === null} {#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Заметки" /> <EmptyState description={t("ui.noMessage")} title={t("notes.title")} />
{:else} {:else}
<div class="panel-content"> <div class="panel-content">
<AnnotationEditor onsubmit={add} {saving} /> <AnnotationEditor onsubmit={add} {saving} />
@@ -0,0 +1,84 @@
<script lang="ts">
import { noteParts, notes } from "#lib/stores/notes.svelte.js";
interface Props {
interactive?: boolean;
text: string;
}
const { interactive = true, text }: Props = $props();
const parts = $derived(noteParts(text));
function openTag(event: MouseEvent, tag: string) {
event.stopPropagation();
notes.openTag(tag);
}
</script>
<p class="note-text">
{#each parts as part, index (index)}
{#if "tag" in part && interactive}
<button
class="tag"
onclick={(event) => openTag(event, part.tag)}
type="button"
class:active={notes.tag === part.tag}
>
{part.label}
</button>
{:else if "tag" in part}
<span class="tag" class:active={notes.tag === part.tag}
>{part.label}</span
>
{:else}
{part.text}
{/if}
{/each}
</p>
<style lang="scss">
.note-text {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tag {
display: inline;
padding: 0 0.3125rem;
border: 0;
border-radius: 0.375rem;
font: inherit;
font-size: 0.875em;
color: var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
transition: background-color 0.15s ease;
&.active {
box-shadow: inset 0 0 0 1px var(--color-primary);
}
@media (hover: hover) {
&:is(button):hover {
background-color: color-mix(
in srgb,
var(--color-primary) 20%,
transparent
);
}
}
&:is(button) {
cursor: pointer;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more