Compare commits
67
Commits
73b8903de1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9e8a96774 | ||
|
|
d660079c02 | ||
|
|
acfa726e61 | ||
|
|
d9378056f4 | ||
|
|
c307426f4e | ||
|
|
977dee3cb2 | ||
|
|
bca0b34636 | ||
|
|
e228d999dd | ||
|
|
aff8a9e443 | ||
|
|
25364f4523 | ||
|
|
0cd34f3037 | ||
|
|
cff493f5dd | ||
|
|
543e6eaa50 | ||
|
|
7baa32f400 | ||
|
|
e977ab77a8 | ||
|
|
8a1723e99f | ||
|
|
c06597aba2 | ||
|
|
7f3458b3ef | ||
|
|
4a32e32114 | ||
|
|
9c556ff8b9 | ||
|
|
171d8d922d | ||
|
|
848d2aca1a | ||
|
|
7f7fc1296e | ||
|
|
f62c345e2b | ||
|
|
9ae8b34005 | ||
|
|
a0e134756c | ||
|
|
97f9082f5e | ||
|
|
90b37ec708 | ||
|
|
4082adba25 | ||
|
|
b3805123b5 | ||
|
|
896b89f3b0 | ||
|
|
4a5c92f0e9 | ||
|
|
e5a5edcaae | ||
|
|
9a6bd8a714 | ||
|
|
d45c96c259 | ||
|
|
42b4e1d7e6 | ||
|
|
309614c979 | ||
|
|
98dda99c5c | ||
|
|
8e0f828dd4 | ||
|
|
9aa8690e33 | ||
|
|
dadba4d545 | ||
|
|
fffbb08d36 | ||
|
|
c247dd0217 | ||
|
|
b9a0604426 | ||
|
|
9f16e2aa56 | ||
|
|
010fa29ce0 | ||
|
|
395d58464d | ||
|
|
83c57ac96b | ||
|
|
45c19cac20 | ||
|
|
0dd14c5d5e | ||
|
|
1abb396c36 | ||
|
|
143408cd7f | ||
|
|
c8d485fe0e | ||
|
|
b86e2627d8 | ||
|
|
8f9ceb6af8 | ||
|
|
0c9c848fb1 | ||
|
|
aa41bbbc74 | ||
|
|
efc431c969 | ||
|
|
7d7cf3cbbb | ||
|
|
97ccfbbffd | ||
|
|
2c56934133 | ||
|
|
ea3a7c0a61 | ||
|
|
6a4fd6ad47 | ||
|
|
9da0c06ac1 | ||
|
|
2bb8ca45fb | ||
|
|
4469938f47 | ||
|
|
8ed9ca1951 |
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
|
)
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]))
|
||||||
|
]
|
||||||
@@ -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]
|
||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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'"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Generated
+2
@@ -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" },
|
||||||
|
|||||||
Vendored
+6
-5
@@ -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 {};
|
|
||||||
@@ -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(",") },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user