Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec5979437d | ||
|
|
59f171a62d | ||
|
|
e3559da2a7 |
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
_commit: v0.6.0
|
_commit: v0.7.1
|
||||||
_src_path: https://git.kotikot.com/templates/backend-python.git
|
_src_path: https://git.kotikot.com/templates/backend-python.git
|
||||||
author_email: h@kotikot.com
|
author_email: h@kotikot.com
|
||||||
author_name: h
|
author_name: h
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
_commit: v0.7.0
|
_commit: v0.8.0
|
||||||
_src_path: https://git.kotikot.com/templates/frontend-svelte.git
|
_src_path: https://git.kotikot.com/templates/frontend-svelte.git
|
||||||
install_deps: false
|
install_deps: false
|
||||||
layout: part
|
layout: part
|
||||||
|
|||||||
@@ -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 messages, with `#tags` and one list of them all, and rules that fire alerts, both yours to write in the web client.
|
- **Annotations and watches**: your notes on chats 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, a feed of what was deleted or edited across chats, 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, 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: `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.
|
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ 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:99a2ec276b9a91b53fc2a9b3f5a6deef2e0e854d1af80cf8f2ccc61cef4e2028
|
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""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"
|
|
||||||
)
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""message reads
|
|
||||||
|
|
||||||
Revision ID: d7a3c5e9f2b1
|
|
||||||
Revises: f8c2d6e1a4b7
|
|
||||||
Create Date: 2026-10-19 01:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "d7a3c5e9f2b1"
|
|
||||||
down_revision: str | None = "f8c2d6e1a4b7"
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
_READS = """
|
|
||||||
CREATE TABLE message_reads (
|
|
||||||
account_id integer NOT NULL,
|
|
||||||
chat_id bigint NOT NULL,
|
|
||||||
message_id bigint NOT NULL,
|
|
||||||
reader_id bigint NOT NULL,
|
|
||||||
read_at timestamptz NOT NULL,
|
|
||||||
source text NOT NULL,
|
|
||||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (account_id, chat_id, message_id, reader_id)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
_FETCHES = """
|
|
||||||
CREATE TABLE read_fetches (
|
|
||||||
account_id integer NOT NULL,
|
|
||||||
chat_id bigint NOT NULL,
|
|
||||||
message_id bigint NOT NULL,
|
|
||||||
outcome text NOT NULL,
|
|
||||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
retry_at timestamptz NOT NULL,
|
|
||||||
PRIMARY KEY (account_id, chat_id, message_id)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.execute(_READS)
|
|
||||||
op.execute(_FETCHES)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.execute("DROP TABLE IF EXISTS read_fetches")
|
|
||||||
op.execute("DROP TABLE IF EXISTS message_reads")
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
"""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)")
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""capture uptime
|
|
||||||
|
|
||||||
Revision ID: d8f4a1c6e2b7
|
|
||||||
Revises: d7a3c5e9f2b1
|
|
||||||
Create Date: 2026-10-19 03:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "d8f4a1c6e2b7"
|
|
||||||
down_revision: str | None = "d7a3c5e9f2b1"
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
_UPTIME = """
|
|
||||||
CREATE TABLE capture_uptime (
|
|
||||||
account_id integer NOT NULL,
|
|
||||||
started_at timestamptz NOT NULL,
|
|
||||||
seen_at timestamptz NOT NULL,
|
|
||||||
PRIMARY KEY (account_id, started_at)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
_BACKFILL = """
|
|
||||||
INSERT INTO capture_uptime (account_id, started_at, seen_at)
|
|
||||||
SELECT account_id, min(ts), max(ts)
|
|
||||||
FROM (
|
|
||||||
SELECT account_id, ts,
|
|
||||||
count(*) FILTER (WHERE gap > interval '30 minutes')
|
|
||||||
OVER (PARTITION BY account_id ORDER BY ts) AS stretch
|
|
||||||
FROM (
|
|
||||||
SELECT account_id, ts,
|
|
||||||
ts - lag(ts) OVER (PARTITION BY account_id ORDER BY ts) AS gap
|
|
||||||
FROM presence
|
|
||||||
) gaps
|
|
||||||
) stretches
|
|
||||||
GROUP BY account_id, stretch
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.execute(_UPTIME)
|
|
||||||
op.execute(_BACKFILL)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.execute("DROP TABLE IF EXISTS capture_uptime")
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""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)"
|
|
||||||
)
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
"""chat stats last deleted
|
|
||||||
|
|
||||||
Revision ID: f1c4a8e2d6b3
|
|
||||||
Revises: d8f1a6c3e5b7
|
|
||||||
Create Date: 2026-10-19 04:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "f1c4a8e2d6b3"
|
|
||||||
down_revision: str | None = "d8f1a6c3e5b7"
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
_APPLY = """
|
|
||||||
CREATE OR REPLACE FUNCTION chat_stats_apply() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
||||||
BEGIN
|
|
||||||
IF TG_OP = 'INSERT' THEN
|
|
||||||
INSERT INTO chat_stats AS cs (account_id, chat_id, message_count,
|
|
||||||
last_date, last_message_id,
|
|
||||||
last_text, last_sender_id{column})
|
|
||||||
VALUES (NEW.account_id, NEW.chat_id, 1,
|
|
||||||
CASE WHEN NEW.date <= now() + interval '1 day'
|
|
||||||
THEN NEW.date END,
|
|
||||||
CASE WHEN NEW.date <= now() + interval '1 day'
|
|
||||||
THEN NEW.message_id END,
|
|
||||||
NEW.text, NEW.sender_id{value})
|
|
||||||
ON CONFLICT (account_id, chat_id) DO UPDATE SET
|
|
||||||
message_count = cs.message_count + 1,
|
|
||||||
last_date = CASE WHEN chat_stats_newer(cs, EXCLUDED)
|
|
||||||
THEN EXCLUDED.last_date ELSE cs.last_date END,
|
|
||||||
last_message_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
|
|
||||||
THEN EXCLUDED.last_message_id
|
|
||||||
ELSE cs.last_message_id END,
|
|
||||||
last_text = CASE WHEN chat_stats_newer(cs, EXCLUDED)
|
|
||||||
THEN EXCLUDED.last_text ELSE cs.last_text END,
|
|
||||||
last_sender_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
|
|
||||||
THEN EXCLUDED.last_sender_id
|
|
||||||
ELSE cs.last_sender_id END{merge};
|
|
||||||
ELSE
|
|
||||||
UPDATE chat_stats
|
|
||||||
SET last_text = NEW.text, last_sender_id = NEW.sender_id{update}
|
|
||||||
WHERE account_id = NEW.account_id
|
|
||||||
AND chat_id = NEW.chat_id
|
|
||||||
AND last_message_id = NEW.message_id;
|
|
||||||
END IF;
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$
|
|
||||||
"""
|
|
||||||
|
|
||||||
_DELETED_PARTS = {
|
|
||||||
"column": ", last_deleted",
|
|
||||||
"value": ", NEW.deleted_at IS NOT NULL",
|
|
||||||
"merge": (
|
|
||||||
",\n last_deleted = CASE WHEN chat_stats_newer(cs, EXCLUDED)"
|
|
||||||
" THEN EXCLUDED.last_deleted ELSE cs.last_deleted END"
|
|
||||||
),
|
|
||||||
"update": ", last_deleted = NEW.deleted_at IS NOT NULL",
|
|
||||||
"deleted": " OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at",
|
|
||||||
}
|
|
||||||
_PLAIN_PARTS = dict.fromkeys(_DELETED_PARTS, "")
|
|
||||||
|
|
||||||
_UPDATE_TRIGGER = (
|
|
||||||
"CREATE TRIGGER messages_chat_stats_update AFTER UPDATE ON messages "
|
|
||||||
"FOR EACH ROW WHEN (OLD.text IS DISTINCT FROM NEW.text "
|
|
||||||
"OR OLD.sender_id IS DISTINCT FROM NEW.sender_id{deleted}) "
|
|
||||||
"EXECUTE FUNCTION chat_stats_apply()"
|
|
||||||
)
|
|
||||||
|
|
||||||
_BACKFILL = """
|
|
||||||
UPDATE chat_stats cs SET last_deleted = true
|
|
||||||
FROM messages m
|
|
||||||
WHERE m.account_id = cs.account_id AND m.chat_id = cs.chat_id
|
|
||||||
AND m.message_id = cs.last_message_id AND m.date = cs.last_date
|
|
||||||
AND m.deleted_at IS NOT NULL
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.execute(
|
|
||||||
"ALTER TABLE chat_stats ADD COLUMN last_deleted boolean NOT NULL DEFAULT false"
|
|
||||||
)
|
|
||||||
op.execute(_BACKFILL)
|
|
||||||
op.execute(_APPLY.format(**_DELETED_PARTS))
|
|
||||||
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
|
|
||||||
op.execute(_UPDATE_TRIGGER.format(**_DELETED_PARTS))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
|
|
||||||
op.execute(_UPDATE_TRIGGER.format(**_PLAIN_PARTS))
|
|
||||||
op.execute(_APPLY.format(**_PLAIN_PARTS))
|
|
||||||
op.execute("ALTER TABLE chat_stats DROP COLUMN last_deleted")
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""capture reliability
|
|
||||||
|
|
||||||
Revision ID: f8c2d6e1a4b7
|
|
||||||
Revises: c9e2a7f4b1d8
|
|
||||||
Create Date: 2026-10-19 03:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "f8c2d6e1a4b7"
|
|
||||||
down_revision: str | None = "c9e2a7f4b1d8"
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"jobs", sa.Column("run_after", sa.DateTime(timezone=True), nullable=True)
|
|
||||||
)
|
|
||||||
op.execute(
|
|
||||||
"ALTER TABLE accounts "
|
|
||||||
"ADD COLUMN catchup_pts integer, "
|
|
||||||
"ADD COLUMN catchup_qts integer, "
|
|
||||||
"ADD COLUMN catchup_date integer"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.execute(
|
|
||||||
"ALTER TABLE accounts "
|
|
||||||
"DROP COLUMN IF EXISTS catchup_pts, "
|
|
||||||
"DROP COLUMN IF EXISTS catchup_qts, "
|
|
||||||
"DROP COLUMN IF EXISTS catchup_date"
|
|
||||||
)
|
|
||||||
op.drop_column("jobs", "run_after")
|
|
||||||
@@ -20,7 +20,6 @@ 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,12 +21,10 @@ 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,
|
||||||
@@ -40,9 +38,7 @@ 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
|
||||||
@@ -62,14 +58,11 @@ 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()
|
||||||
@@ -98,7 +91,6 @@ 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)
|
||||||
@@ -111,11 +103,9 @@ 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)
|
||||||
|
|
||||||
|
|||||||
+24
-200
@@ -1,4 +1,3 @@
|
|||||||
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
|
||||||
@@ -21,27 +20,12 @@ from api.mcp.download import (
|
|||||||
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
||||||
from dependencies.container import container
|
from dependencies.container import container
|
||||||
from utils.files import resolve_mime
|
from utils.files import resolve_mime
|
||||||
from utils.read import (
|
from utils.read import annotations, chats, media, peers, presence, social, watches
|
||||||
annotations,
|
|
||||||
chats,
|
|
||||||
feed,
|
|
||||||
media,
|
|
||||||
peers,
|
|
||||||
people,
|
|
||||||
presence,
|
|
||||||
presence_stats,
|
|
||||||
social,
|
|
||||||
watches,
|
|
||||||
)
|
|
||||||
from utils.read.accounts import self_user_id, unknown_account_hint
|
from utils.read.accounts import self_user_id, unknown_account_hint
|
||||||
from utils.read.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.search.models import SearchFilters
|
||||||
from utils.read.read_receipts import read_info
|
|
||||||
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,
|
||||||
@@ -72,25 +56,17 @@ 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` (words by prefix over message text,
|
- Find something: `search_messages_tool` (full-text over message text and STT
|
||||||
STT transcripts and earlier versions of edited messages; filters by chat,
|
transcripts; supports chat/sender/date filters and regex).
|
||||||
sender, chat kind, media, deleted, edited, dates, regex).
|
- Forensics: `get_deleted_messages` and `get_message_versions` recover content
|
||||||
- People: `get_person` gives one person in full: profile changes, shared groups,
|
removed or edited in Telegram but kept in the archive.
|
||||||
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 (kinds `keyword`,
|
- Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and
|
||||||
`new_message`, `message_deleted`, `message_edited`, `peer_online`; params in
|
`list_alerts` reads what those rules fired.
|
||||||
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
|
||||||
@@ -100,8 +76,7 @@ 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 tools accept `limit` and `offset`; `search_messages_tool` returns
|
Paging: list/search tools accept `limit` and `offset`.
|
||||||
`next_cursor` to pass back as `cursor`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS)
|
mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS)
|
||||||
@@ -140,49 +115,27 @@ 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,
|
||||||
) -> dict[str, Any]:
|
offset: int = 0,
|
||||||
"""Search messages by words, newest first, including deleted ones.
|
) -> list[dict[str, Any]]:
|
||||||
|
"""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,
|
||||||
)
|
)
|
||||||
page = await search_messages(await _pool(), filters)
|
return _dump(await search_messages(await _pool(), filters))
|
||||||
return page.model_dump(mode="json", exclude={"terms"})
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
@@ -285,41 +238,6 @@ 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
|
||||||
@@ -377,23 +295,6 @@ async def get_callbacks(
|
|||||||
return _dump(items)
|
return _dump(items)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
|
||||||
async def get_read_info(
|
|
||||||
account_id: int, chat_id: int, message_id: int
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""When the user's own message was read, from the archive only.
|
|
||||||
|
|
||||||
`observed_at` is when the archive saw the chat read up to this message,
|
|
||||||
`exact_at` the read date Telegram reported for a private chat, `readers`
|
|
||||||
who read it in a group and when, `listened_at` when a voice or round
|
|
||||||
video was played. `outcome` is the last Telegram answer: ok, not_read_yet,
|
|
||||||
user_privacy (they hide read time), your_privacy (the user hides theirs),
|
|
||||||
chat_too_big, too_old, unavailable, flood or error.
|
|
||||||
"""
|
|
||||||
info = await read_info(await _pool(), account_id, chat_id, message_id)
|
|
||||||
return info.model_dump(mode="json") if info else None
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
async def presence_history(
|
async def presence_history(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
@@ -416,58 +317,12 @@ async def presence_history(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(name="presence_stats")
|
|
||||||
async def presence_stats_tool(
|
|
||||||
account_id: int,
|
|
||||||
peer_id: int,
|
|
||||||
days: int = 30,
|
|
||||||
tz: str = DEFAULT_TZ,
|
|
||||||
limit: int = DEFAULT_LIMIT,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Time a peer spent online, from status pushes turned into online sessions.
|
|
||||||
|
|
||||||
`tz` is an IANA zone name; days and hours are counted in it. `week` holds
|
|
||||||
the average online minutes for each weekday (0 is Monday) and hour, null
|
|
||||||
where the archive has no data for that hour. `days` gives online seconds
|
|
||||||
per day, `gaps` the stretches when capture was down, `sessions` the latest
|
|
||||||
sessions (online stretches with breaks under 5 minutes), paged by
|
|
||||||
`limit`/`offset`. `hidden` means the peer hides their last seen time.
|
|
||||||
"""
|
|
||||||
pool = await _pool()
|
|
||||||
if not 1 <= days <= MAX_DAYS:
|
|
||||||
msg = f"days must be between 1 and {MAX_DAYS}"
|
|
||||||
raise ToolError(msg)
|
|
||||||
if not await known_timezone(pool, tz):
|
|
||||||
msg = f"Unknown time zone: {tz}"
|
|
||||||
raise ToolError(msg)
|
|
||||||
stats = await presence_stats.presence_stats(
|
|
||||||
pool, account_id, peer_id, Page(limit=limit, offset=offset), days=days, tz=tz
|
|
||||||
)
|
|
||||||
return stats.model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]:
|
async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]:
|
||||||
"""Get name/username/avatar change history of a contact."""
|
"""Get name/username/avatar change history of a contact."""
|
||||||
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,
|
||||||
@@ -506,37 +361,13 @@ async def get_annotations(
|
|||||||
@mcp.tool
|
@mcp.tool
|
||||||
async def set_watch(
|
async def set_watch(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
kind: WatchKind,
|
kind: str,
|
||||||
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, clean, name=clean_name(name), enabled=enabled
|
await _pool(), account_id, kind, params or {}, enabled=enabled
|
||||||
)
|
)
|
||||||
return watch.model_dump(mode="json")
|
return watch.model_dump(mode="json")
|
||||||
|
|
||||||
@@ -551,19 +382,12 @@ 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,
|
||||||
) -> dict[str, Any]:
|
offset: int = 0,
|
||||||
"""List fired alerts from watch rules, newest first.
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List fired alerts from watch rules."""
|
||||||
Each alert names its watch (id, kind, name), the event (`message`, `edit`,
|
return _dump(
|
||||||
`transcript`, `delete`, `online`), the chat, the message preview with its
|
await watches.list_alerts(
|
||||||
sender and deleted/edited flags, the peer for `peer_online`, and `path`,
|
await _pool(), account_id, Page(limit=limit, offset=offset), seen=seen
|
||||||
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")
|
|
||||||
+19
-54
@@ -1,19 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from typing import Any
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.env import env
|
||||||
from utils.events import BG_EVENTS_CHANNEL
|
from utils.events import BG_EVENTS_CHANNEL
|
||||||
from utils.listen import Listener
|
|
||||||
from utils.read import chats as chats_read
|
from utils.read import chats as chats_read
|
||||||
from utils.read import presence as presence_read
|
from utils.read import presence as presence_read
|
||||||
from utils.read import watches as watches_read
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,15 +26,8 @@ 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(
|
self._conn: asyncpg.Connection | None = None
|
||||||
"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)
|
||||||
@@ -51,51 +39,25 @@ class EventHub:
|
|||||||
|
|
||||||
async def start(self, pool: asyncpg.Pool) -> None:
|
async def start(self, pool: asyncpg.Pool) -> None:
|
||||||
self._pool = pool
|
self._pool = pool
|
||||||
await self._listener.start()
|
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||||
|
await conn.add_listener(BG_EVENTS_CHANNEL, self._on_notify)
|
||||||
|
self._conn = conn
|
||||||
|
logger.info("Realtime hub listening on %s", BG_EVENTS_CHANNEL)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
for task in self._tasks:
|
for task in self._tasks:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
await self._listener.stop()
|
if self._conn is not None:
|
||||||
|
await self._conn.close()
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
def _on_notify(self, payload: str) -> None:
|
def _on_notify(
|
||||||
|
self, _conn: asyncpg.Connection, _pid: int, _channel: str, payload: str
|
||||||
|
) -> None:
|
||||||
task = asyncio.create_task(self._dispatch(payload))
|
task = asyncio.create_task(self._dispatch(payload))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(self._tasks.discard)
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
|
||||||
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)
|
||||||
@@ -103,12 +65,16 @@ class EventHub:
|
|||||||
return
|
return
|
||||||
account_id = event.get("account_id")
|
account_id = event.get("account_id")
|
||||||
chat_id = event.get("chat_id")
|
chat_id = event.get("chat_id")
|
||||||
account_wide = event.get("kind") == "presence"
|
scoped = event.get("kind") == "presence"
|
||||||
targets = [
|
targets = [
|
||||||
sub
|
sub
|
||||||
for sub in self._subscribers
|
for sub in self._subscribers
|
||||||
if sub.account_id == account_id
|
if sub.account_id == account_id
|
||||||
and (account_wide or sub.chat_id is None or sub.chat_id == chat_id)
|
and (
|
||||||
|
sub.chat_id == chat_id
|
||||||
|
if scoped
|
||||||
|
else sub.chat_id is None or sub.chat_id == chat_id
|
||||||
|
)
|
||||||
]
|
]
|
||||||
if not targets:
|
if not targets:
|
||||||
return
|
return
|
||||||
@@ -155,7 +121,6 @@ class EventHub:
|
|||||||
"type": "receipt",
|
"type": "receipt",
|
||||||
"chat_id": event["chat_id"],
|
"chat_id": event["chat_id"],
|
||||||
"read_up_to": event["message_id"],
|
"read_up_to": event["message_id"],
|
||||||
"read_at": datetime.now(UTC).isoformat(),
|
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import asyncpg
|
|||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
from api.timezone import checked_tz
|
|
||||||
from utils.read import analytics
|
from utils.read import analytics
|
||||||
from utils.read.models import ResponseStats, VolumeBucket
|
from utils.read.models import ResponseStats, VolumeBucket
|
||||||
from utils.read.period import DEFAULT_TZ, MAX_DAYS
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/analytics", tags=["analytics"], route_class=DishkaRoute)
|
router = APIRouter(prefix="/api/analytics", tags=["analytics"], route_class=DishkaRoute)
|
||||||
|
|
||||||
@@ -20,12 +18,9 @@ async def volume(
|
|||||||
pool: FromDishka[asyncpg.Pool],
|
pool: FromDishka[asyncpg.Pool],
|
||||||
account_id: AccountId,
|
account_id: AccountId,
|
||||||
chat_id: ChatId,
|
chat_id: ChatId,
|
||||||
days: Annotated[int, Query(ge=1, le=MAX_DAYS)] = 90,
|
days: Annotated[int, Query()] = 90,
|
||||||
tz: Annotated[str, Query()] = DEFAULT_TZ,
|
|
||||||
) -> list[VolumeBucket]:
|
) -> list[VolumeBucket]:
|
||||||
return await analytics.message_volume(
|
return await analytics.message_volume(pool, account_id, chat_id, days=days)
|
||||||
pool, account_id, chat_id, days=days, tz=await checked_tz(pool, tz)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/response-time")
|
@router.get("/response-time")
|
||||||
|
|||||||
@@ -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, NoteTag, NoteView, Page
|
from utils.read.models import DEFAULT_LIMIT, AnnotationView, Page
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute
|
prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute
|
||||||
@@ -42,26 +42,6 @@ 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
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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,14 +4,8 @@ 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, people
|
from utils.read import peers
|
||||||
from utils.read.models import (
|
from utils.read.models import PeerHistoryView, PeerView
|
||||||
PeerGroups,
|
|
||||||
PeerHistoryView,
|
|
||||||
PeerOverview,
|
|
||||||
PeerView,
|
|
||||||
ProfileChange,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute)
|
router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute)
|
||||||
|
|
||||||
@@ -41,24 +35,3 @@ 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)
|
|
||||||
@@ -5,18 +5,8 @@ import asyncpg
|
|||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
from api.timezone import checked_tz
|
from utils.read import presence
|
||||||
from utils.read import presence, presence_stats
|
from utils.read.models import DEFAULT_LIMIT, Page, PresenceHourly, PresenceSample
|
||||||
from utils.read.models import (
|
|
||||||
DEFAULT_LIMIT,
|
|
||||||
MAX_LIMIT,
|
|
||||||
Page,
|
|
||||||
PresenceHourly,
|
|
||||||
PresenceSample,
|
|
||||||
PresenceSession,
|
|
||||||
PresenceStats,
|
|
||||||
)
|
|
||||||
from utils.read.period import DEFAULT_TZ, MAX_DAYS
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/presence", tags=["presence"], route_class=DishkaRoute)
|
router = APIRouter(prefix="/api/presence", tags=["presence"], route_class=DishkaRoute)
|
||||||
|
|
||||||
@@ -24,8 +14,6 @@ AccountId = Annotated[int, Query()]
|
|||||||
PeerId = Annotated[int, Query()]
|
PeerId = Annotated[int, Query()]
|
||||||
DateFrom = Annotated[datetime | None, Query()]
|
DateFrom = Annotated[datetime | None, Query()]
|
||||||
DateTo = Annotated[datetime | None, Query()]
|
DateTo = Annotated[datetime | None, Query()]
|
||||||
Days = Annotated[int, Query(ge=1, le=MAX_DAYS)]
|
|
||||||
Tz = Annotated[str, Query()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -55,16 +43,6 @@ 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],
|
||||||
@@ -76,43 +54,3 @@ async def presence_hourly(
|
|||||||
return await presence.presence_hourly(
|
return await presence.presence_hourly(
|
||||||
pool, account_id, peer_id, date_from=date_from, date_to=date_to
|
pool, account_id, peer_id, date_from=date_from, date_to=date_to
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
async def stats(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
account_id: AccountId,
|
|
||||||
peer_id: PeerId,
|
|
||||||
days: Days = 30,
|
|
||||||
tz: Tz = DEFAULT_TZ,
|
|
||||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
|
||||||
offset: Annotated[int, Query()] = 0,
|
|
||||||
) -> PresenceStats:
|
|
||||||
return await presence_stats.presence_stats(
|
|
||||||
pool,
|
|
||||||
account_id,
|
|
||||||
peer_id,
|
|
||||||
Page(limit=limit, offset=offset),
|
|
||||||
days=days,
|
|
||||||
tz=await checked_tz(pool, tz),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions")
|
|
||||||
async def sessions(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
account_id: AccountId,
|
|
||||||
peer_id: PeerId,
|
|
||||||
days: Days = 30,
|
|
||||||
tz: Tz = DEFAULT_TZ,
|
|
||||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
|
||||||
offset: Annotated[int, Query()] = 0,
|
|
||||||
) -> list[PresenceSession]:
|
|
||||||
return await presence_stats.presence_sessions(
|
|
||||||
pool,
|
|
||||||
account_id,
|
|
||||||
peer_id,
|
|
||||||
Page(limit=limit, offset=offset),
|
|
||||||
days=days,
|
|
||||||
tz=await checked_tz(pool, tz),
|
|
||||||
)
|
|
||||||
@@ -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, SearchPage
|
from utils.search.models import SearchFilters, SearchHit
|
||||||
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()]
|
||||||
) -> SearchPage:
|
) -> list[SearchHit]:
|
||||||
return await search_messages(pool, filters)
|
return await search_messages(pool, filters)
|
||||||
@@ -2,13 +2,10 @@ from typing import Annotated
|
|||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, Query
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from utils.jobs import enqueue
|
|
||||||
from utils.read import social
|
from utils.read import social
|
||||||
from utils.read.models import CallbackView, LinkView, ReactionView, ReadInfoView
|
from utils.read.models import CallbackView, LinkView, ReactionView
|
||||||
from utils.read.read_receipts import read_info
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/messages/{chat_id}/{message_id}",
|
prefix="/api/messages/{chat_id}/{message_id}",
|
||||||
@@ -19,10 +16,6 @@ router = APIRouter(
|
|||||||
AccountId = Annotated[int, Query()]
|
AccountId = Annotated[int, Query()]
|
||||||
|
|
||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
|
||||||
account_id: int
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/callbacks")
|
@router.get("/callbacks")
|
||||||
async def message_callbacks(
|
async def message_callbacks(
|
||||||
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
||||||
@@ -42,29 +35,3 @@ async def message_links(
|
|||||||
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
||||||
) -> list[LinkView]:
|
) -> list[LinkView]:
|
||||||
return await social.get_links(pool, account_id, chat_id, message_id)
|
return await social.get_links(pool, account_id, chat_id, message_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/read")
|
|
||||||
async def message_read(
|
|
||||||
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
|
||||||
) -> ReadInfoView:
|
|
||||||
info = await read_info(pool, account_id, chat_id, message_id)
|
|
||||||
if info is None:
|
|
||||||
raise HTTPException(status_code=404, detail="message not found")
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/read/refresh")
|
|
||||||
async def refresh_message_read(
|
|
||||||
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, body: RefreshRequest
|
|
||||||
) -> dict[str, int | None]:
|
|
||||||
info = await read_info(pool, body.account_id, chat_id, message_id)
|
|
||||||
if info is None or not info.can_fetch:
|
|
||||||
return {"job_id": None}
|
|
||||||
job_id = await enqueue(
|
|
||||||
pool,
|
|
||||||
body.account_id,
|
|
||||||
"fetch_read_info",
|
|
||||||
{"chat_id": chat_id, "message_id": message_id},
|
|
||||||
)
|
|
||||||
return {"job_id": job_id}
|
|
||||||
@@ -6,88 +6,55 @@ 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 AlertPage, WatchItem, WatchView
|
from utils.read.models import DEFAULT_LIMIT, AlertView, Page, 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 WatchUpdate(BaseModel):
|
class WatchCreate(BaseModel):
|
||||||
kind: str | None = None
|
account_id: int
|
||||||
|
kind: str
|
||||||
params: dict[str, Any] = {}
|
params: dict[str, Any] = {}
|
||||||
name: str | None = None
|
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class WatchCreate(WatchUpdate):
|
class WatchUpdate(BaseModel):
|
||||||
account_id: int
|
params: dict[str, Any] = {}
|
||||||
kind: str
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class SeenAll(BaseModel):
|
|
||||||
account_id: int
|
|
||||||
up_to: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _params(kind: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
return normalize_params(kind, params)
|
|
||||||
except WatchParamsError as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
async def _item(pool: asyncpg.Pool, watch: WatchView) -> WatchItem:
|
|
||||||
return (await watches.describe_watches(pool, watch.account_id, [watch]))[0]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/watches")
|
@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[WatchItem]:
|
) -> list[WatchView]:
|
||||||
rows = await watches.list_watches(pool, account_id)
|
return 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) -> WatchItem:
|
async def create_watch(pool: FromDishka[asyncpg.Pool], body: WatchCreate) -> WatchView:
|
||||||
watch = await watches.create_watch(
|
return await watches.create_watch(
|
||||||
pool,
|
pool, body.account_id, body.kind, body.params, enabled=body.enabled
|
||||||
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) -> WatchItem:
|
async def get_watch(pool: FromDishka[asyncpg.Pool], watch_id: int) -> WatchView:
|
||||||
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 await _item(pool, watch)
|
return 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
|
||||||
) -> WatchItem:
|
) -> WatchView:
|
||||||
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,
|
pool, watch_id, body.params, enabled=body.enabled
|
||||||
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 await _item(pool, watch)
|
return watch
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/watches/{watch_id}", status_code=204)
|
@router.delete("/watches/{watch_id}", status_code=204)
|
||||||
@@ -101,29 +68,14 @@ 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,
|
||||||
before: Annotated[int | None, Query()] = None,
|
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||||
limit: Annotated[int, Query()] = watches.ALERT_PAGE,
|
offset: Annotated[int, Query()] = 0,
|
||||||
) -> AlertPage:
|
) -> list[AlertView]:
|
||||||
return await watches.list_alerts(
|
return await watches.list_alerts(
|
||||||
pool, account_id, seen=seen, before=before, limit=limit
|
pool, account_id, Page(limit=limit, offset=offset), seen=seen
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@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):
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import asyncpg
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
from utils.read.period import known_timezone
|
|
||||||
|
|
||||||
|
|
||||||
async def checked_tz(pool: asyncpg.Pool, tz: str) -> str:
|
|
||||||
if not await known_timezone(pool, tz):
|
|
||||||
raise HTTPException(status_code=422, detail=f"Unknown time zone: {tz}")
|
|
||||||
return tz
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -20,16 +20,11 @@ async def on_deleted_messages(client: PyroClient, messages: list[Message]) -> No
|
|||||||
box.append(message.id)
|
box.append(message.id)
|
||||||
else:
|
else:
|
||||||
channels.setdefault(chat_id, []).append(message.id)
|
channels.setdefault(chat_id, []).append(message.id)
|
||||||
ctx.pending_deletes.remember(None, box)
|
|
||||||
for chat_id, ids in channels.items():
|
|
||||||
ctx.pending_deletes.remember(chat_id, ids)
|
|
||||||
if box:
|
if box:
|
||||||
await repository.mark_deleted_box(ctx.pool, ctx.account_id, box)
|
await repository.mark_deleted_box(ctx.pool, ctx.account_id, box)
|
||||||
await 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ from pyrogram.types import Message
|
|||||||
|
|
||||||
from userbot.modules.capture import repository
|
from userbot.modules.capture import repository
|
||||||
from userbot.modules.capture.chat_meta import meta_from_chat
|
from userbot.modules.capture.chat_meta import meta_from_chat
|
||||||
from userbot.modules.capture.message import message_row
|
from userbot.modules.capture.message import sender_id
|
||||||
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, media_unique_id, self_destruct_ttl
|
||||||
from userbot.modules.watches.seen import seen_message
|
from userbot.modules.raw_json import raw_json
|
||||||
from utils.events import notify_bg_event
|
from utils.events import notify_bg_event
|
||||||
|
|
||||||
|
|
||||||
@@ -23,12 +23,22 @@ 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.messages and toggles.track_edits_deletes):
|
if not toggles.track_edits_deletes:
|
||||||
return
|
return
|
||||||
changed = await repository.add_version(
|
changed = await repository.add_version(
|
||||||
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
|
ctx.pool,
|
||||||
|
ctx.account_id,
|
||||||
|
chat_id,
|
||||||
|
message.id,
|
||||||
|
message.date,
|
||||||
|
sender_id(message),
|
||||||
|
message.text or message.caption,
|
||||||
|
raw_json(message),
|
||||||
|
message.edit_date,
|
||||||
|
media_unique_id(message),
|
||||||
|
has_media=message.media is not None,
|
||||||
|
is_self_destruct=self_destruct_ttl(message) is not None,
|
||||||
)
|
)
|
||||||
if not changed:
|
if not changed:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -21,7 +20,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_message(seen_message(meta.chat_id, message))
|
await ctx.watches.on_text(meta.chat_id, message.id, message.text or message.caption)
|
||||||
toggles = ctx.resolve(meta)
|
toggles = ctx.resolve(meta)
|
||||||
if not toggles.messages:
|
if not toggles.messages:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -13,21 +13,18 @@ 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)
|
await repository.insert_status(
|
||||||
status = user.status.name.lower()
|
ctx.pool,
|
||||||
if ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).presence:
|
ctx.account_id,
|
||||||
await repository.insert_status(
|
user.id,
|
||||||
ctx.pool,
|
datetime.now(UTC),
|
||||||
ctx.account_id,
|
user.status.name.lower(),
|
||||||
user.id,
|
user.last_online_date,
|
||||||
at,
|
user.next_offline_date,
|
||||||
status,
|
raw_json(user.raw),
|
||||||
user.last_online_date,
|
)
|
||||||
user.next_offline_date,
|
await ctx.watches.on_status(user.id, is_online=user.status.name.lower() == "online")
|
||||||
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, status, at, user.last_online_date)
|
|
||||||
|
|
||||||
|
|
||||||
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
|
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
|
||||||
@@ -25,14 +25,8 @@ async def _handle_user(
|
|||||||
current = await get_peer(ctx.pool, ctx.account_id, update.user_id)
|
current = await get_peer(ctx.pool, ctx.account_id, update.user_id)
|
||||||
if current == fields:
|
if current == fields:
|
||||||
return
|
return
|
||||||
is_bot = bool(getattr(raw_user, "bot", False))
|
|
||||||
await write_profile(
|
await write_profile(
|
||||||
ctx.pool,
|
ctx.pool, ctx.account_id, update.user_id, fields, raw_json(raw_user)
|
||||||
ctx.account_id,
|
|
||||||
update.user_id,
|
|
||||||
fields,
|
|
||||||
raw_json(raw_user),
|
|
||||||
history=ctx.resolve_peer(update.user_id, is_bot=is_bot).profile_history,
|
|
||||||
)
|
)
|
||||||
changed_photo = current is None or current.photo_unique_id != photo_unique_id
|
changed_photo = current is None or current.photo_unique_id != photo_unique_id
|
||||||
if photo_file_id and photo_unique_id and changed_photo:
|
if photo_file_id and photo_unique_id and changed_photo:
|
||||||
@@ -57,14 +51,7 @@ async def _merge_partial(
|
|||||||
merged = replace(current or ProfileFields(), **overrides)
|
merged = replace(current or ProfileFields(), **overrides)
|
||||||
if merged == current:
|
if merged == current:
|
||||||
return
|
return
|
||||||
await write_profile(
|
await write_profile(ctx.pool, ctx.account_id, peer_id, merged, raw_str)
|
||||||
ctx.pool,
|
|
||||||
ctx.account_id,
|
|
||||||
peer_id,
|
|
||||||
merged,
|
|
||||||
raw_str,
|
|
||||||
history=ctx.resolve_peer(peer_id).profile_history,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle(
|
async def handle(
|
||||||
|
|||||||
@@ -7,35 +7,25 @@ from userbot.modules.raw_json import raw_json
|
|||||||
from userbot.modules.read_receipts import repository
|
from userbot.modules.read_receipts import repository
|
||||||
from utils.events import notify_bg_event
|
from utils.events import notify_bg_event
|
||||||
|
|
||||||
HANDLES = (raw.types.UpdateReadHistoryOutbox, raw.types.UpdateReadChannelOutbox)
|
HANDLES = (raw.types.UpdateReadHistoryOutbox,)
|
||||||
|
|
||||||
|
|
||||||
def _watermark(update: raw.base.Update) -> tuple[int, int] | None:
|
|
||||||
if isinstance(update, raw.types.UpdateReadHistoryOutbox):
|
|
||||||
return utils.get_peer_id(update.peer), update.max_id
|
|
||||||
if isinstance(update, raw.types.UpdateReadChannelOutbox):
|
|
||||||
peer = raw.types.PeerChannel(channel_id=update.channel_id)
|
|
||||||
return utils.get_peer_id(peer), update.max_id
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def handle(
|
async def handle(
|
||||||
client: PyroClient, update: raw.base.Update, _users: dict, _chats: dict
|
client: PyroClient, update: raw.base.Update, _users: dict, _chats: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
ctx = client.capture
|
ctx = client.capture
|
||||||
watermark = _watermark(update)
|
if ctx is None or not isinstance(update, raw.types.UpdateReadHistoryOutbox):
|
||||||
if ctx is None or watermark is None:
|
|
||||||
return
|
return
|
||||||
chat_id, max_id = watermark
|
chat_id = utils.get_peer_id(update.peer)
|
||||||
await repository.insert_read(
|
await repository.insert_read(
|
||||||
ctx.pool,
|
ctx.pool,
|
||||||
ctx.account_id,
|
ctx.account_id,
|
||||||
chat_id,
|
chat_id,
|
||||||
chat_id,
|
chat_id,
|
||||||
datetime.now(UTC),
|
datetime.now(UTC),
|
||||||
max_id,
|
update.max_id,
|
||||||
raw_json(update),
|
raw_json(update),
|
||||||
)
|
)
|
||||||
await notify_bg_event(
|
await notify_bg_event(
|
||||||
ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=max_id
|
ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=update.max_id
|
||||||
)
|
)
|
||||||
@@ -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_transcript(meta.chat_id, update.msg_id, update.text)
|
await ctx.watches.on_text(meta.chat_id, update.msg_id, update.text)
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
from pyrogram.types import Story
|
from pyrogram.types import Story
|
||||||
|
|
||||||
from userbot.modules.client import PyroClient
|
from userbot.modules.client import PyroClient
|
||||||
from userbot.modules.stories.service import save_story, story_peer_id
|
from userbot.modules.stories.service import save_story
|
||||||
|
|
||||||
|
|
||||||
@PyroClient.on_story()
|
@PyroClient.on_story()
|
||||||
async def on_story(client: PyroClient, story: Story) -> None:
|
async def on_story(client: PyroClient, story: Story) -> None:
|
||||||
ctx = client.capture
|
if client.capture is None:
|
||||||
if ctx is None:
|
|
||||||
return
|
return
|
||||||
peer_id = story_peer_id(story)
|
await save_story(client, client.capture, story)
|
||||||
is_bot = bool(story.from_user and story.from_user.is_bot)
|
|
||||||
if peer_id > 0 and not ctx.resolve_peer(peer_id, is_bot=is_bot).stories:
|
|
||||||
return
|
|
||||||
await save_story(client, ctx, story)
|
|
||||||
|
|
||||||
|
|
||||||
handlers = on_story.handlers # ty: ignore[unresolved-attribute]
|
handlers = on_story.handlers # ty: ignore[unresolved-attribute]
|
||||||
@@ -2,11 +2,10 @@ import asyncpg
|
|||||||
from pyrogram import Client
|
from pyrogram import Client
|
||||||
|
|
||||||
from userbot.modules.capture.identity import ChatMetaCache, PeerIdentityCache
|
from userbot.modules.capture.identity import ChatMetaCache, PeerIdentityCache
|
||||||
from userbot.modules.capture.pending import PendingDeletes
|
|
||||||
from userbot.modules.contacts import ContactCache
|
from userbot.modules.contacts import ContactCache
|
||||||
from userbot.modules.folders import FolderCache
|
from userbot.modules.folders import FolderCache
|
||||||
from userbot.modules.watches import WatchCache
|
from userbot.modules.watches import WatchCache
|
||||||
from utils.policy.models import CaptureToggles, ChatKind, ChatMeta, PolicySet
|
from utils.policy.models import CaptureToggles, ChatMeta, PolicySet
|
||||||
from utils.policy.repository import load_policy_set
|
from utils.policy.repository import load_policy_set
|
||||||
from utils.policy.resolver import resolve
|
from utils.policy.resolver import resolve
|
||||||
from utils.storage import ContentAddressedStorage
|
from utils.storage import ContentAddressedStorage
|
||||||
@@ -29,7 +28,6 @@ class CaptureContext:
|
|||||||
self.watches = WatchCache(pool, account_id)
|
self.watches = WatchCache(pool, account_id)
|
||||||
self.peer_identity = PeerIdentityCache()
|
self.peer_identity = PeerIdentityCache()
|
||||||
self.chat_meta = ChatMetaCache()
|
self.chat_meta = ChatMetaCache()
|
||||||
self.pending_deletes = PendingDeletes()
|
|
||||||
self.policies = PolicySet()
|
self.policies = PolicySet()
|
||||||
|
|
||||||
async def reload_policies(self) -> None:
|
async def reload_policies(self) -> None:
|
||||||
@@ -38,16 +36,6 @@ class CaptureContext:
|
|||||||
def resolve(self, chat: ChatMeta) -> CaptureToggles:
|
def resolve(self, chat: ChatMeta) -> CaptureToggles:
|
||||||
return resolve(chat, self.folders.folders, self.policies)
|
return resolve(chat, self.folders.folders, self.policies)
|
||||||
|
|
||||||
def resolve_peer(self, peer_id: int, *, is_bot: bool = False) -> CaptureToggles:
|
|
||||||
return self.resolve(
|
|
||||||
ChatMeta(
|
|
||||||
chat_id=peer_id,
|
|
||||||
kind=ChatKind.DM,
|
|
||||||
is_bot=is_bot,
|
|
||||||
is_contact=peer_id in self.contacts.ids,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def build_capture_context(
|
async def build_capture_context(
|
||||||
client: Client,
|
client: Client,
|
||||||
|
|||||||
@@ -58,14 +58,7 @@ async def _capture_peer(message: Message, ctx: CaptureContext) -> None:
|
|||||||
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||||
if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields):
|
if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields):
|
||||||
return
|
return
|
||||||
await write_profile(
|
await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
|
||||||
ctx.pool,
|
|
||||||
ctx.account_id,
|
|
||||||
user.id,
|
|
||||||
fields,
|
|
||||||
raw_json(user),
|
|
||||||
history=ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).profile_history,
|
|
||||||
)
|
|
||||||
if photo_file_id and photo_unique_id:
|
if photo_file_id and photo_unique_id:
|
||||||
from userbot.modules.avatars import note_avatar # noqa: PLC0415
|
from userbot.modules.avatars import note_avatar # noqa: PLC0415
|
||||||
|
|
||||||
@@ -89,15 +82,17 @@ 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 record_chat_meta # noqa: PLC0415
|
from userbot.modules.groups.repository import insert_chat_history # noqa: PLC0415
|
||||||
|
|
||||||
await record_chat_meta(
|
await insert_chat_history(
|
||||||
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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pyrogram import Client
|
from pyrogram import Client
|
||||||
from pyrogram.types import Message
|
from pyrogram.types import Message
|
||||||
|
|
||||||
from userbot.modules.capture import repository
|
from userbot.modules.capture import repository
|
||||||
from userbot.modules.capture.context import CaptureContext
|
from userbot.modules.capture.context import CaptureContext
|
||||||
from userbot.modules.capture.repository import MessageRow
|
|
||||||
from userbot.modules.links import extract_links
|
from userbot.modules.links import extract_links
|
||||||
from userbot.modules.links import repository as links_repository
|
from userbot.modules.links import repository as links_repository
|
||||||
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
|
from userbot.modules.media import capture_media, self_destruct_ttl
|
||||||
from userbot.modules.raw_json import raw_json
|
from userbot.modules.raw_json import raw_json
|
||||||
from utils.policy.models import CaptureToggles
|
from utils.policy.models import CaptureToggles
|
||||||
|
|
||||||
@@ -37,39 +34,27 @@ def callbacks(message: Message) -> list[tuple[int, str | None, bytes | None]]:
|
|||||||
return buttons
|
return buttons
|
||||||
|
|
||||||
|
|
||||||
def message_row(message: Message, chat_id: int, date: datetime) -> MessageRow:
|
|
||||||
return MessageRow(
|
|
||||||
chat_id=chat_id,
|
|
||||||
message_id=message.id,
|
|
||||||
date=date,
|
|
||||||
sender_id=sender_id(message),
|
|
||||||
text=message.text or message.caption,
|
|
||||||
raw=raw_json(message),
|
|
||||||
has_media=message.media is not None,
|
|
||||||
is_self_destruct=self_destruct_ttl(message) is not None,
|
|
||||||
edit_date=message.edit_date,
|
|
||||||
media_unique_id=media_unique_id(message),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def capture_message(
|
async def capture_message(
|
||||||
client: Client, message: Message, ctx: CaptureContext, toggles: CaptureToggles
|
client: Client, message: Message, ctx: CaptureContext, toggles: CaptureToggles
|
||||||
) -> None:
|
) -> None:
|
||||||
if message.empty or message.chat is None or message.date is None:
|
if message.empty or message.chat is None or message.date is None:
|
||||||
return
|
return
|
||||||
chat_id = message.chat.id or 0
|
chat_id = message.chat.id or 0
|
||||||
current = await repository.upsert_message(
|
await repository.upsert_message(
|
||||||
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
|
ctx.pool,
|
||||||
|
ctx.account_id,
|
||||||
|
chat_id,
|
||||||
|
message.id,
|
||||||
|
message.date,
|
||||||
|
sender_id(message),
|
||||||
|
message.text or message.caption,
|
||||||
|
raw_json(message),
|
||||||
|
has_media=message.media is not None,
|
||||||
|
is_self_destruct=self_destruct_ttl(message) is not None,
|
||||||
)
|
)
|
||||||
if ctx.pending_deletes.take(chat_id, message.id):
|
|
||||||
await repository.mark_deleted_late(
|
|
||||||
ctx.pool, ctx.account_id, chat_id, message.id, message.date
|
|
||||||
)
|
|
||||||
from userbot.modules.capture.identity import capture_identity # noqa: PLC0415
|
from userbot.modules.capture.identity import capture_identity # noqa: PLC0415
|
||||||
|
|
||||||
await capture_identity(message, ctx)
|
await capture_identity(message, ctx)
|
||||||
if not current:
|
|
||||||
return
|
|
||||||
await capture_media(client, message, ctx, chat_id, message.id, toggles)
|
await capture_media(client, message, ctx, chat_id, message.id, toggles)
|
||||||
buttons = callbacks(message)
|
buttons = callbacks(message)
|
||||||
if buttons:
|
if buttons:
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
|
|
||||||
|
|
||||||
PENDING_DELETE_SECONDS = 120.0
|
|
||||||
PENDING_DELETE_LIMIT = 10_000
|
|
||||||
|
|
||||||
DeleteKey = tuple[int | None, int]
|
|
||||||
|
|
||||||
|
|
||||||
def _key(chat_id: int | None, message_id: int) -> DeleteKey:
|
|
||||||
if chat_id is None or chat_id > CHANNEL_ID_THRESHOLD:
|
|
||||||
return None, message_id
|
|
||||||
return chat_id, message_id
|
|
||||||
|
|
||||||
|
|
||||||
class PendingDeletes:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._expires: dict[DeleteKey, float] = {}
|
|
||||||
|
|
||||||
def remember(self, chat_id: int | None, message_ids: list[int]) -> None:
|
|
||||||
now = time.monotonic()
|
|
||||||
while self._expires:
|
|
||||||
oldest = next(iter(self._expires))
|
|
||||||
fresh = self._expires[oldest] > now
|
|
||||||
if fresh and len(self._expires) < PENDING_DELETE_LIMIT:
|
|
||||||
break
|
|
||||||
del self._expires[oldest]
|
|
||||||
for message_id in message_ids:
|
|
||||||
key = _key(chat_id, message_id)
|
|
||||||
self._expires.pop(key, None)
|
|
||||||
self._expires[key] = now + PENDING_DELETE_SECONDS
|
|
||||||
|
|
||||||
def take(self, chat_id: int, message_id: int) -> bool:
|
|
||||||
expires = self._expires.pop(_key(chat_id, message_id), None)
|
|
||||||
return expires is not None and expires > time.monotonic()
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
@@ -22,19 +21,13 @@ _TOUCH_EDITED = """
|
|||||||
INSERT INTO messages
|
INSERT INTO messages
|
||||||
(account_id, chat_id, message_id, date, sender_id, text, raw,
|
(account_id, chat_id, message_id, date, sender_id, text, raw,
|
||||||
has_media, is_self_destruct, edited_at)
|
has_media, is_self_destruct, edited_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, CASE WHEN $10 THEN now() END)
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, now())
|
||||||
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
|
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
|
||||||
text = EXCLUDED.text,
|
text = EXCLUDED.text,
|
||||||
raw = EXCLUDED.raw,
|
raw = EXCLUDED.raw,
|
||||||
has_media = EXCLUDED.has_media,
|
has_media = EXCLUDED.has_media,
|
||||||
is_self_destruct = EXCLUDED.is_self_destruct,
|
is_self_destruct = EXCLUDED.is_self_destruct,
|
||||||
edited_at = coalesce(EXCLUDED.edited_at, messages.edited_at)
|
edited_at = now()
|
||||||
"""
|
|
||||||
|
|
||||||
_LOCK_MESSAGE = """
|
|
||||||
SELECT pg_advisory_xact_lock(
|
|
||||||
hashtextextended(concat_ws(':', 'message', $1::int, $2::bigint, $3::bigint), 0)
|
|
||||||
)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_FIND_DELETED = """
|
_FIND_DELETED = """
|
||||||
@@ -47,12 +40,6 @@ UPDATE messages SET deleted_at = now()
|
|||||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4
|
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_MARK_DELETED_LATE = """
|
|
||||||
UPDATE messages SET deleted_at = now()
|
|
||||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4
|
|
||||||
AND deleted_at IS NULL
|
|
||||||
"""
|
|
||||||
|
|
||||||
_INSERT_VERSION = """
|
_INSERT_VERSION = """
|
||||||
INSERT INTO message_versions
|
INSERT INTO message_versions
|
||||||
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
||||||
@@ -65,7 +52,7 @@ INSERT INTO message_versions
|
|||||||
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
||||||
SELECT account_id, chat_id, message_id, clock_timestamp(), NULL, text, raw
|
SELECT account_id, chat_id, message_id, clock_timestamp(), NULL, text, raw
|
||||||
FROM messages m
|
FROM messages m
|
||||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
|
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM message_versions v
|
SELECT 1 FROM message_versions v
|
||||||
WHERE v.account_id = m.account_id AND v.chat_id = m.chat_id
|
WHERE v.account_id = m.account_id AND v.chat_id = m.chat_id
|
||||||
@@ -74,37 +61,15 @@ WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
|
|||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_INSERT_PAST_VERSION = """
|
|
||||||
INSERT INTO message_versions
|
|
||||||
(account_id, chat_id, message_id, observed_at, edit_date, text, raw)
|
|
||||||
SELECT $1::int, $2::bigint, $3::bigint,
|
|
||||||
coalesce(
|
|
||||||
(SELECT min(v.observed_at) - interval '1 microsecond'
|
|
||||||
FROM message_versions v
|
|
||||||
WHERE v.account_id = $1 AND v.chat_id = $2 AND v.message_id = $3
|
|
||||||
AND v.edit_date > $4::timestamptz),
|
|
||||||
$4::timestamptz,
|
|
||||||
$5::timestamptz
|
|
||||||
),
|
|
||||||
$4::timestamptz, $6::text, $7::jsonb
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM message_versions v
|
|
||||||
WHERE v.account_id = $1 AND v.chat_id = $2 AND v.message_id = $3
|
|
||||||
AND v.edit_date IS NOT DISTINCT FROM $4::timestamptz
|
|
||||||
AND v.text IS NOT DISTINCT FROM $6::text
|
|
||||||
)
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
"""
|
|
||||||
|
|
||||||
_CURRENT_CONTENT = """
|
_CURRENT_CONTENT = """
|
||||||
SELECT
|
SELECT
|
||||||
m.text AS text,
|
m.text AS text,
|
||||||
m.raw->>'edit_date' AS edit_date,
|
|
||||||
(SELECT d.unique_id FROM media d
|
(SELECT d.unique_id FROM media d
|
||||||
WHERE d.account_id = m.account_id AND d.chat_id = m.chat_id
|
WHERE d.account_id = m.account_id AND d.chat_id = m.chat_id
|
||||||
AND d.message_id = m.message_id) AS media_unique_id
|
AND d.message_id = m.message_id) AS media_unique_id
|
||||||
FROM messages m
|
FROM messages m
|
||||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
|
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
|
||||||
|
ORDER BY m.date DESC LIMIT 1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_CURRENT_MEDIA = """
|
_CURRENT_MEDIA = """
|
||||||
@@ -156,110 +121,33 @@ WHERE evicted_key = $2 OR (unique_id = $1 AND evicted_at IS NOT NULL)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
async def upsert_message( # noqa: PLR0913
|
||||||
class MessageRow:
|
pool: asyncpg.Pool,
|
||||||
chat_id: int
|
account_id: int,
|
||||||
message_id: int
|
chat_id: int,
|
||||||
date: datetime
|
message_id: int,
|
||||||
sender_id: int | None
|
date: datetime,
|
||||||
text: str | None
|
sender_id: int | None,
|
||||||
raw: str
|
text: str | None,
|
||||||
has_media: bool
|
raw: str,
|
||||||
is_self_destruct: bool
|
*,
|
||||||
edit_date: datetime | None
|
has_media: bool,
|
||||||
media_unique_id: str | None
|
is_self_destruct: bool,
|
||||||
|
|
||||||
@property
|
|
||||||
def columns(self) -> tuple:
|
|
||||||
return (
|
|
||||||
self.chat_id,
|
|
||||||
self.message_id,
|
|
||||||
self.date,
|
|
||||||
self.sender_id,
|
|
||||||
self.text,
|
|
||||||
self.raw,
|
|
||||||
self.has_media,
|
|
||||||
self.is_self_destruct,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _lock_current(
|
|
||||||
conn: asyncpg.Connection, account_id: int, row: MessageRow
|
|
||||||
) -> asyncpg.Record | None:
|
|
||||||
await conn.execute(_LOCK_MESSAGE, account_id, row.chat_id, row.message_id)
|
|
||||||
return await conn.fetchrow(
|
|
||||||
_CURRENT_CONTENT, account_id, row.chat_id, row.message_id, row.date
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_stale(current: asyncpg.Record, row: MessageRow) -> bool:
|
|
||||||
stored = current["edit_date"]
|
|
||||||
if stored is None:
|
|
||||||
return False
|
|
||||||
if row.edit_date is None:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
return row.edit_date < datetime.fromisoformat(stored)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _record_past(
|
|
||||||
conn: asyncpg.Connection, account_id: int, row: MessageRow
|
|
||||||
) -> None:
|
) -> None:
|
||||||
await conn.execute(
|
await pool.execute(
|
||||||
_INSERT_PAST_VERSION,
|
_UPSERT_MESSAGE,
|
||||||
account_id,
|
account_id,
|
||||||
row.chat_id,
|
chat_id,
|
||||||
row.message_id,
|
message_id,
|
||||||
row.edit_date,
|
date,
|
||||||
row.date,
|
sender_id,
|
||||||
row.text,
|
text,
|
||||||
row.raw,
|
raw,
|
||||||
|
has_media,
|
||||||
|
is_self_destruct,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _record_edit(
|
|
||||||
conn: asyncpg.Connection, account_id: int, row: MessageRow, *, mark_edited: bool
|
|
||||||
) -> None:
|
|
||||||
await conn.execute(
|
|
||||||
_SNAPSHOT_ORIGINAL, account_id, row.chat_id, row.message_id, row.date
|
|
||||||
)
|
|
||||||
await conn.execute(_TOUCH_EDITED, account_id, *row.columns, mark_edited)
|
|
||||||
await conn.execute(
|
|
||||||
_INSERT_VERSION,
|
|
||||||
account_id,
|
|
||||||
row.chat_id,
|
|
||||||
row.message_id,
|
|
||||||
row.edit_date,
|
|
||||||
row.text,
|
|
||||||
row.raw,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def upsert_message(pool: asyncpg.Pool, account_id: int, row: MessageRow) -> bool:
|
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
|
||||||
current = await _lock_current(conn, account_id, row)
|
|
||||||
if current is None:
|
|
||||||
await conn.execute(_UPSERT_MESSAGE, account_id, *row.columns)
|
|
||||||
return True
|
|
||||||
stored_media = current["media_unique_id"]
|
|
||||||
changed = current["text"] != row.text or (
|
|
||||||
stored_media is not None and stored_media != row.media_unique_id
|
|
||||||
)
|
|
||||||
if _is_stale(current, row):
|
|
||||||
if changed:
|
|
||||||
await _record_past(conn, account_id, row)
|
|
||||||
return False
|
|
||||||
if changed:
|
|
||||||
await _record_edit(
|
|
||||||
conn, account_id, row, mark_edited=row.edit_date is not None
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await conn.execute(_UPSERT_MESSAGE, account_id, *row.columns)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def max_message_id(
|
async def max_message_id(
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int
|
pool: asyncpg.Pool, account_id: int, chat_id: int
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
@@ -300,29 +188,48 @@ async def mark_deleted_channel(
|
|||||||
await _mark_deleted(pool, account_id, rows)
|
await _mark_deleted(pool, account_id, rows)
|
||||||
|
|
||||||
|
|
||||||
async def add_version(pool: asyncpg.Pool, account_id: int, row: MessageRow) -> bool:
|
async def add_version( # noqa: PLR0913
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
account_id: int,
|
||||||
|
chat_id: int,
|
||||||
|
message_id: int,
|
||||||
|
date: datetime,
|
||||||
|
sender_id: int | None,
|
||||||
|
text: str | None,
|
||||||
|
raw: str,
|
||||||
|
edit_date: datetime | None,
|
||||||
|
media_unique_id: str | None,
|
||||||
|
*,
|
||||||
|
has_media: bool,
|
||||||
|
is_self_destruct: bool,
|
||||||
|
) -> bool:
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
current = await _lock_current(conn, account_id, row)
|
current = await conn.fetchrow(_CURRENT_CONTENT, account_id, chat_id, message_id)
|
||||||
if current is not None:
|
text_changed = current is None or current["text"] != text
|
||||||
changed = (
|
media_changed = (
|
||||||
current["text"] != row.text
|
current is not None and current["media_unique_id"] != media_unique_id
|
||||||
or current["media_unique_id"] != row.media_unique_id
|
)
|
||||||
)
|
if not (text_changed or media_changed):
|
||||||
if not changed:
|
return False
|
||||||
return False
|
await conn.execute(_SNAPSHOT_ORIGINAL, account_id, chat_id, message_id)
|
||||||
if _is_stale(current, row):
|
await conn.execute(
|
||||||
await _record_past(conn, account_id, row)
|
_TOUCH_EDITED,
|
||||||
return False
|
account_id,
|
||||||
await _record_edit(conn, account_id, row, mark_edited=True)
|
chat_id,
|
||||||
|
message_id,
|
||||||
|
date,
|
||||||
|
sender_id,
|
||||||
|
text,
|
||||||
|
raw,
|
||||||
|
has_media,
|
||||||
|
is_self_destruct,
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
_INSERT_VERSION, account_id, chat_id, message_id, edit_date, text, raw
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def mark_deleted_late(
|
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int, date: datetime
|
|
||||||
) -> None:
|
|
||||||
await pool.execute(_MARK_DELETED_LATE, account_id, chat_id, message_id, date)
|
|
||||||
|
|
||||||
|
|
||||||
async def current_media(
|
async def current_media(
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
||||||
) -> asyncpg.Record | None:
|
) -> asyncpg.Record | None:
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from userbot.modules.catchup.service import SavedState, catch_up, read_saved_state
|
|
||||||
|
|
||||||
__all__ = ["SavedState", "catch_up", "read_saved_state"]
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
_LOAD = """
|
|
||||||
SELECT catchup_pts, catchup_qts, catchup_date FROM accounts WHERE account_id = $1
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SAVE = """
|
|
||||||
UPDATE accounts SET catchup_pts = $2, catchup_qts = $3, catchup_date = $4
|
|
||||||
WHERE account_id = $1
|
|
||||||
"""
|
|
||||||
|
|
||||||
_CLEAR = """
|
|
||||||
UPDATE accounts SET catchup_pts = NULL, catchup_qts = NULL, catchup_date = NULL
|
|
||||||
WHERE account_id = $1
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class UpdateCursor:
|
|
||||||
pts: int
|
|
||||||
qts: int
|
|
||||||
date: int
|
|
||||||
|
|
||||||
|
|
||||||
async def load_cursor(pool: asyncpg.Pool, account_id: int) -> UpdateCursor | None:
|
|
||||||
row = await pool.fetchrow(_LOAD, account_id)
|
|
||||||
if row is None or row["catchup_pts"] is None:
|
|
||||||
return None
|
|
||||||
return UpdateCursor(row["catchup_pts"], row["catchup_qts"], row["catchup_date"])
|
|
||||||
|
|
||||||
|
|
||||||
async def save_cursor(
|
|
||||||
pool: asyncpg.Pool, account_id: int, cursor: UpdateCursor
|
|
||||||
) -> None:
|
|
||||||
await pool.execute(_SAVE, account_id, cursor.pts, cursor.qts, cursor.date)
|
|
||||||
|
|
||||||
|
|
||||||
async def clear_cursor(pool: asyncpg.Pool, account_id: int) -> None:
|
|
||||||
await pool.execute(_CLEAR, account_id)
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
from contextlib import closing
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pyrogram import raw, utils
|
|
||||||
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
|
|
||||||
|
|
||||||
from userbot.modules.capture.chat_meta import meta_from_peer
|
|
||||||
from userbot.modules.capture.context import CaptureContext
|
|
||||||
from userbot.modules.catchup import repository
|
|
||||||
from userbot.modules.catchup.repository import UpdateCursor
|
|
||||||
from userbot.modules.client import PyroClient
|
|
||||||
from utils.logging import logger
|
|
||||||
from utils.policy.models import ChatKind
|
|
||||||
|
|
||||||
BOX_SLICES = 50
|
|
||||||
CHANNEL_SLICES = 10
|
|
||||||
CHANNEL_LIMIT = 100
|
|
||||||
MAX_CHANNELS = 50
|
|
||||||
QUEUE_HIGH_WATER = 200
|
|
||||||
QUEUE_WAIT_SECONDS = 0.5
|
|
||||||
NO_QTS = -1
|
|
||||||
MAX_AGE_SECONDS = 3 * 24 * 3600
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SavedState:
|
|
||||||
box: UpdateCursor | None = None
|
|
||||||
channels: dict[int, int] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BoxWalk:
|
|
||||||
cursor: UpdateCursor
|
|
||||||
done: bool = False
|
|
||||||
updates: int = 0
|
|
||||||
channels: dict[int, raw.base.Chat] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
def read_saved_state(path: Path) -> SavedState:
|
|
||||||
try:
|
|
||||||
uri = f"{path.resolve().as_uri()}?mode=ro"
|
|
||||||
with closing(sqlite3.connect(uri, uri=True)) as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT id, pts, qts, date FROM update_state"
|
|
||||||
).fetchall()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(f"[yellow]Session {path.stem}: no saved update state ({exc})[/]")
|
|
||||||
return SavedState()
|
|
||||||
box = None
|
|
||||||
channels: dict[int, int] = {}
|
|
||||||
for state_id, pts, qts, date in rows:
|
|
||||||
if pts is None:
|
|
||||||
continue
|
|
||||||
if state_id == 0:
|
|
||||||
box = UpdateCursor(
|
|
||||||
pts, NO_QTS if qts is None else qts, date or int(time.time())
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
channels[state_id] = pts
|
|
||||||
return SavedState(box, channels)
|
|
||||||
|
|
||||||
|
|
||||||
async def _dispatch(
|
|
||||||
client: PyroClient,
|
|
||||||
new_messages: list,
|
|
||||||
other_updates: list,
|
|
||||||
users: list,
|
|
||||||
chats: list,
|
|
||||||
) -> int:
|
|
||||||
await client.fetch_peers(users)
|
|
||||||
await client.fetch_peers(chats)
|
|
||||||
user_map = {user.id: user for user in users}
|
|
||||||
chat_map = {chat.id: chat for chat in chats}
|
|
||||||
queue = client.dispatcher.updates_queue
|
|
||||||
for message in new_messages:
|
|
||||||
update = raw.types.UpdateNewMessage(message=message, pts=0, pts_count=0)
|
|
||||||
queue.put_nowait((update, user_map, chat_map))
|
|
||||||
for update in other_updates:
|
|
||||||
queue.put_nowait((update, user_map, chat_map))
|
|
||||||
while queue.qsize() > QUEUE_HIGH_WATER: # noqa: ASYNC110
|
|
||||||
await asyncio.sleep(QUEUE_WAIT_SECONDS)
|
|
||||||
return len(new_messages) + len(other_updates)
|
|
||||||
|
|
||||||
|
|
||||||
def _channel_hints(other_updates: list, chats: list) -> dict[int, raw.base.Chat]:
|
|
||||||
by_id = {chat.id: chat for chat in chats}
|
|
||||||
return {
|
|
||||||
utils.get_channel_id(update.channel_id): by_id[update.channel_id]
|
|
||||||
for update in other_updates
|
|
||||||
if isinstance(update, raw.types.UpdateChannelTooLong)
|
|
||||||
and update.channel_id in by_id
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _walk_box(
|
|
||||||
client: PyroClient, ctx: CaptureContext, cursor: UpdateCursor
|
|
||||||
) -> BoxWalk:
|
|
||||||
walk = BoxWalk(cursor)
|
|
||||||
for _ in range(BOX_SLICES):
|
|
||||||
diff = await client.invoke(
|
|
||||||
raw.functions.updates.GetDifference(
|
|
||||||
pts=walk.cursor.pts, date=walk.cursor.date, qts=walk.cursor.qts
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if isinstance(diff, raw.types.updates.DifferenceEmpty):
|
|
||||||
walk.done = True
|
|
||||||
return walk
|
|
||||||
if isinstance(diff, raw.types.updates.DifferenceTooLong):
|
|
||||||
logger.warning(
|
|
||||||
f"[yellow]Account {ctx.account_id}: too much missed to catch up "
|
|
||||||
f"(pts {walk.cursor.pts} to {diff.pts}), that gap stays[/]"
|
|
||||||
)
|
|
||||||
walk.done = True
|
|
||||||
return walk
|
|
||||||
walk.updates += await _dispatch(
|
|
||||||
client, diff.new_messages, diff.other_updates, diff.users, diff.chats
|
|
||||||
)
|
|
||||||
walk.channels.update(_channel_hints(diff.other_updates, diff.chats))
|
|
||||||
final = isinstance(diff, raw.types.updates.Difference)
|
|
||||||
state = diff.state if final else diff.intermediate_state
|
|
||||||
moved = UpdateCursor(state.pts, state.qts, state.date or walk.cursor.date)
|
|
||||||
if moved == walk.cursor and not final:
|
|
||||||
logger.warning(
|
|
||||||
f"[yellow]Account {ctx.account_id}: catch-up stopped advancing "
|
|
||||||
f"at pts={moved.pts}[/]"
|
|
||||||
)
|
|
||||||
return walk
|
|
||||||
walk.cursor = moved
|
|
||||||
await repository.save_cursor(ctx.pool, ctx.account_id, moved)
|
|
||||||
if final:
|
|
||||||
walk.done = True
|
|
||||||
return walk
|
|
||||||
return walk
|
|
||||||
|
|
||||||
|
|
||||||
async def _walk_channel(client: PyroClient, peer_id: int, pts: int) -> int:
|
|
||||||
peer = await client.resolve_peer(peer_id)
|
|
||||||
if not isinstance(peer, raw.types.InputPeerChannel):
|
|
||||||
return 0
|
|
||||||
channel = raw.types.InputChannel(
|
|
||||||
channel_id=peer.channel_id, access_hash=peer.access_hash
|
|
||||||
)
|
|
||||||
updates = 0
|
|
||||||
for _ in range(CHANNEL_SLICES):
|
|
||||||
diff = await client.invoke(
|
|
||||||
raw.functions.updates.GetChannelDifference(
|
|
||||||
channel=channel,
|
|
||||||
filter=raw.types.ChannelMessagesFilterEmpty(),
|
|
||||||
pts=pts,
|
|
||||||
limit=CHANNEL_LIMIT,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if isinstance(diff, raw.types.updates.ChannelDifferenceEmpty):
|
|
||||||
return updates
|
|
||||||
if isinstance(diff, raw.types.updates.ChannelDifferenceTooLong):
|
|
||||||
logger.info(f"Channel {peer_id}: too much missed to catch up")
|
|
||||||
return updates
|
|
||||||
updates += await _dispatch(
|
|
||||||
client, diff.new_messages, diff.other_updates, diff.users, diff.chats
|
|
||||||
)
|
|
||||||
if diff.final:
|
|
||||||
return updates
|
|
||||||
pts = diff.pts
|
|
||||||
return updates
|
|
||||||
|
|
||||||
|
|
||||||
def _captured_channels(
|
|
||||||
ctx: CaptureContext, hints: dict[int, raw.base.Chat], saved: dict[int, int]
|
|
||||||
) -> list[int]:
|
|
||||||
wanted: list[tuple[bool, int]] = []
|
|
||||||
for peer_id, chat in hints.items():
|
|
||||||
if peer_id not in saved:
|
|
||||||
continue
|
|
||||||
meta = meta_from_peer(
|
|
||||||
raw.types.PeerChannel(channel_id=chat.id),
|
|
||||||
{chat.id: chat},
|
|
||||||
{},
|
|
||||||
ctx.contacts.ids,
|
|
||||||
)
|
|
||||||
toggles = ctx.resolve(meta)
|
|
||||||
if toggles.messages or toggles.track_edits_deletes:
|
|
||||||
wanted.append((meta.kind is ChatKind.CHANNEL, peer_id))
|
|
||||||
return [peer_id for _, peer_id in sorted(wanted)[:MAX_CHANNELS]]
|
|
||||||
|
|
||||||
|
|
||||||
async def _walk_channels(
|
|
||||||
client: PyroClient, ctx: CaptureContext, walk: BoxWalk, saved: dict[int, int]
|
|
||||||
) -> int:
|
|
||||||
peer_ids = _captured_channels(ctx, walk.channels, saved)
|
|
||||||
updates = 0
|
|
||||||
for peer_id in peer_ids:
|
|
||||||
try:
|
|
||||||
updates += await _walk_channel(client, peer_id, saved[peer_id])
|
|
||||||
except (FloodWait, FloodPremiumWait) as exc:
|
|
||||||
logger.warning(
|
|
||||||
f"[yellow]Account {ctx.account_id}: channel catch-up stopped "
|
|
||||||
f"by a flood wait:[/] {exc}"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except RPCError as exc:
|
|
||||||
logger.warning(
|
|
||||||
f"[yellow]Account {ctx.account_id}: channel {peer_id} "
|
|
||||||
f"catch-up failed:[/] {exc}"
|
|
||||||
)
|
|
||||||
skipped = len(walk.channels) - len(peer_ids)
|
|
||||||
if skipped:
|
|
||||||
logger.info(f"Account {ctx.account_id}: {skipped} channel gap(s) left as is")
|
|
||||||
return updates
|
|
||||||
|
|
||||||
|
|
||||||
async def catch_up(client: PyroClient, saved: SavedState) -> None:
|
|
||||||
ctx = client.capture
|
|
||||||
if ctx is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
pending = await repository.load_cursor(ctx.pool, ctx.account_id)
|
|
||||||
cursor = pending or saved.box
|
|
||||||
if cursor is None:
|
|
||||||
logger.info(f"Account {ctx.account_id}: no saved update state to catch up")
|
|
||||||
return
|
|
||||||
if time.time() - cursor.date > MAX_AGE_SECONDS:
|
|
||||||
logger.warning(
|
|
||||||
f"[yellow]Account {ctx.account_id}: last update seen too long ago "
|
|
||||||
f"(pts={cursor.pts}), not catching up[/]"
|
|
||||||
)
|
|
||||||
await repository.clear_cursor(ctx.pool, ctx.account_id)
|
|
||||||
return
|
|
||||||
await repository.save_cursor(ctx.pool, ctx.account_id, cursor)
|
|
||||||
logger.info(
|
|
||||||
f"Account {ctx.account_id}: catching up from pts={cursor.pts}"
|
|
||||||
f"{' (resumed)' if pending else ''}"
|
|
||||||
)
|
|
||||||
walk = await _walk_box(client, ctx, cursor)
|
|
||||||
if not walk.done:
|
|
||||||
logger.warning(
|
|
||||||
f"[yellow]Account {ctx.account_id}: catch-up unfinished at "
|
|
||||||
f"pts={walk.cursor.pts},[/] {walk.updates} updates replayed, "
|
|
||||||
f"resumes on the next start"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
from_channels = await _walk_channels(client, ctx, walk, saved.channels)
|
|
||||||
await repository.clear_cursor(ctx.pool, ctx.account_id)
|
|
||||||
logger.info(
|
|
||||||
f"[green]Account {ctx.account_id}: caught up,[/] {walk.updates} updates "
|
|
||||||
f"from the main box, {from_channels} from channels"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
f"[red]Account {ctx.account_id}: catch-up failed,[/] "
|
|
||||||
f"resumes on the next start"
|
|
||||||
)
|
|
||||||
@@ -7,23 +7,7 @@ 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 DO NOTHING
|
ON CONFLICT (account_id, chat_id, message_id) 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 = """
|
||||||
@@ -35,14 +19,13 @@ 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 event IN ('meta', 'title')
|
WHERE account_id = $1 AND chat_id = $2 AND title IS NOT NULL
|
||||||
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
|
WHERE account_id = $1 AND chat_id = $2 AND photo_unique_id IS NOT NULL
|
||||||
AND event IN ('meta', 'photo', 'photo_deleted')
|
|
||||||
ORDER BY ts DESC LIMIT 1
|
ORDER BY ts DESC LIMIT 1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -81,29 +64,6 @@ 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,
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ from userbot.modules.jobs import repository
|
|||||||
from userbot.modules.jobs.context import JobContext
|
from userbot.modules.jobs.context import JobContext
|
||||||
from userbot.modules.jobs.registry import JOB_HANDLERS, JobHandler
|
from userbot.modules.jobs.registry import JOB_HANDLERS, JobHandler
|
||||||
from utils.db.models import Job, JobStatus
|
from utils.db.models import Job, JobStatus
|
||||||
from utils.listen import Listener
|
from utils.env import env
|
||||||
from utils.logging import logger
|
from utils.logging import logger
|
||||||
|
|
||||||
JOBS_CHANGED_CHANNEL = "jobs_changed"
|
JOBS_CHANGED_CHANNEL = "jobs_changed"
|
||||||
POLL_INTERVAL_SECONDS = 60.0
|
POLL_INTERVAL_SECONDS = 60.0
|
||||||
RESTART_SECONDS = 10.0
|
|
||||||
|
|
||||||
|
|
||||||
class JobConsumer:
|
class JobConsumer:
|
||||||
@@ -39,20 +38,25 @@ class JobConsumer:
|
|||||||
self.pool, ctx.job_id, JobStatus.FAILED, f"no handler for {job.kind!r}"
|
self.pool, ctx.job_id, JobStatus.FAILED, f"no handler for {job.kind!r}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
try:
|
while True:
|
||||||
await handler(ctx)
|
try:
|
||||||
except (FloodWait, FloodPremiumWait) as exc:
|
await handler(ctx)
|
||||||
wait = exc.value if isinstance(exc.value, int) else 1
|
except (FloodWait, FloodPremiumWait) as exc:
|
||||||
await repository.defer_flood_wait(self.pool, ctx.job_id, wait)
|
await repository.bump_flood_wait(self.pool, ctx.job_id)
|
||||||
logger.warning(
|
wait = exc.value if isinstance(exc.value, int) else 1
|
||||||
f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind}), "
|
logger.warning(
|
||||||
f"deferred[/]"
|
f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind})[/]"
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
await asyncio.sleep(wait)
|
||||||
logger.exception(f"Job {ctx.job_id} ({job.kind}) failed")
|
except Exception as exc:
|
||||||
await repository.finish(self.pool, ctx.job_id, JobStatus.FAILED, str(exc))
|
logger.exception(f"Job {ctx.job_id} ({job.kind}) failed")
|
||||||
else:
|
await repository.finish(
|
||||||
await repository.finish(self.pool, ctx.job_id, JobStatus.DONE)
|
self.pool, ctx.job_id, JobStatus.FAILED, str(exc)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
await repository.finish(self.pool, ctx.job_id, JobStatus.DONE)
|
||||||
|
return
|
||||||
|
|
||||||
async def drain(self) -> None:
|
async def drain(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
@@ -62,41 +66,23 @@ class JobConsumer:
|
|||||||
await self.execute(job)
|
await self.execute(job)
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await self._serve()
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
f"[red]Job consumer for account {self.account_id} failed,[/] "
|
|
||||||
f"restarting in {RESTART_SECONDS:.0f}s"
|
|
||||||
)
|
|
||||||
await asyncio.sleep(RESTART_SECONDS)
|
|
||||||
|
|
||||||
async def _serve(self) -> None:
|
|
||||||
requeued = await repository.requeue_running(self.pool, self.account_id)
|
requeued = await repository.requeue_running(self.pool, self.account_id)
|
||||||
if requeued:
|
if requeued:
|
||||||
logger.info(f"[yellow]Requeued {requeued} stale running job(s).[/]")
|
logger.info(f"[yellow]Requeued {requeued} stale running job(s).[/]")
|
||||||
pruned = await repository.prune_finished(self.pool, self.account_id)
|
pruned = await repository.prune_finished(self.pool, self.account_id)
|
||||||
if pruned:
|
if pruned:
|
||||||
logger.info(f"[yellow]Pruned {pruned} finished job(s).[/]")
|
logger.info(f"[yellow]Pruned {pruned} finished job(s).[/]")
|
||||||
listener = Listener(
|
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||||
f"jobs {self.account_id}",
|
await conn.add_listener(JOBS_CHANGED_CHANNEL, lambda *_: self._wake.set())
|
||||||
{JOBS_CHANGED_CHANNEL: lambda _payload: self._wake.set()},
|
|
||||||
self._wake.set,
|
|
||||||
)
|
|
||||||
await listener.start()
|
|
||||||
logger.info(f"[green]Job consumer running for account {self.account_id}.[/]")
|
logger.info(f"[green]Job consumer running for account {self.account_id}.[/]")
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
self._wake.clear()
|
|
||||||
await self.drain()
|
await self.drain()
|
||||||
|
self._wake.clear()
|
||||||
with contextlib.suppress(TimeoutError):
|
with contextlib.suppress(TimeoutError):
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
self._wake.wait(), timeout=await self._idle_seconds()
|
self._wake.wait(), timeout=POLL_INTERVAL_SECONDS
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await listener.stop()
|
with contextlib.suppress(Exception):
|
||||||
|
await conn.close()
|
||||||
async def _idle_seconds(self) -> float:
|
|
||||||
due = await repository.seconds_to_next_due(self.pool, self.account_id)
|
|
||||||
return POLL_INTERVAL_SECONDS if due is None else min(due, POLL_INTERVAL_SECONDS)
|
|
||||||
@@ -5,7 +5,6 @@ from userbot.modules.jobs.handlers import (
|
|||||||
fetch_avatar,
|
fetch_avatar,
|
||||||
fetch_custom_emoji,
|
fetch_custom_emoji,
|
||||||
fetch_media,
|
fetch_media,
|
||||||
fetch_read_info,
|
|
||||||
search_peers,
|
search_peers,
|
||||||
sync_contacts,
|
sync_contacts,
|
||||||
sync_dialogs,
|
sync_dialogs,
|
||||||
@@ -19,7 +18,6 @@ __all__ = [
|
|||||||
"fetch_avatar",
|
"fetch_avatar",
|
||||||
"fetch_custom_emoji",
|
"fetch_custom_emoji",
|
||||||
"fetch_media",
|
"fetch_media",
|
||||||
"fetch_read_info",
|
|
||||||
"search_peers",
|
"search_peers",
|
||||||
"sync_contacts",
|
"sync_contacts",
|
||||||
"sync_dialogs",
|
"sync_dialogs",
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
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.snapshots import save_group, save_user
|
from userbot.modules.profiles.parse import snapshot_from_high_level
|
||||||
|
from userbot.modules.profiles.repository import write_profile
|
||||||
|
from userbot.modules.raw_json import raw_json
|
||||||
|
|
||||||
MEMBER_CAP = 200
|
MEMBER_CAP = 200
|
||||||
|
|
||||||
@@ -19,17 +25,45 @@ LIMIT 100
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_user(ctx: CaptureContext, user: User) -> None:
|
||||||
|
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||||
|
await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
|
||||||
|
if photo_file_id and photo_unique_id:
|
||||||
|
await note_avatar(
|
||||||
|
ctx.pool, ctx.account_id, user.id, "peer", photo_unique_id, photo_file_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None:
|
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 not None:
|
if chat is None:
|
||||||
await save_group(ctx, chat)
|
return
|
||||||
|
photo = chat.photo
|
||||||
|
photo_unique_id = photo.big_photo_unique_id if photo else None
|
||||||
|
photo_file_id = photo.big_file_id if photo else None
|
||||||
|
await insert_chat_history(
|
||||||
|
ctx.pool,
|
||||||
|
ctx.account_id,
|
||||||
|
chat_id,
|
||||||
|
0,
|
||||||
|
"meta",
|
||||||
|
chat.title,
|
||||||
|
photo_unique_id,
|
||||||
|
None,
|
||||||
|
datetime.now(UTC),
|
||||||
|
raw_json(chat),
|
||||||
|
)
|
||||||
|
if photo_file_id and photo_unique_id:
|
||||||
|
await note_avatar(
|
||||||
|
ctx.pool, ctx.account_id, chat_id, "chat", photo_unique_id, photo_file_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -55,7 +89,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,124 +0,0 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
from pyrogram import Client, raw
|
|
||||||
from pyrogram.errors import (
|
|
||||||
ChannelInvalid,
|
|
||||||
ChannelPrivate,
|
|
||||||
ChatTooBig,
|
|
||||||
FloodPremiumWait,
|
|
||||||
FloodWait,
|
|
||||||
MessageIdInvalid,
|
|
||||||
MessageNotReadYet,
|
|
||||||
MessageTooOld,
|
|
||||||
MsgIdInvalid,
|
|
||||||
MsgTooOld,
|
|
||||||
PeerIdInvalid,
|
|
||||||
RPCError,
|
|
||||||
UserPrivacyRestricted,
|
|
||||||
YourPrivacyRestricted,
|
|
||||||
)
|
|
||||||
|
|
||||||
from userbot.modules.jobs.context import JobContext
|
|
||||||
from userbot.modules.jobs.registry import register
|
|
||||||
from userbot.modules.read_receipts.repository import save_fetch, save_reads
|
|
||||||
from utils.jobs import enqueue
|
|
||||||
from utils.logging import logger
|
|
||||||
from utils.policy.models import ChatKind
|
|
||||||
from utils.read.peers import get_peers
|
|
||||||
from utils.read.read_receipts import read_info
|
|
||||||
|
|
||||||
RETRY = timedelta(seconds=60)
|
|
||||||
LATER = timedelta(days=1)
|
|
||||||
|
|
||||||
Refusal = tuple[tuple[type[Exception], ...], str, timedelta]
|
|
||||||
|
|
||||||
REFUSALS: tuple[Refusal, ...] = (
|
|
||||||
((MessageNotReadYet,), "not_read_yet", RETRY),
|
|
||||||
((UserPrivacyRestricted,), "user_privacy", RETRY),
|
|
||||||
((YourPrivacyRestricted,), "your_privacy", LATER),
|
|
||||||
((ChatTooBig,), "chat_too_big", LATER),
|
|
||||||
((MsgTooOld, MessageTooOld), "too_old", LATER),
|
|
||||||
(
|
|
||||||
(
|
|
||||||
PeerIdInvalid,
|
|
||||||
ChannelInvalid,
|
|
||||||
ChannelPrivate,
|
|
||||||
MessageIdInvalid,
|
|
||||||
MsgIdInvalid,
|
|
||||||
KeyError,
|
|
||||||
ValueError,
|
|
||||||
),
|
|
||||||
"unavailable",
|
|
||||||
RETRY,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _refusal(exc: Exception) -> tuple[str, timedelta]:
|
|
||||||
for kinds, outcome, retry in REFUSALS:
|
|
||||||
if isinstance(exc, kinds):
|
|
||||||
return outcome, retry
|
|
||||||
return "error", RETRY
|
|
||||||
|
|
||||||
|
|
||||||
def _at(timestamp: int) -> datetime:
|
|
||||||
return datetime.fromtimestamp(timestamp, UTC)
|
|
||||||
|
|
||||||
|
|
||||||
async def ask_telegram(
|
|
||||||
client: Client, chat_id: int, message_id: int, *, group: bool
|
|
||||||
) -> list[tuple[int, datetime]]:
|
|
||||||
peer = await client.resolve_peer(chat_id)
|
|
||||||
if peer is None:
|
|
||||||
msg = f"unknown peer {chat_id}"
|
|
||||||
raise ValueError(msg)
|
|
||||||
if group:
|
|
||||||
participants = await client.invoke(
|
|
||||||
raw.functions.messages.GetMessageReadParticipants(
|
|
||||||
peer=peer, msg_id=message_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return [(item.user_id, _at(item.date)) for item in participants]
|
|
||||||
outbox = await client.invoke(
|
|
||||||
raw.functions.messages.GetOutboxReadDate(peer=peer, msg_id=message_id)
|
|
||||||
)
|
|
||||||
return [(chat_id, _at(outbox.date))]
|
|
||||||
|
|
||||||
|
|
||||||
async def _enrich_unknown(ctx: JobContext, chat_id: int, reader_ids: list[int]) -> None:
|
|
||||||
known = {
|
|
||||||
peer.peer_id for peer in await get_peers(ctx.pool, ctx.account_id, reader_ids)
|
|
||||||
}
|
|
||||||
if set(reader_ids) - known:
|
|
||||||
await enqueue(ctx.pool, ctx.account_id, "enrich_chat", {"chat_id": chat_id})
|
|
||||||
|
|
||||||
|
|
||||||
@register("fetch_read_info")
|
|
||||||
async def fetch_read_info(ctx: JobContext) -> None:
|
|
||||||
client = ctx.client
|
|
||||||
if client is None:
|
|
||||||
return
|
|
||||||
chat_id = ctx.job.params["chat_id"]
|
|
||||||
message_id = ctx.job.params["message_id"]
|
|
||||||
info = await read_info(ctx.pool, ctx.account_id, chat_id, message_id)
|
|
||||||
if info is None or not info.can_fetch:
|
|
||||||
return
|
|
||||||
group = info.chat_kind == ChatKind.GROUP
|
|
||||||
try:
|
|
||||||
reads = await ask_telegram(client, chat_id, message_id, group=group)
|
|
||||||
except (FloodWait, FloodPremiumWait) as exc:
|
|
||||||
wait = exc.value if isinstance(exc.value, int) else 0
|
|
||||||
retry = max(RETRY, timedelta(seconds=wait))
|
|
||||||
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, "flood", retry)
|
|
||||||
return
|
|
||||||
except (RPCError, KeyError, ValueError) as exc:
|
|
||||||
outcome, retry = _refusal(exc)
|
|
||||||
if outcome == "error":
|
|
||||||
logger.warning(f"[yellow]Read info {chat_id}/{message_id}: {exc!r}[/]")
|
|
||||||
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, outcome, retry)
|
|
||||||
return
|
|
||||||
source = "participants" if group else "outbox_date"
|
|
||||||
await save_reads(ctx.pool, ctx.account_id, chat_id, message_id, source, reads)
|
|
||||||
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, "ok", RETRY)
|
|
||||||
if group and reads:
|
|
||||||
await _enrich_unknown(ctx, chat_id, [reader_id for reader_id, _ in reads])
|
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
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.snapshots import save_user
|
from userbot.modules.profiles.parse import snapshot_from_high_level
|
||||||
|
from userbot.modules.profiles.repository import write_profile
|
||||||
|
from userbot.modules.raw_json import raw_json
|
||||||
|
|
||||||
|
|
||||||
@register("sync_contacts")
|
@register("sync_contacts")
|
||||||
@@ -18,7 +21,17 @@ 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
|
||||||
await save_user(capture, 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))
|
||||||
|
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,10 +2,14 @@ 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.snapshots import save_group, save_private, save_user
|
from userbot.modules.profiles.parse import snapshot_from_high_level
|
||||||
|
from userbot.modules.profiles.repository import write_profile
|
||||||
|
from userbot.modules.profiles.snapshots import save_group, save_private
|
||||||
|
from userbot.modules.raw_json import raw_json
|
||||||
from utils.logging import logger
|
from utils.logging import logger
|
||||||
|
|
||||||
SAVE_EVERY = 100
|
SAVE_EVERY = 100
|
||||||
@@ -26,8 +30,21 @@ 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 isinstance(user, User):
|
if not isinstance(user, User):
|
||||||
await save_user(ctx, user)
|
continue
|
||||||
|
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
|
||||||
|
await write_profile(
|
||||||
|
ctx.pool, ctx.account_id, user.id, fields, raw_json(user)
|
||||||
|
)
|
||||||
|
if photo_file_id and photo_unique_id:
|
||||||
|
await note_avatar(
|
||||||
|
ctx.pool,
|
||||||
|
ctx.account_id,
|
||||||
|
user.id,
|
||||||
|
"peer",
|
||||||
|
photo_unique_id,
|
||||||
|
photo_file_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@register("sync_dialogs")
|
@register("sync_dialogs")
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ async def claim_next(pool: asyncpg.Pool, account_id: int) -> Job | None:
|
|||||||
"updated_at = now(), attempts = attempts + 1 "
|
"updated_at = now(), attempts = attempts + 1 "
|
||||||
"WHERE id = ("
|
"WHERE id = ("
|
||||||
" SELECT id FROM jobs WHERE account_id = $1 AND status = 'pending' "
|
" SELECT id FROM jobs WHERE account_id = $1 AND status = 'pending' "
|
||||||
" AND (run_after IS NULL OR run_after <= now()) "
|
|
||||||
" ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1"
|
" ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1"
|
||||||
") RETURNING *",
|
") RETURNING *",
|
||||||
account_id,
|
account_id,
|
||||||
@@ -77,21 +76,11 @@ async def report_progress(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def defer_flood_wait(pool: asyncpg.Pool, job_id: int, seconds: int) -> None:
|
async def bump_flood_wait(pool: asyncpg.Pool, job_id: int) -> None:
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
"UPDATE jobs SET status = 'pending', flood_waits = flood_waits + 1, "
|
"UPDATE jobs SET flood_waits = flood_waits + 1, updated_at = now() "
|
||||||
"run_after = now() + make_interval(secs => $2), updated_at = now() "
|
"WHERE id = $1",
|
||||||
"WHERE id = $1 AND status = 'running'",
|
|
||||||
job_id,
|
job_id,
|
||||||
seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def seconds_to_next_due(pool: asyncpg.Pool, account_id: int) -> float | None:
|
|
||||||
return await pool.fetchval(
|
|
||||||
"SELECT extract(epoch FROM min(run_after) - now())::float FROM jobs "
|
|
||||||
"WHERE account_id = $1 AND status = 'pending' AND run_after > now()",
|
|
||||||
account_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,6 @@ INSERT INTO links
|
|||||||
(account_id, chat_id, message_id, position, url, kind, web_url, web_title,
|
(account_id, chat_id, message_id, position, url, kind, web_url, web_title,
|
||||||
web_description, web_site_name, raw)
|
web_description, web_site_name, raw)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
|
||||||
ON CONFLICT (account_id, chat_id, message_id, position) DO UPDATE SET
|
|
||||||
url = EXCLUDED.url,
|
|
||||||
kind = EXCLUDED.kind,
|
|
||||||
web_url = EXCLUDED.web_url,
|
|
||||||
web_title = EXCLUDED.web_title,
|
|
||||||
web_description = EXCLUDED.web_description,
|
|
||||||
web_site_name = EXCLUDED.web_site_name,
|
|
||||||
raw = EXCLUDED.raw
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from pyrogram import Client, raw
|
from pyrogram import Client, raw
|
||||||
from pyrogram.types import Chat, User
|
from pyrogram.types import Chat, User
|
||||||
@@ -12,11 +12,6 @@ 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:
|
||||||
@@ -40,7 +35,6 @@ 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
|
||||||
|
|
||||||
@@ -58,7 +52,6 @@ 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
|
||||||
|
|
||||||
@@ -71,7 +64,8 @@ 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,
|
||||||
unknown=CHAT_UNKNOWN,
|
is_deleted_account=False,
|
||||||
)
|
)
|
||||||
return fields, photo_file_id, photo_unique_id
|
return fields, photo_file_id, photo_unique_id
|
||||||
@@ -9,46 +9,26 @@ WHERE account_id = $1 AND peer_id = $2
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_UPSERT_PEER = """
|
_UPSERT_PEER = """
|
||||||
INSERT INTO peers AS p
|
INSERT INTO peers
|
||||||
(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 = CASE WHEN 'username' = ANY($10::text[])
|
username = EXCLUDED.username,
|
||||||
THEN COALESCE(EXCLUDED.username, p.username) ELSE EXCLUDED.username END,
|
phone = EXCLUDED.phone,
|
||||||
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 = CASE WHEN 'is_deleted_account' = ANY($10::text[])
|
is_deleted_account = EXCLUDED.is_deleted_account,
|
||||||
THEN p.is_deleted_account ELSE EXCLUDED.is_deleted_account END,
|
|
||||||
raw = EXCLUDED.raw,
|
raw = EXCLUDED.raw,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_INSERT_CHANGE = """
|
_INSERT_HISTORY = """
|
||||||
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)
|
||||||
SELECT account_id, peer_id, clock_timestamp(), first_name, last_name, username,
|
VALUES ($1, $2, now(), $3, $4, $5, $6, $7, $8, $9::jsonb)
|
||||||
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -69,30 +49,18 @@ async def get_peer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def write_profile( # noqa: PLR0913
|
async def write_profile(
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool, account_id: int, peer_id: int, fields: ProfileFields, raw: str
|
||||||
account_id: int,
|
) -> None:
|
||||||
peer_id: int,
|
args = (
|
||||||
fields: ProfileFields,
|
fields.first_name,
|
||||||
raw: str,
|
fields.last_name,
|
||||||
*,
|
fields.username,
|
||||||
history: bool,
|
fields.phone,
|
||||||
) -> bool:
|
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(
|
await conn.execute(_UPSERT_PEER, account_id, peer_id, *args)
|
||||||
_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,46 +1,19 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from pyrogram.enums import ChatType
|
from pyrogram.types import Chat
|
||||||
from pyrogram.types import Chat, 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 record_chat_meta
|
from userbot.modules.groups.repository import insert_chat_history
|
||||||
from userbot.modules.profiles.parse import snapshot_from_chat, snapshot_from_high_level
|
from userbot.modules.profiles.parse import snapshot_from_chat
|
||||||
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)
|
||||||
is_bot = chat.type is ChatType.BOT
|
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, raw_json(chat))
|
||||||
await write_profile(
|
|
||||||
ctx.pool,
|
|
||||||
ctx.account_id,
|
|
||||||
chat_id,
|
|
||||||
fields,
|
|
||||||
raw_json(chat),
|
|
||||||
history=ctx.resolve_peer(chat_id, is_bot=is_bot).profile_history,
|
|
||||||
)
|
|
||||||
if photo_file_id and photo_unique_id:
|
if photo_file_id and photo_unique_id:
|
||||||
await note_avatar(
|
await note_avatar(
|
||||||
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
|
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
|
||||||
@@ -53,13 +26,15 @@ 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 record_chat_meta(
|
await insert_chat_history(
|
||||||
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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
@@ -9,26 +9,6 @@ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
|||||||
ON CONFLICT (account_id, chat_id, message_id, ts) DO NOTHING
|
ON CONFLICT (account_id, chat_id, message_id, ts) DO NOTHING
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_UPSERT_READ = """
|
|
||||||
INSERT INTO message_reads
|
|
||||||
(account_id, chat_id, message_id, reader_id, read_at, source, fetched_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, now())
|
|
||||||
ON CONFLICT (account_id, chat_id, message_id, reader_id) DO UPDATE SET
|
|
||||||
read_at = EXCLUDED.read_at,
|
|
||||||
source = EXCLUDED.source,
|
|
||||||
fetched_at = EXCLUDED.fetched_at
|
|
||||||
"""
|
|
||||||
|
|
||||||
_UPSERT_FETCH = """
|
|
||||||
INSERT INTO read_fetches
|
|
||||||
(account_id, chat_id, message_id, outcome, fetched_at, retry_at)
|
|
||||||
VALUES ($1, $2, $3, $4, now(), now() + $5::interval)
|
|
||||||
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
|
|
||||||
outcome = EXCLUDED.outcome,
|
|
||||||
fetched_at = EXCLUDED.fetched_at,
|
|
||||||
retry_at = EXCLUDED.retry_at
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
async def insert_read( # noqa: PLR0913
|
async def insert_read( # noqa: PLR0913
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
@@ -54,33 +34,3 @@ async def insert_listened( # noqa: PLR0913
|
|||||||
await pool.execute(
|
await pool.execute(
|
||||||
_INSERT, account_id, chat_id, message_id, ts, reader_id, "listened", raw
|
_INSERT, account_id, chat_id, message_id, ts, reader_id, "listened", raw
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def save_reads( # noqa: PLR0913
|
|
||||||
pool: asyncpg.Pool,
|
|
||||||
account_id: int,
|
|
||||||
chat_id: int,
|
|
||||||
message_id: int,
|
|
||||||
source: str,
|
|
||||||
reads: list[tuple[int, datetime]],
|
|
||||||
) -> None:
|
|
||||||
await pool.executemany(
|
|
||||||
_UPSERT_READ,
|
|
||||||
[
|
|
||||||
(account_id, chat_id, message_id, reader_id, read_at, source)
|
|
||||||
for reader_id, read_at in reads
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def save_fetch( # noqa: PLR0913
|
|
||||||
pool: asyncpg.Pool,
|
|
||||||
account_id: int,
|
|
||||||
chat_id: int,
|
|
||||||
message_id: int,
|
|
||||||
outcome: str,
|
|
||||||
retry_after: timedelta,
|
|
||||||
) -> None:
|
|
||||||
await pool.execute(
|
|
||||||
_UPSERT_FETCH, account_id, chat_id, message_id, outcome, retry_after
|
|
||||||
)
|
|
||||||
Whitespace-only changes.
@@ -1,16 +0,0 @@
|
|||||||
import asyncpg
|
|
||||||
|
|
||||||
_HEARTBEAT = """
|
|
||||||
WITH extended AS (
|
|
||||||
UPDATE capture_uptime SET seen_at = now()
|
|
||||||
WHERE account_id = $1 AND seen_at >= now() - interval '150 seconds'
|
|
||||||
RETURNING account_id
|
|
||||||
)
|
|
||||||
INSERT INTO capture_uptime (account_id, started_at, seen_at)
|
|
||||||
SELECT $1, now(), now()
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM extended)
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
async def heartbeat(pool: asyncpg.Pool, account_id: int) -> None:
|
|
||||||
await pool.execute(_HEARTBEAT, account_id)
|
|
||||||
@@ -1,182 +1,60 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from typing import TYPE_CHECKING
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from userbot.modules.watches import repository
|
|
||||||
from userbot.modules.watches.evaluator import (
|
from userbot.modules.watches.evaluator import (
|
||||||
Rules,
|
KIND_KEYWORD,
|
||||||
Seen,
|
KIND_PEER_ONLINE,
|
||||||
build_rules,
|
|
||||||
match_chat_scope,
|
|
||||||
match_keyword,
|
match_keyword,
|
||||||
match_new_message,
|
match_peer_online,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
||||||
ONLINE_QUIET = timedelta(minutes=15)
|
from utils.read.models import WatchView
|
||||||
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.rules = Rules()
|
self.watches: list[WatchView] = []
|
||||||
self._self_id: int | None = None
|
self._online: set[int] = set()
|
||||||
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.rules = build_rules(rows)
|
self.watches = [watch for watch in rows if watch.enabled]
|
||||||
self._self_id = await self_user_id(self._pool, self._account_id)
|
logger.info(f"[green]Watches cached:[/] {len(self.watches)}")
|
||||||
logger.info(f"[green]Watches cached:[/] {len(self.rules)}")
|
|
||||||
|
|
||||||
def _own(self, seen: Seen) -> bool:
|
async def on_text(self, chat_id: int, message_id: int, text: str | None) -> None:
|
||||||
return seen.outgoing or (
|
if not text:
|
||||||
self._self_id is not None and seen.sender_id == self._self_id
|
return
|
||||||
)
|
for watch in self.watches:
|
||||||
|
if watch.kind == KIND_KEYWORD and match_keyword(
|
||||||
async def _guard(self, work: Coroutine[Any, Any, None]) -> None:
|
text, chat_id, watch.params
|
||||||
try:
|
):
|
||||||
await work
|
await watches_read.insert_alert(
|
||||||
except Exception:
|
self._pool,
|
||||||
logger.exception("[red]Watch evaluation failed[/]")
|
self._account_id,
|
||||||
|
watch.id,
|
||||||
async def _fire(self, watch_id: int, payload: dict[str, Any], key: str) -> None:
|
{"chat_id": chat_id, "message_id": message_id},
|
||||||
await watches_read.insert_alert(
|
dedup_key=f"{chat_id}:{message_id}",
|
||||||
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_message(self, seen: Seen) -> None:
|
async def on_status(self, peer_id: int, *, is_online: bool) -> None:
|
||||||
if self._own(seen):
|
if not is_online:
|
||||||
|
self._online.discard(peer_id)
|
||||||
return
|
return
|
||||||
await self._guard(self._on_message(seen))
|
if peer_id in self._online:
|
||||||
|
return
|
||||||
async def _on_message(self, seen: Seen) -> None:
|
self._online.add(peer_id)
|
||||||
await self._keyword("message", seen)
|
for watch in self.watches:
|
||||||
for watch_id, params in self.rules.new_message:
|
if watch.kind == KIND_PEER_ONLINE and match_peer_online(
|
||||||
if match_new_message(params, seen):
|
peer_id, watch.params
|
||||||
await self._fire(
|
):
|
||||||
watch_id,
|
await watches_read.insert_alert(
|
||||||
_message_payload("message", seen),
|
self._pool, self._account_id, watch.id, {"peer_id": peer_id}
|
||||||
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,104 +1,24 @@
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from typing import Any
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from utils.logging import logger
|
KIND_KEYWORD = "keyword"
|
||||||
from utils.read.models import WatchView
|
KIND_PEER_ONLINE = "peer_online"
|
||||||
from utils.watch_rules import (
|
|
||||||
MAX_TEXT,
|
|
||||||
DeletedParams,
|
|
||||||
EditedParams,
|
|
||||||
KeywordParams,
|
|
||||||
NewMessageParams,
|
|
||||||
PeerOnlineParams,
|
|
||||||
WatchParamsError,
|
|
||||||
compile_pattern,
|
|
||||||
parse_params,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
def match_keyword(text: str, chat_id: int, params: dict[str, Any]) -> bool:
|
||||||
class Seen:
|
target_chat = params.get("chat_id")
|
||||||
chat_id: int
|
if target_chat is not None and target_chat != chat_id:
|
||||||
message_id: int
|
return False
|
||||||
sender_id: int | None
|
pattern = params.get("pattern")
|
||||||
text: str | None
|
if not pattern:
|
||||||
outgoing: bool = False
|
return False
|
||||||
date: datetime | None = None
|
if params.get("regex"):
|
||||||
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:
|
||||||
params = parse_params(watch.kind, watch.params)
|
return re.search(pattern, text) is not None
|
||||||
except WatchParamsError as exc:
|
except re.error:
|
||||||
logger.warning(f"[yellow]Watch {watch.id} skipped:[/] {exc}")
|
return False
|
||||||
continue
|
return pattern.casefold() in text.casefold()
|
||||||
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 _in_scope(chat_id: int | None, sender_id: int | None, seen: Seen) -> bool:
|
def match_peer_online(peer_id: int, params: dict[str, Any]) -> bool:
|
||||||
if chat_id is not None and chat_id != seen.chat_id:
|
return params.get("peer_id") == peer_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
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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),
|
|
||||||
)
|
|
||||||
@@ -11,13 +11,10 @@ import uvloop
|
|||||||
|
|
||||||
from dependencies.container import container
|
from dependencies.container import container
|
||||||
from userbot.modules.capture import CaptureContext, build_capture_context
|
from userbot.modules.capture import CaptureContext, build_capture_context
|
||||||
from userbot.modules.catchup import SavedState, catch_up, read_saved_state
|
|
||||||
from userbot.modules.client import PyroClient
|
from userbot.modules.client import PyroClient
|
||||||
from userbot.modules.jobs import JobConsumer
|
from userbot.modules.jobs import JobConsumer
|
||||||
from userbot.modules.uptime import repository as uptime
|
|
||||||
from utils.env import env
|
from utils.env import env
|
||||||
from utils.jobs import enqueue
|
from utils.jobs import enqueue
|
||||||
from utils.listen import Listener
|
|
||||||
from utils.logging import logger, setup_logging
|
from utils.logging import logger, setup_logging
|
||||||
from utils.read.accounts import (
|
from utils.read.accounts import (
|
||||||
ACCOUNTS_CHANGED_CHANNEL,
|
ACCOUNTS_CHANGED_CHANNEL,
|
||||||
@@ -31,14 +28,12 @@ from utils.storage import ContentAddressedStorage
|
|||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
SESSION_FLUSH_INTERVAL = 60
|
SESSION_FLUSH_INTERVAL = 60
|
||||||
SESSION_RETRY_INTERVAL = 60
|
|
||||||
UPTIME_INTERVAL = 60
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RunningAccount:
|
class RunningAccount:
|
||||||
client: PyroClient
|
client: PyroClient
|
||||||
tasks: list[asyncio.Task]
|
consumer_task: asyncio.Task
|
||||||
device_model: str | None
|
device_model: str | None
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +54,6 @@ class AccountRegistry:
|
|||||||
self._pool = pool
|
self._pool = pool
|
||||||
self._storage = storage
|
self._storage = storage
|
||||||
self._running: dict[str, RunningAccount] = {}
|
self._running: dict[str, RunningAccount] = {}
|
||||||
self._saved: dict[str, SavedState] = {}
|
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -92,8 +86,6 @@ class AccountRegistry:
|
|||||||
|
|
||||||
async def _start(self, path: Path, device_model: str | None) -> None:
|
async def _start(self, path: Path, device_model: str | None) -> None:
|
||||||
session_name = path.stem
|
session_name = path.stem
|
||||||
if session_name not in self._saved:
|
|
||||||
self._saved[session_name] = await asyncio.to_thread(read_saved_state, path)
|
|
||||||
client = PyroClient(
|
client = PyroClient(
|
||||||
session_name, workdir=str(path.parent), device_model=device_model
|
session_name, workdir=str(path.parent), device_model=device_model
|
||||||
)
|
)
|
||||||
@@ -108,20 +100,14 @@ class AccountRegistry:
|
|||||||
client, self._pool, self._storage, account_id
|
client, self._pool, self._storage, account_id
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(f"[red]Failed to start session:[/] {session_name}")
|
||||||
f"[red]Failed to start session:[/] {session_name}, "
|
|
||||||
f"retrying in {SESSION_RETRY_INTERVAL}s"
|
|
||||||
)
|
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await client.stop()
|
await client.stop()
|
||||||
return
|
return
|
||||||
consumer = JobConsumer(client, self._pool, account_id)
|
consumer = JobConsumer(client, self._pool, account_id)
|
||||||
saved = self._saved.pop(session_name)
|
self._running[session_name] = RunningAccount(
|
||||||
tasks = [
|
client, asyncio.create_task(consumer.run()), device_model
|
||||||
asyncio.create_task(consumer.run()),
|
)
|
||||||
asyncio.create_task(catch_up(client, saved)),
|
|
||||||
]
|
|
||||||
self._running[session_name] = RunningAccount(client, tasks, device_model)
|
|
||||||
logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})")
|
logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})")
|
||||||
await enqueue(self._pool, account_id, "sync_dialogs", {})
|
await enqueue(self._pool, account_id, "sync_dialogs", {})
|
||||||
await enqueue(self._pool, account_id, "sync_contacts", {})
|
await enqueue(self._pool, account_id, "sync_contacts", {})
|
||||||
@@ -130,8 +116,7 @@ class AccountRegistry:
|
|||||||
account = self._running.pop(session_name, None)
|
account = self._running.pop(session_name, None)
|
||||||
if account is None:
|
if account is None:
|
||||||
return
|
return
|
||||||
for task in account.tasks:
|
await _cancel(account.consumer_task)
|
||||||
await _cancel(task)
|
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await account.client.stop()
|
await account.client.stop()
|
||||||
logger.info(f"[yellow]Client stopped:[/] {session_name}")
|
logger.info(f"[yellow]Client stopped:[/] {session_name}")
|
||||||
@@ -139,8 +124,7 @@ class AccountRegistry:
|
|||||||
async def _log_out(self, path: Path) -> None:
|
async def _log_out(self, path: Path) -> None:
|
||||||
account = self._running.pop(path.stem, None)
|
account = self._running.pop(path.stem, None)
|
||||||
if account is not None:
|
if account is not None:
|
||||||
for task in account.tasks:
|
await _cancel(account.consumer_task)
|
||||||
await _cancel(task)
|
|
||||||
client = account.client
|
client = account.client
|
||||||
else:
|
else:
|
||||||
client = PyroClient(
|
client = PyroClient(
|
||||||
@@ -164,25 +148,9 @@ async def _flush_sessions(registry: AccountRegistry) -> None:
|
|||||||
await client.storage.save()
|
await client.storage.save()
|
||||||
|
|
||||||
|
|
||||||
async def _retry_sessions(registry: AccountRegistry) -> None:
|
async def _listen_changes(
|
||||||
while True:
|
registry: AccountRegistry, tasks: set[asyncio.Task]
|
||||||
await asyncio.sleep(SESSION_RETRY_INTERVAL)
|
) -> asyncpg.Connection:
|
||||||
try:
|
|
||||||
await registry.sync()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[red]Session sync failed[/]")
|
|
||||||
|
|
||||||
|
|
||||||
async def _keep_uptime(registry: AccountRegistry, pool: asyncpg.Pool) -> None:
|
|
||||||
while True:
|
|
||||||
for client in registry.clients:
|
|
||||||
if client.capture is not None and client.is_connected:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await uptime.heartbeat(pool, client.capture.account_id)
|
|
||||||
await asyncio.sleep(UPTIME_INTERVAL)
|
|
||||||
|
|
||||||
|
|
||||||
def _change_listener(registry: AccountRegistry, tasks: set[asyncio.Task]) -> Listener:
|
|
||||||
def spawn(coro: Coroutine[Any, Any, None]) -> None:
|
def spawn(coro: Coroutine[Any, Any, None]) -> None:
|
||||||
task = asyncio.create_task(coro)
|
task = asyncio.create_task(coro)
|
||||||
tasks.add(task)
|
tasks.add(task)
|
||||||
@@ -195,29 +163,26 @@ def _change_listener(registry: AccountRegistry, tasks: set[asyncio.Task]) -> Lis
|
|||||||
if client.capture is not None:
|
if client.capture is not None:
|
||||||
spawn(make_coro(client.capture))
|
spawn(make_coro(client.capture))
|
||||||
|
|
||||||
def on_policy(_payload: str) -> None:
|
def on_policy(
|
||||||
|
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
||||||
|
) -> None:
|
||||||
reload(lambda capture: capture.reload_policies())
|
reload(lambda capture: capture.reload_policies())
|
||||||
|
|
||||||
def on_watch(_payload: str) -> None:
|
def on_watch(
|
||||||
|
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
||||||
|
) -> None:
|
||||||
reload(lambda capture: capture.watches.refresh())
|
reload(lambda capture: capture.watches.refresh())
|
||||||
|
|
||||||
def on_accounts(_payload: str) -> None:
|
def on_accounts(
|
||||||
|
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
||||||
|
) -> None:
|
||||||
spawn(registry.sync())
|
spawn(registry.sync())
|
||||||
|
|
||||||
def on_reconnect() -> None:
|
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||||
on_policy("")
|
await conn.add_listener("policy_changed", on_policy)
|
||||||
on_watch("")
|
await conn.add_listener(WATCHES_CHANGED_CHANNEL, on_watch)
|
||||||
on_accounts("")
|
await conn.add_listener(ACCOUNTS_CHANGED_CHANNEL, on_accounts)
|
||||||
|
return conn
|
||||||
return Listener(
|
|
||||||
"userbot",
|
|
||||||
{
|
|
||||||
"policy_changed": on_policy,
|
|
||||||
WATCHES_CHANGED_CHANNEL: on_watch,
|
|
||||||
ACCOUNTS_CHANGED_CHANNEL: on_accounts,
|
|
||||||
},
|
|
||||||
on_reconnect,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def runner() -> None:
|
async def runner() -> None:
|
||||||
@@ -226,24 +191,22 @@ async def runner() -> None:
|
|||||||
|
|
||||||
registry = AccountRegistry(pool, storage)
|
registry = AccountRegistry(pool, storage)
|
||||||
tasks: set[asyncio.Task] = set()
|
tasks: set[asyncio.Task] = set()
|
||||||
listener = _change_listener(registry, tasks)
|
listen_conn: asyncpg.Connection | None = 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)
|
||||||
flush = asyncio.create_task(_flush_sessions(registry))
|
flush = asyncio.create_task(_flush_sessions(registry))
|
||||||
retry = asyncio.create_task(_retry_sessions(registry))
|
|
||||||
beat = asyncio.create_task(_keep_uptime(registry, pool))
|
|
||||||
try:
|
try:
|
||||||
await registry.sync()
|
await registry.sync()
|
||||||
if not registry.clients:
|
if not registry.clients:
|
||||||
logger.warning("[yellow]No sessions yet. Add an account in the web UI.[/]")
|
logger.warning("[yellow]No sessions yet. Add an account in the web UI.[/]")
|
||||||
await listener.start()
|
listen_conn = await _listen_changes(registry, tasks)
|
||||||
logger.info("[green]Userbot running.[/]")
|
logger.info("[green]Userbot running.[/]")
|
||||||
await stop.wait()
|
await stop.wait()
|
||||||
finally:
|
finally:
|
||||||
await _cancel(flush)
|
await _cancel(flush)
|
||||||
await _cancel(retry)
|
if listen_conn is not None:
|
||||||
await _cancel(beat)
|
with contextlib.suppress(Exception):
|
||||||
await listener.stop()
|
await listen_conn.close()
|
||||||
await registry.close()
|
await registry.close()
|
||||||
await container.close()
|
await container.close()
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,8 @@ from datetime import datetime
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import BigInteger, Column, DateTime, LargeBinary, Text, func
|
||||||
BigInteger,
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
Column,
|
|
||||||
DateTime,
|
|
||||||
ForeignKey,
|
|
||||||
Integer,
|
|
||||||
LargeBinary,
|
|
||||||
Text,
|
|
||||||
func,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
@@ -212,9 +203,6 @@ class Job(SQLModel, table=True):
|
|||||||
finished_at: datetime | None = Field(
|
finished_at: datetime | None = Field(
|
||||||
default=None, sa_column=Column(DateTime(timezone=True))
|
default=None, sa_column=Column(DateTime(timezone=True))
|
||||||
)
|
)
|
||||||
run_after: datetime | None = Field(
|
|
||||||
default=None, sa_column=Column(DateTime(timezone=True))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CapturePolicy(SQLModel, table=True):
|
class CapturePolicy(SQLModel, table=True):
|
||||||
@@ -356,7 +344,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), primary_key=True))
|
ts: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False))
|
||||||
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)
|
||||||
)
|
)
|
||||||
@@ -454,7 +442,6 @@ 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)
|
||||||
)
|
)
|
||||||
@@ -479,11 +466,7 @@ 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(
|
watch_id: int = Field(foreign_key="watches.id")
|
||||||
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)
|
||||||
@@ -560,62 +543,3 @@ 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,7 +41,6 @@ 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):
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from typing import Literal
|
|||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
BG_EVENTS_CHANNEL = "bg_events"
|
BG_EVENTS_CHANNEL = "bg_events"
|
||||||
MESSAGE_IDS_PER_EVENT = 500
|
|
||||||
|
|
||||||
EventKind = Literal["message", "edit", "delete", "reaction", "presence", "receipt"]
|
EventKind = Literal["message", "edit", "delete", "reaction", "presence", "receipt"]
|
||||||
|
|
||||||
@@ -23,15 +22,8 @@ async def notify_bg_event( # noqa: PLR0913
|
|||||||
payload["chat_id"] = chat_id
|
payload["chat_id"] = chat_id
|
||||||
if message_id is not None:
|
if message_id is not None:
|
||||||
payload["message_id"] = message_id
|
payload["message_id"] = message_id
|
||||||
if message_ids is None:
|
if message_ids is not None:
|
||||||
await _notify(pool, payload)
|
payload["message_ids"] = message_ids
|
||||||
return
|
|
||||||
for start in range(0, len(message_ids), MESSAGE_IDS_PER_EVENT):
|
|
||||||
chunk = message_ids[start : start + MESSAGE_IDS_PER_EVENT]
|
|
||||||
await _notify(pool, {**payload, "message_ids": chunk})
|
|
||||||
|
|
||||||
|
|
||||||
async def _notify(pool: asyncpg.Pool, payload: dict[str, object]) -> None:
|
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
"SELECT pg_notify($1, $2)", BG_EVENTS_CHANNEL, json.dumps(payload)
|
"SELECT pg_notify($1, $2)", BG_EVENTS_CHANNEL, json.dumps(payload)
|
||||||
)
|
)
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
@@ -8,22 +7,15 @@ JOBS_CHANGED_CHANNEL = "jobs_changed"
|
|||||||
|
|
||||||
|
|
||||||
async def enqueue(
|
async def enqueue(
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool, account_id: int, kind: str, params: dict[str, Any]
|
||||||
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') "
|
"AND params = $3::jsonb AND status IN ('pending', 'running') LIMIT 1",
|
||||||
"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,87 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
from collections.abc import Callable, Mapping
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
from utils.env import env
|
|
||||||
from utils.logging import logger
|
|
||||||
|
|
||||||
RETRY_MIN_SECONDS = 1.0
|
|
||||||
RETRY_MAX_SECONDS = 60.0
|
|
||||||
PING_INTERVAL_SECONDS = 60.0
|
|
||||||
PING_TIMEOUT_SECONDS = 10.0
|
|
||||||
CLOSE_TIMEOUT_SECONDS = 5.0
|
|
||||||
|
|
||||||
OnNotify = Callable[[str], None]
|
|
||||||
|
|
||||||
|
|
||||||
class Listener:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
channels: Mapping[str, OnNotify],
|
|
||||||
on_reconnect: Callable[[], None] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._name = name
|
|
||||||
self._channels = channels
|
|
||||||
self._on_reconnect = on_reconnect
|
|
||||||
self._task: asyncio.Task | None = None
|
|
||||||
self._ready = asyncio.Event()
|
|
||||||
self._listening = False
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
self._task = asyncio.create_task(self._run())
|
|
||||||
await self._ready.wait()
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
if self._task is None:
|
|
||||||
return
|
|
||||||
self._task.cancel()
|
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
|
||||||
await self._task
|
|
||||||
self._task = None
|
|
||||||
|
|
||||||
async def _run(self) -> None:
|
|
||||||
delay = RETRY_MIN_SECONDS
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await self._listen()
|
|
||||||
except Exception as exc:
|
|
||||||
if self._listening:
|
|
||||||
delay = RETRY_MIN_SECONDS
|
|
||||||
logger.warning(
|
|
||||||
f"{self._name}: no listen connection ({exc!r}), "
|
|
||||||
f"reconnecting in {delay:.0f}s"
|
|
||||||
)
|
|
||||||
self._listening = False
|
|
||||||
self._ready.set()
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
delay = min(delay * 2, RETRY_MAX_SECONDS)
|
|
||||||
|
|
||||||
async def _listen(self) -> None:
|
|
||||||
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
|
||||||
try:
|
|
||||||
lost = asyncio.Event()
|
|
||||||
conn.add_termination_listener(lambda _conn: lost.set())
|
|
||||||
for channel, on_notify in self._channels.items():
|
|
||||||
await conn.add_listener(
|
|
||||||
channel,
|
|
||||||
lambda _conn, _pid, _channel, payload, f=on_notify: f(payload),
|
|
||||||
)
|
|
||||||
logger.info(f"{self._name}: listening on {', '.join(self._channels)}")
|
|
||||||
self._listening = True
|
|
||||||
if self._ready.is_set() and self._on_reconnect is not None:
|
|
||||||
self._on_reconnect()
|
|
||||||
self._ready.set()
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(lost.wait(), PING_INTERVAL_SECONDS)
|
|
||||||
except TimeoutError:
|
|
||||||
await conn.fetchval("SELECT 1", timeout=PING_TIMEOUT_SECONDS)
|
|
||||||
else:
|
|
||||||
msg = "closed by the server"
|
|
||||||
raise ConnectionError(msg)
|
|
||||||
finally:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await conn.close(timeout=CLOSE_TIMEOUT_SECONDS)
|
|
||||||
@@ -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(track_edits_deletes=True),
|
ChatKind.CHANNEL: CaptureToggles(),
|
||||||
ChatKind.GROUP: CaptureToggles(messages=True, track_edits_deletes=True),
|
ChatKind.GROUP: CaptureToggles(messages=True),
|
||||||
ChatKind.DM: CaptureToggles(
|
ChatKind.DM: CaptureToggles(
|
||||||
messages=True,
|
messages=True,
|
||||||
media=True,
|
media=True,
|
||||||
|
|||||||
@@ -4,31 +4,23 @@ import asyncpg
|
|||||||
|
|
||||||
from utils.read.accounts import self_user_id
|
from utils.read.accounts import self_user_id
|
||||||
from utils.read.models import ResponseStats, VolumeBucket
|
from utils.read.models import ResponseStats, VolumeBucket
|
||||||
from utils.read.period import period_bounds
|
|
||||||
|
|
||||||
_VOLUME = """
|
|
||||||
WITH counts AS (
|
|
||||||
SELECT (date AT TIME ZONE $5)::date AS day, count(*) AS total,
|
|
||||||
count(*) FILTER (WHERE sender_id = $3) AS outgoing
|
|
||||||
FROM messages
|
|
||||||
WHERE account_id = $1 AND chat_id = $2 AND date >= $4
|
|
||||||
GROUP BY 1
|
|
||||||
)
|
|
||||||
SELECT local_day::date AS bucket, coalesce(total, 0) AS total,
|
|
||||||
coalesce(outgoing, 0) AS outgoing
|
|
||||||
FROM generate_series(($4 AT TIME ZONE $5)::date, (now() AT TIME ZONE $5)::date,
|
|
||||||
interval '1 day') AS local_day
|
|
||||||
LEFT JOIN counts ON counts.day = local_day::date
|
|
||||||
ORDER BY 1
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
async def message_volume(
|
async def message_volume(
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int, tz: str
|
pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int = 90
|
||||||
) -> list[VolumeBucket]:
|
) -> list[VolumeBucket]:
|
||||||
self_id = await self_user_id(pool, account_id)
|
self_id = await self_user_id(pool, account_id)
|
||||||
since, _ = await period_bounds(pool, days, tz)
|
rows = await pool.fetch(
|
||||||
rows = await pool.fetch(_VOLUME, account_id, chat_id, self_id, since, tz)
|
"SELECT date_trunc('day', date) AS bucket, count(*) AS total, "
|
||||||
|
"count(*) FILTER (WHERE sender_id = $3) AS outgoing "
|
||||||
|
"FROM messages "
|
||||||
|
"WHERE account_id = $1 AND chat_id = $2 AND date >= $4 "
|
||||||
|
"GROUP BY bucket ORDER BY bucket",
|
||||||
|
account_id,
|
||||||
|
chat_id,
|
||||||
|
self_id,
|
||||||
|
datetime.now(UTC) - timedelta(days=days),
|
||||||
|
)
|
||||||
return [
|
return [
|
||||||
VolumeBucket(
|
VolumeBucket(
|
||||||
bucket=row["bucket"],
|
bucket=row["bucket"],
|
||||||
|
|||||||
@@ -1,34 +1,9 @@
|
|||||||
import re
|
|
||||||
from collections import Counter
|
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.read.chats import get_chats
|
from utils.read.models import AnnotationView, Page
|
||||||
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,
|
||||||
@@ -95,49 +70,3 @@ 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]))
|
|
||||||
]
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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,5 +1,3 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.policy.models import FolderSpec
|
from utils.policy.models import FolderSpec
|
||||||
@@ -12,7 +10,7 @@ from utils.read.models import (
|
|||||||
MessageView,
|
MessageView,
|
||||||
Page,
|
Page,
|
||||||
)
|
)
|
||||||
from utils.read.read_receipts import read_times, read_up_to
|
from utils.read.read_receipts import read_up_to
|
||||||
|
|
||||||
_MESSAGE_COLS = (
|
_MESSAGE_COLS = (
|
||||||
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
|
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
|
||||||
@@ -80,8 +78,7 @@ _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_message_id,
|
cs.last_date, cs.last_text, cs.last_sender_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,
|
||||||
@@ -150,8 +147,6 @@ 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"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -262,7 +257,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -282,7 +276,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -292,35 +285,12 @@ async def _apply_read_status(
|
|||||||
self_id = await self_user_id(pool, account_id)
|
self_id = await self_user_id(pool, account_id)
|
||||||
if self_id is None:
|
if self_id is None:
|
||||||
return
|
return
|
||||||
own = [view for view in views if view.sender_id == self_id]
|
|
||||||
if not own:
|
|
||||||
return
|
|
||||||
marker = await read_up_to(pool, account_id, chat_id)
|
marker = await read_up_to(pool, account_id, chat_id)
|
||||||
times = await read_times(
|
if marker is None:
|
||||||
pool, account_id, chat_id, [(view.message_id, view.date) for view in own]
|
|
||||||
)
|
|
||||||
for view in own:
|
|
||||||
view.read_at = times.get(view.message_id)
|
|
||||||
view.read = view.read_at is not None or (
|
|
||||||
marker is not None and view.message_id <= marker
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _apply_notes(
|
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int, views: list[MessageView]
|
|
||||||
) -> None:
|
|
||||||
if not views:
|
|
||||||
return
|
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:
|
for view in views:
|
||||||
view.has_note = view.message_id in noted
|
if view.sender_id == self_id and view.message_id <= marker:
|
||||||
|
view.read = True
|
||||||
|
|
||||||
|
|
||||||
def _build_album(
|
def _build_album(
|
||||||
@@ -355,12 +325,6 @@ 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:
|
||||||
@@ -371,67 +335,6 @@ async def _single_views(
|
|||||||
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]:
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import date, datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -38,8 +38,6 @@ 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):
|
||||||
@@ -205,8 +203,6 @@ class MessageView(BaseModel):
|
|||||||
is_sticker: bool = False
|
is_sticker: bool = False
|
||||||
is_animated_emoji: bool = False
|
is_animated_emoji: bool = False
|
||||||
read: bool = False
|
read: bool = False
|
||||||
read_at: datetime | None = None
|
|
||||||
has_note: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class MessageVersionView(BaseModel):
|
class MessageVersionView(BaseModel):
|
||||||
@@ -215,50 +211,6 @@ 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
|
||||||
@@ -409,61 +361,8 @@ class PresenceHourly(BaseModel):
|
|||||||
last_seen: datetime | None
|
last_seen: datetime | None
|
||||||
|
|
||||||
|
|
||||||
class PresenceSession(BaseModel):
|
|
||||||
started: datetime
|
|
||||||
ended: datetime
|
|
||||||
seconds: float
|
|
||||||
online_seconds: float
|
|
||||||
ongoing: bool
|
|
||||||
|
|
||||||
|
|
||||||
class PresenceHour(BaseModel):
|
|
||||||
weekday: int
|
|
||||||
hour: int
|
|
||||||
hours: int
|
|
||||||
online_minutes: float | None
|
|
||||||
covered_hours: float
|
|
||||||
|
|
||||||
|
|
||||||
class PresenceDay(BaseModel):
|
|
||||||
day: date
|
|
||||||
online_seconds: float
|
|
||||||
covered_seconds: float
|
|
||||||
|
|
||||||
|
|
||||||
class CoverageGap(BaseModel):
|
|
||||||
started: datetime
|
|
||||||
ended: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class PresenceSummary(BaseModel):
|
|
||||||
online_seconds: float
|
|
||||||
daily_seconds: float | None
|
|
||||||
covered_seconds: float
|
|
||||||
session_count: int
|
|
||||||
median_session_seconds: float | None
|
|
||||||
first_online: int | None
|
|
||||||
last_offline: int | None
|
|
||||||
last_seen: datetime | None
|
|
||||||
|
|
||||||
|
|
||||||
class PresenceStats(BaseModel):
|
|
||||||
peer_id: int
|
|
||||||
tz: str
|
|
||||||
since: datetime
|
|
||||||
until: datetime
|
|
||||||
current: PresenceSample | None
|
|
||||||
online: bool
|
|
||||||
hidden: bool
|
|
||||||
summary: PresenceSummary
|
|
||||||
week: list[PresenceHour]
|
|
||||||
days: list[PresenceDay]
|
|
||||||
gaps: list[CoverageGap]
|
|
||||||
sessions: list[PresenceSession]
|
|
||||||
|
|
||||||
|
|
||||||
class VolumeBucket(BaseModel):
|
class VolumeBucket(BaseModel):
|
||||||
bucket: date
|
bucket: datetime
|
||||||
total: int
|
total: int
|
||||||
outgoing: int
|
outgoing: int
|
||||||
incoming: int
|
incoming: int
|
||||||
@@ -488,29 +387,6 @@ class PeerView(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ReaderView(BaseModel):
|
|
||||||
peer_id: int
|
|
||||||
read_at: datetime
|
|
||||||
peer: PeerView | None
|
|
||||||
|
|
||||||
|
|
||||||
class ReadInfoView(BaseModel):
|
|
||||||
chat_id: int
|
|
||||||
message_id: int
|
|
||||||
chat_kind: str
|
|
||||||
own: bool
|
|
||||||
voice: bool
|
|
||||||
observed_at: datetime | None
|
|
||||||
listened_at: datetime | None
|
|
||||||
exact_at: datetime | None
|
|
||||||
readers: list[ReaderView]
|
|
||||||
read: bool
|
|
||||||
unread: bool
|
|
||||||
outcome: str | None
|
|
||||||
fetched_at: datetime | None
|
|
||||||
can_fetch: bool
|
|
||||||
|
|
||||||
|
|
||||||
class PeerHistoryView(BaseModel):
|
class PeerHistoryView(BaseModel):
|
||||||
observed_at: datetime
|
observed_at: datetime
|
||||||
first_name: str | None
|
first_name: str | None
|
||||||
@@ -521,54 +397,6 @@ 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
|
||||||
@@ -614,62 +442,16 @@ 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
|
||||||
@@ -680,68 +462,6 @@ 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,8 +2,6 @@ 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, "
|
||||||
@@ -11,23 +9,6 @@ _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
|
||||||
@@ -58,7 +39,14 @@ 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(_HISTORY, account_id, peer_id, HISTORY_LIMIT)
|
rows = await pool.fetch(
|
||||||
|
"SELECT observed_at, first_name, last_name, username, phone, "
|
||||||
|
"photo_unique_id, is_deleted_account FROM peer_history "
|
||||||
|
"WHERE account_id = $1 AND peer_id = $2 "
|
||||||
|
"ORDER BY observed_at DESC LIMIT 500",
|
||||||
|
account_id,
|
||||||
|
peer_id,
|
||||||
|
)
|
||||||
return [PeerHistoryView(**dict(row)) for row in rows]
|
return [PeerHistoryView(**dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
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)
|
|
||||||
)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
DEFAULT_TZ = "UTC"
|
|
||||||
MAX_DAYS = 365
|
|
||||||
|
|
||||||
_timezones: set[str] = set()
|
|
||||||
|
|
||||||
|
|
||||||
async def known_timezone(pool: asyncpg.Pool, tz: str) -> bool:
|
|
||||||
if not _timezones:
|
|
||||||
rows = await pool.fetch("SELECT name FROM pg_timezone_names")
|
|
||||||
_timezones.update(row["name"] for row in rows)
|
|
||||||
return tz in _timezones
|
|
||||||
|
|
||||||
|
|
||||||
async def period_bounds(
|
|
||||||
pool: asyncpg.Pool, days: int, tz: str
|
|
||||||
) -> tuple[datetime, datetime]:
|
|
||||||
row = await pool.fetchrow(
|
|
||||||
"SELECT (date_trunc('day', now() AT TIME ZONE $1) "
|
|
||||||
"- make_interval(days => $2 - 1)) AT TIME ZONE $1 AS since, now() AS until",
|
|
||||||
tz,
|
|
||||||
days,
|
|
||||||
)
|
|
||||||
return row["since"], row["until"]
|
|
||||||
@@ -48,29 +48,6 @@ 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,
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
from utils.read.models import (
|
|
||||||
CoverageGap,
|
|
||||||
Page,
|
|
||||||
PresenceDay,
|
|
||||||
PresenceHour,
|
|
||||||
PresenceSession,
|
|
||||||
PresenceStats,
|
|
||||||
PresenceSummary,
|
|
||||||
)
|
|
||||||
from utils.read.period import period_bounds
|
|
||||||
from utils.read.presence import current_presence
|
|
||||||
|
|
||||||
SECONDS_IN_HOUR = 3600
|
|
||||||
SECONDS_IN_DAY = 86_400
|
|
||||||
MIN_CELL_COVERAGE = 1800
|
|
||||||
MIN_CELL_SHARE = 0.5
|
|
||||||
STATS_WORK_MEM = "32MB"
|
|
||||||
FUZZY_STATUSES = {"recently", "last_week", "last_month", "long_time_ago"}
|
|
||||||
|
|
||||||
_SESSIONS = """
|
|
||||||
marks AS (
|
|
||||||
SELECT ts, status,
|
|
||||||
least(next_offline_date, ts + interval '5 minutes',
|
|
||||||
lead(coalesce(last_online_date, ts)) OVER w, $4) AS ended,
|
|
||||||
lag(CASE WHEN status = 'online'
|
|
||||||
THEN least(next_offline_date, ts + interval '5 minutes')
|
|
||||||
ELSE coalesce(last_online_date, ts) END) OVER w AS previous
|
|
||||||
FROM presence
|
|
||||||
WHERE account_id = $1 AND peer_id = $2
|
|
||||||
AND ts >= $3::timestamptz - interval '5 minutes' AND ts <= $4
|
|
||||||
WINDOW w AS (ORDER BY ts)
|
|
||||||
),
|
|
||||||
online AS (
|
|
||||||
SELECT greatest(ts, $3) AS started, ended,
|
|
||||||
count(*) FILTER (WHERE previous IS NULL
|
|
||||||
OR ts > previous + interval '5 minutes')
|
|
||||||
OVER (ORDER BY ts) AS visit
|
|
||||||
FROM marks
|
|
||||||
WHERE status = 'online' AND ended > greatest(ts, $3)
|
|
||||||
),
|
|
||||||
sessions AS (
|
|
||||||
SELECT min(started) AS started, max(ended) AS ended,
|
|
||||||
extract(epoch FROM sum(ended - started))::float8 AS online_seconds
|
|
||||||
FROM online
|
|
||||||
GROUP BY visit
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SESSION_FIELDS = """
|
|
||||||
started, ended, extract(epoch FROM ended - started)::float8 AS seconds,
|
|
||||||
online_seconds, ended >= $4 AS ongoing
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SESSION_PAGE = f"""
|
|
||||||
WITH {_SESSIONS}
|
|
||||||
SELECT {_SESSION_FIELDS}
|
|
||||||
FROM sessions
|
|
||||||
ORDER BY started DESC
|
|
||||||
LIMIT $5 OFFSET $6
|
|
||||||
""" # noqa: S608
|
|
||||||
|
|
||||||
_COVERED_HOURS = """
|
|
||||||
SELECT local_hour,
|
|
||||||
extract(epoch FROM sum(
|
|
||||||
least(ended, (local_hour + interval '1 hour') AT TIME ZONE $5)
|
|
||||||
- greatest(started, local_hour AT TIME ZONE $5)))::float8 AS seconds
|
|
||||||
FROM uptime,
|
|
||||||
generate_series(date_trunc('hour', started AT TIME ZONE $5),
|
|
||||||
date_trunc('hour', ended AT TIME ZONE $5),
|
|
||||||
interval '1 hour') AS local_hour
|
|
||||||
GROUP BY local_hour
|
|
||||||
"""
|
|
||||||
|
|
||||||
_STATS = f"""
|
|
||||||
WITH {_SESSIONS},
|
|
||||||
uptime AS (
|
|
||||||
SELECT greatest(started_at, $3) AS started,
|
|
||||||
least(seen_at + interval '90 seconds', $4) AS ended
|
|
||||||
FROM capture_uptime
|
|
||||||
WHERE account_id = $1 AND started_at < $4
|
|
||||||
AND seen_at + interval '90 seconds' > $3
|
|
||||||
),
|
|
||||||
quarters AS (
|
|
||||||
SELECT quarter, sum(ended - started - spill) AS inside, sum(spill) AS spill
|
|
||||||
FROM (SELECT started, ended, quarter,
|
|
||||||
greatest(ended - quarter - interval '15 minutes',
|
|
||||||
interval '0') AS spill
|
|
||||||
FROM (SELECT started, ended,
|
|
||||||
date_bin('15 minutes', started, timestamptz 'epoch') AS quarter
|
|
||||||
FROM online) binned) split
|
|
||||||
GROUP BY quarter
|
|
||||||
),
|
|
||||||
online_hours AS (
|
|
||||||
SELECT date_trunc('hour', quarter AT TIME ZONE $5) AS local_hour,
|
|
||||||
extract(epoch FROM sum(share))::float8 AS seconds
|
|
||||||
FROM (SELECT quarter, inside AS share FROM quarters
|
|
||||||
UNION ALL
|
|
||||||
SELECT quarter + interval '15 minutes', spill FROM quarters) shares
|
|
||||||
GROUP BY 1
|
|
||||||
),
|
|
||||||
covered_hours AS ({_COVERED_HOURS}),
|
|
||||||
hours AS (
|
|
||||||
SELECT local_hour,
|
|
||||||
extract(epoch FROM
|
|
||||||
least((local_hour + interval '1 hour') AT TIME ZONE $5, $4)
|
|
||||||
- greatest(local_hour AT TIME ZONE $5, $3))::float8 AS span,
|
|
||||||
coalesce(online.seconds, 0) AS online,
|
|
||||||
least(3600, greatest(coalesce(covered.seconds, 0),
|
|
||||||
coalesce(online.seconds, 0))) AS covered
|
|
||||||
FROM generate_series(date_trunc('hour', $3 AT TIME ZONE $5),
|
|
||||||
date_trunc('hour', $4 AT TIME ZONE $5),
|
|
||||||
interval '1 hour') AS local_hour
|
|
||||||
LEFT JOIN online_hours AS online USING (local_hour)
|
|
||||||
LEFT JOIN covered_hours AS covered USING (local_hour)
|
|
||||||
),
|
|
||||||
week AS (
|
|
||||||
SELECT extract(isodow FROM local_hour)::int - 1 AS weekday,
|
|
||||||
extract(hour FROM local_hour)::int AS hour,
|
|
||||||
count(*) AS hours, sum(span) AS span,
|
|
||||||
sum(online) AS online, sum(covered) AS covered
|
|
||||||
FROM hours
|
|
||||||
GROUP BY 1, 2
|
|
||||||
),
|
|
||||||
days AS (
|
|
||||||
SELECT local_hour::date AS day, sum(online) AS online, sum(covered) AS covered
|
|
||||||
FROM hours
|
|
||||||
GROUP BY 1
|
|
||||||
),
|
|
||||||
uptime_reach AS (
|
|
||||||
SELECT started,
|
|
||||||
coalesce(max(ended) OVER (ORDER BY started
|
|
||||||
ROWS BETWEEN UNBOUNDED PRECEDING
|
|
||||||
AND 1 PRECEDING), $3) AS reached
|
|
||||||
FROM uptime
|
|
||||||
),
|
|
||||||
gaps AS (
|
|
||||||
SELECT reached AS started, started AS ended FROM uptime_reach
|
|
||||||
UNION ALL
|
|
||||||
SELECT coalesce(max(ended), $3), $4 FROM uptime
|
|
||||||
),
|
|
||||||
quiet AS (
|
|
||||||
SELECT hour
|
|
||||||
FROM week
|
|
||||||
GROUP BY hour
|
|
||||||
ORDER BY sum(online) / nullif(sum(covered), 0) NULLS LAST, hour
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
activity_days AS (
|
|
||||||
SELECT (sessions.started AT TIME ZONE $5) - make_interval(hours => quiet.hour)
|
|
||||||
AS started,
|
|
||||||
(sessions.ended AT TIME ZONE $5) - make_interval(hours => quiet.hour)
|
|
||||||
AS ended,
|
|
||||||
(($4 AT TIME ZONE $5) - make_interval(hours => quiet.hour))::date AS today
|
|
||||||
FROM sessions, quiet
|
|
||||||
),
|
|
||||||
daily AS (
|
|
||||||
SELECT extract(epoch FROM min(started - started::date))::float8 AS first_online,
|
|
||||||
extract(epoch FROM max(ended - started::date))::float8 AS last_offline
|
|
||||||
FROM activity_days
|
|
||||||
WHERE started::date < today
|
|
||||||
GROUP BY started::date
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
(SELECT count(*) FROM sessions) AS session_count,
|
|
||||||
(SELECT coalesce(sum(online_seconds), 0) FROM sessions) AS online_seconds,
|
|
||||||
(SELECT sum(covered) FROM hours) AS covered_seconds,
|
|
||||||
(SELECT percentile_cont(0.5) WITHIN GROUP (
|
|
||||||
ORDER BY extract(epoch FROM ended - started)) FROM sessions) AS median_session,
|
|
||||||
(SELECT max(ended) FROM sessions) AS last_seen,
|
|
||||||
(SELECT hour FROM quiet) AS day_start,
|
|
||||||
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY first_online)
|
|
||||||
FROM daily) AS first_online,
|
|
||||||
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY last_offline)
|
|
||||||
FROM daily) AS last_offline,
|
|
||||||
(SELECT json_agg(json_build_object(
|
|
||||||
'weekday', weekday, 'hour', hour, 'hours', hours, 'span', span,
|
|
||||||
'online', online, 'covered', covered))
|
|
||||||
FROM week) AS week,
|
|
||||||
(SELECT json_agg(json_build_object(
|
|
||||||
'day', day, 'online_seconds', online, 'covered_seconds', covered)
|
|
||||||
ORDER BY day)
|
|
||||||
FROM days) AS days,
|
|
||||||
(SELECT json_agg(json_build_object('started', started, 'ended', ended)
|
|
||||||
ORDER BY started)
|
|
||||||
FROM gaps WHERE ended - started > interval '5 minutes') AS gaps,
|
|
||||||
(SELECT json_agg(row_to_json(page) ORDER BY page.started DESC)
|
|
||||||
FROM (SELECT {_SESSION_FIELDS} FROM sessions
|
|
||||||
ORDER BY started DESC LIMIT $6 OFFSET $7) page) AS sessions
|
|
||||||
""" # noqa: S608
|
|
||||||
|
|
||||||
|
|
||||||
def _time_of_day(shifted: float | None, day_start: int | None) -> int | None:
|
|
||||||
if shifted is None or day_start is None:
|
|
||||||
return None
|
|
||||||
return round(shifted + day_start * SECONDS_IN_HOUR) % SECONDS_IN_DAY
|
|
||||||
|
|
||||||
|
|
||||||
def _week(rows: list[dict]) -> list[PresenceHour]:
|
|
||||||
cells = {(row["weekday"], row["hour"]): row for row in rows}
|
|
||||||
week: list[PresenceHour] = []
|
|
||||||
for weekday in range(7):
|
|
||||||
for hour in range(24):
|
|
||||||
row = cells.get(
|
|
||||||
(weekday, hour), {"hours": 0, "span": 0, "online": 0, "covered": 0}
|
|
||||||
)
|
|
||||||
covered = row["covered"]
|
|
||||||
minutes = (
|
|
||||||
min(60.0, row["online"] / covered * 60)
|
|
||||||
if covered > 0
|
|
||||||
and covered >= min(MIN_CELL_COVERAGE, row["span"] * MIN_CELL_SHARE)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
week.append(
|
|
||||||
PresenceHour(
|
|
||||||
weekday=weekday,
|
|
||||||
hour=hour,
|
|
||||||
hours=row["hours"],
|
|
||||||
online_minutes=minutes,
|
|
||||||
covered_hours=covered / SECONDS_IN_HOUR,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return week
|
|
||||||
|
|
||||||
|
|
||||||
async def presence_stats( # noqa: PLR0913
|
|
||||||
pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str
|
|
||||||
) -> PresenceStats:
|
|
||||||
since, until = await period_bounds(pool, days, tz)
|
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
|
||||||
await conn.execute(f"SET LOCAL work_mem = '{STATS_WORK_MEM}'")
|
|
||||||
row = await conn.fetchrow(
|
|
||||||
_STATS,
|
|
||||||
account_id,
|
|
||||||
peer_id,
|
|
||||||
since,
|
|
||||||
until,
|
|
||||||
tz,
|
|
||||||
page.capped_limit,
|
|
||||||
page.offset,
|
|
||||||
)
|
|
||||||
current = await current_presence(pool, account_id, peer_id)
|
|
||||||
sessions = [PresenceSession(**item) for item in json.loads(row["sessions"] or "[]")]
|
|
||||||
covered = row["covered_seconds"] or 0
|
|
||||||
online = row["online_seconds"]
|
|
||||||
return PresenceStats(
|
|
||||||
peer_id=peer_id,
|
|
||||||
tz=tz,
|
|
||||||
since=since,
|
|
||||||
until=until,
|
|
||||||
current=current,
|
|
||||||
online=current is not None
|
|
||||||
and current.status == "online"
|
|
||||||
and current.next_offline_date is not None
|
|
||||||
and current.next_offline_date > until,
|
|
||||||
hidden=row["session_count"] == 0
|
|
||||||
and current is not None
|
|
||||||
and current.status in FUZZY_STATUSES,
|
|
||||||
summary=PresenceSummary(
|
|
||||||
online_seconds=online,
|
|
||||||
daily_seconds=online / covered * SECONDS_IN_DAY
|
|
||||||
if covered >= SECONDS_IN_HOUR
|
|
||||||
else None,
|
|
||||||
covered_seconds=covered,
|
|
||||||
session_count=row["session_count"],
|
|
||||||
median_session_seconds=row["median_session"],
|
|
||||||
first_online=_time_of_day(row["first_online"], row["day_start"]),
|
|
||||||
last_offline=_time_of_day(row["last_offline"], row["day_start"]),
|
|
||||||
last_seen=row["last_seen"]
|
|
||||||
or (current.last_online_date if current else None),
|
|
||||||
),
|
|
||||||
week=_week(json.loads(row["week"] or "[]")),
|
|
||||||
days=[PresenceDay(**item) for item in json.loads(row["days"] or "[]")],
|
|
||||||
gaps=[CoverageGap(**item) for item in json.loads(row["gaps"] or "[]")],
|
|
||||||
sessions=sessions,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def presence_sessions( # noqa: PLR0913
|
|
||||||
pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str
|
|
||||||
) -> list[PresenceSession]:
|
|
||||||
since, until = await period_bounds(pool, days, tz)
|
|
||||||
rows = await pool.fetch(
|
|
||||||
_SESSION_PAGE, account_id, peer_id, since, until, page.capped_limit, page.offset
|
|
||||||
)
|
|
||||||
return [PresenceSession(**dict(row)) for row in rows]
|
|
||||||
@@ -1,89 +1,5 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.policy.models import ChatKind
|
|
||||||
from utils.read.discover import chat_kind
|
|
||||||
from utils.read.models import ReaderView, ReadInfoView
|
|
||||||
from utils.read.peers import get_peers
|
|
||||||
|
|
||||||
FETCH_WINDOW = timedelta(days=7)
|
|
||||||
CLOCK_SKEW = timedelta(minutes=1)
|
|
||||||
VOICE_KINDS = ["voice", "video_note"]
|
|
||||||
|
|
||||||
_READ_TIMES = """
|
|
||||||
SELECT m.id,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT min(mr.read_at) FROM message_reads mr
|
|
||||||
WHERE mr.account_id = $1 AND mr.chat_id = $2 AND mr.message_id = m.id),
|
|
||||||
CASE WHEN m.date >= $5 THEN (
|
|
||||||
SELECT r.ts FROM read_receipts r
|
|
||||||
WHERE r.account_id = $1 AND r.chat_id = $2 AND r.kind = 'read'
|
|
||||||
AND r.message_id >= m.id AND r.ts >= m.date - $6::interval
|
|
||||||
ORDER BY r.ts LIMIT 1
|
|
||||||
) END
|
|
||||||
) AS read_at
|
|
||||||
FROM unnest($3::bigint[], $4::timestamptz[]) AS m(id, date)
|
|
||||||
"""
|
|
||||||
|
|
||||||
_MESSAGE = """
|
|
||||||
SELECT m.sender_id, m.date, a.tg_user_id, a.is_bot,
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM media md
|
|
||||||
WHERE md.account_id = m.account_id AND md.chat_id = m.chat_id
|
|
||||||
AND md.message_id = m.message_id AND md.kind = ANY($4::text[])
|
|
||||||
) AS voice
|
|
||||||
FROM messages m
|
|
||||||
JOIN accounts a ON a.account_id = m.account_id
|
|
||||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
|
|
||||||
LIMIT 1
|
|
||||||
"""
|
|
||||||
|
|
||||||
_RECEIPTS = """
|
|
||||||
SELECT
|
|
||||||
(SELECT ts FROM read_receipts
|
|
||||||
WHERE account_id = $1 AND chat_id = $2 AND kind = 'read'
|
|
||||||
AND message_id >= $3 AND ts >= $4
|
|
||||||
ORDER BY ts LIMIT 1) AS observed_at,
|
|
||||||
(SELECT min(ts) FROM read_receipts
|
|
||||||
WHERE account_id = $1 AND chat_id = $2 AND kind = 'listened'
|
|
||||||
AND message_id = $3) AS listened_at,
|
|
||||||
(SELECT max(message_id) FROM read_receipts
|
|
||||||
WHERE account_id = $1 AND chat_id = $2 AND kind = 'read') AS watermark
|
|
||||||
"""
|
|
||||||
|
|
||||||
_READS = """
|
|
||||||
SELECT reader_id, read_at, source FROM message_reads
|
|
||||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
|
|
||||||
ORDER BY read_at
|
|
||||||
"""
|
|
||||||
|
|
||||||
_FETCH = """
|
|
||||||
SELECT outcome, fetched_at, retry_at FROM read_fetches
|
|
||||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
|
|
||||||
"""
|
|
||||||
|
|
||||||
_INHERITED = """
|
|
||||||
SELECT outcome FROM read_fetches
|
|
||||||
WHERE account_id = $1 AND retry_at > now()
|
|
||||||
AND ((chat_id = $2 AND outcome = 'chat_too_big') OR outcome = 'your_privacy')
|
|
||||||
LIMIT 1
|
|
||||||
"""
|
|
||||||
|
|
||||||
_capture_starts: dict[int, datetime] = {}
|
|
||||||
|
|
||||||
|
|
||||||
async def capture_start(pool: asyncpg.Pool, account_id: int) -> datetime | None:
|
|
||||||
cached = _capture_starts.get(account_id)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
start = await pool.fetchval(
|
|
||||||
"SELECT min(ts) FROM read_receipts WHERE account_id = $1", account_id
|
|
||||||
)
|
|
||||||
if start is not None:
|
|
||||||
_capture_starts[account_id] = start
|
|
||||||
return start
|
|
||||||
|
|
||||||
|
|
||||||
async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int | None:
|
async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int | None:
|
||||||
return await pool.fetchval(
|
return await pool.fetchval(
|
||||||
@@ -92,97 +8,3 @@ async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int |
|
|||||||
account_id,
|
account_id,
|
||||||
chat_id,
|
chat_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def read_times(
|
|
||||||
pool: asyncpg.Pool,
|
|
||||||
account_id: int,
|
|
||||||
chat_id: int,
|
|
||||||
messages: list[tuple[int, datetime]],
|
|
||||||
) -> dict[int, datetime]:
|
|
||||||
if not messages:
|
|
||||||
return {}
|
|
||||||
start = await capture_start(pool, account_id) or datetime.max.replace(tzinfo=UTC)
|
|
||||||
rows = await pool.fetch(
|
|
||||||
_READ_TIMES,
|
|
||||||
account_id,
|
|
||||||
chat_id,
|
|
||||||
[message_id for message_id, _ in messages],
|
|
||||||
[date for _, date in messages],
|
|
||||||
start,
|
|
||||||
CLOCK_SKEW,
|
|
||||||
)
|
|
||||||
return {row["id"]: row["read_at"] for row in rows if row["read_at"] is not None}
|
|
||||||
|
|
||||||
|
|
||||||
async def read_info(
|
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
|
||||||
) -> ReadInfoView | None:
|
|
||||||
message = await pool.fetchrow(
|
|
||||||
_MESSAGE, account_id, chat_id, message_id, VOICE_KINDS
|
|
||||||
)
|
|
||||||
if message is None:
|
|
||||||
return None
|
|
||||||
kind = await chat_kind(pool, account_id, chat_id)
|
|
||||||
date: datetime = message["date"]
|
|
||||||
receipts = await pool.fetchrow(
|
|
||||||
_RECEIPTS, account_id, chat_id, message_id, date - CLOCK_SKEW
|
|
||||||
)
|
|
||||||
reads = await pool.fetch(_READS, account_id, chat_id, message_id)
|
|
||||||
fetch = await pool.fetchrow(_FETCH, account_id, chat_id, message_id)
|
|
||||||
inherited = await pool.fetchval(_INHERITED, account_id, chat_id)
|
|
||||||
start = await capture_start(pool, account_id)
|
|
||||||
|
|
||||||
observed_at = receipts["observed_at"] if start and date >= start else None
|
|
||||||
exact_at = next(
|
|
||||||
(row["read_at"] for row in reads if row["source"] == "outbox_date"), None
|
|
||||||
)
|
|
||||||
reader_rows = [row for row in reads if row["source"] == "participants"]
|
|
||||||
known = {
|
|
||||||
peer.peer_id: peer
|
|
||||||
for peer in await get_peers(
|
|
||||||
pool, account_id, [row["reader_id"] for row in reader_rows]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
readers = [
|
|
||||||
ReaderView(
|
|
||||||
peer_id=row["reader_id"],
|
|
||||||
read_at=row["read_at"],
|
|
||||||
peer=known.get(row["reader_id"]),
|
|
||||||
)
|
|
||||||
for row in reader_rows
|
|
||||||
]
|
|
||||||
outcome = fetch["outcome"] if fetch else inherited
|
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
own = (
|
|
||||||
message["sender_id"] is not None
|
|
||||||
and message["sender_id"] == (message["tg_user_id"])
|
|
||||||
)
|
|
||||||
applicable = own and kind != ChatKind.CHANNEL and chat_id != message["tg_user_id"]
|
|
||||||
watermark = receipts["watermark"]
|
|
||||||
covered = watermark is not None and message_id <= watermark
|
|
||||||
seen = covered or any((exact_at, readers, receipts["listened_at"]))
|
|
||||||
evidence = watermark is not None or outcome in {"ok", "not_read_yet"}
|
|
||||||
due = fetch is None or fetch["retry_at"] <= now
|
|
||||||
return ReadInfoView(
|
|
||||||
chat_id=chat_id,
|
|
||||||
message_id=message_id,
|
|
||||||
chat_kind=kind.value,
|
|
||||||
own=own,
|
|
||||||
voice=message["voice"],
|
|
||||||
observed_at=observed_at,
|
|
||||||
listened_at=receipts["listened_at"],
|
|
||||||
exact_at=exact_at,
|
|
||||||
readers=readers,
|
|
||||||
read=applicable and seen,
|
|
||||||
unread=applicable and not seen and evidence,
|
|
||||||
outcome=outcome,
|
|
||||||
fetched_at=fetch["fetched_at"] if fetch else None,
|
|
||||||
can_fetch=applicable
|
|
||||||
and not message["is_bot"]
|
|
||||||
and now - date < FETCH_WINDOW
|
|
||||||
and exact_at is None
|
|
||||||
and inherited is None
|
|
||||||
and due,
|
|
||||||
)
|
|
||||||
@@ -4,70 +4,13 @@ from typing import Any
|
|||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.read import chats as chats_read
|
from utils.read.models import AlertView, Page, WatchView
|
||||||
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, name, params, enabled, created_at, updated_at"
|
_WATCH_COLS = "id, account_id, kind, 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:
|
||||||
@@ -82,10 +25,6 @@ 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
|
||||||
@@ -103,21 +42,19 @@ 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( # noqa: PLR0913
|
async def create_watch(
|
||||||
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, name, params, enabled) " # noqa: S608
|
"INSERT INTO watches (account_id, kind, params, enabled) " # noqa: S608
|
||||||
f"VALUES ($1, $2, $3, $4::jsonb, $5) RETURNING {_WATCH_COLS}",
|
f"VALUES ($1, $2, $3::jsonb, $4) RETURNING {_WATCH_COLS}",
|
||||||
account_id,
|
account_id,
|
||||||
kind,
|
kind,
|
||||||
name,
|
|
||||||
json.dumps(params),
|
json.dumps(params),
|
||||||
enabled,
|
enabled,
|
||||||
)
|
)
|
||||||
@@ -125,22 +62,13 @@ async def create_watch( # noqa: PLR0913
|
|||||||
return _to_watch(row)
|
return _to_watch(row)
|
||||||
|
|
||||||
|
|
||||||
async def update_watch( # noqa: PLR0913
|
async def update_watch(
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool, watch_id: int, params: dict[str, Any], *, enabled: bool
|
||||||
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 kind = $2, name = $3, params = $4::jsonb, " # noqa: S608
|
"UPDATE watches SET params = $2::jsonb, enabled = $3, updated_at = now() " # 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,
|
||||||
)
|
)
|
||||||
@@ -151,210 +79,29 @@ async def update_watch( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
async def delete_watch(pool: asyncpg.Pool, watch_id: int) -> bool:
|
async def delete_watch(pool: asyncpg.Pool, watch_id: int) -> bool:
|
||||||
account_id = await pool.fetchval(
|
result = await pool.execute("DELETE FROM watches WHERE id = $1", watch_id)
|
||||||
"DELETE FROM watches WHERE id = $1 RETURNING account_id", watch_id
|
if not result.endswith("1"):
|
||||||
)
|
|
||||||
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,
|
pool: asyncpg.Pool, account_id: int, page: Page, *, seen: bool | None = None
|
||||||
account_id: int,
|
) -> list[AlertView]:
|
||||||
*,
|
|
||||||
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 = "a.account_id = $1"
|
where = "account_id = $1"
|
||||||
if seen is not None:
|
if seen is not None:
|
||||||
params.append(seen)
|
params.append(seen)
|
||||||
where += f" AND a.seen = ${len(params)}"
|
where += f" AND seen = ${len(params)}"
|
||||||
if before is not None:
|
params.append(page.capped_limit)
|
||||||
params.append(before)
|
params.append(page.offset)
|
||||||
where += f" AND a.id < ${len(params)}"
|
|
||||||
params.append(size + 1)
|
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
f"{_ALERT_ROWS} WHERE {where} ORDER BY a.id DESC LIMIT ${len(params)}", *params
|
f"SELECT {_ALERT_COLS} FROM alerts WHERE {where} " # noqa: S608
|
||||||
)
|
f"ORDER BY ts DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
|
||||||
items = await _alert_items(pool, account_id, rows[:size])
|
*params,
|
||||||
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(
|
||||||
@@ -366,7 +113,10 @@ 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_ALERT,
|
"INSERT INTO alerts (account_id, watch_id, ts, payload, seen, dedup_key) " # noqa: S608
|
||||||
|
"VALUES ($1, $2, $3, $4::jsonb, false, $5) "
|
||||||
|
"ON CONFLICT (watch_id, dedup_key) WHERE dedup_key IS NOT NULL "
|
||||||
|
f"DO NOTHING RETURNING {_ALERT_COLS}",
|
||||||
account_id,
|
account_id,
|
||||||
watch_id,
|
watch_id,
|
||||||
datetime.now(UTC),
|
datetime.now(UTC),
|
||||||
@@ -375,32 +125,10 @@ async def insert_alert(
|
|||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
await _notify(
|
await pool.execute(f"NOTIFY {ALERTS_CHANGED_CHANNEL}")
|
||||||
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:
|
||||||
account_id = await pool.fetchval(
|
result = await pool.execute("UPDATE alerts SET seen = true WHERE id = $1", alert_id)
|
||||||
"UPDATE alerts SET seen = true WHERE id = $1 RETURNING account_id", alert_id
|
return result.endswith("1")
|
||||||
)
|
|
||||||
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
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
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'"
|
|
||||||
)
|
|
||||||
@@ -1,48 +1,23 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel
|
||||||
|
|
||||||
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"]
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
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 SearchHit(BaseModel):
|
class SearchHit(BaseModel):
|
||||||
@@ -51,17 +26,5 @@ class SearchHit(BaseModel):
|
|||||||
date: datetime
|
date: datetime
|
||||||
sender_id: int | None
|
sender_id: int | None
|
||||||
text: str | None
|
text: str | None
|
||||||
has_media: bool
|
source: Literal["text", "stt"]
|
||||||
media_kind: str | None
|
|
||||||
edited_at: datetime | None
|
|
||||||
deleted_at: datetime | None
|
|
||||||
source: HitSource
|
|
||||||
extracted_text: str | None
|
extracted_text: str | None
|
||||||
version_text: str | None
|
|
||||||
version_at: datetime | None
|
|
||||||
|
|
||||||
|
|
||||||
class SearchPage(BaseModel):
|
|
||||||
hits: list[SearchHit]
|
|
||||||
next_cursor: str | None
|
|
||||||
terms: list[str]
|
|
||||||
@@ -1,187 +1,88 @@
|
|||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.search.models import (
|
from utils.search.models import MAX_LIMIT, SearchFilters, SearchHit
|
||||||
MAX_LIMIT,
|
|
||||||
ChatKind,
|
_TEXT_TSV = "to_tsvector('russian', coalesce(m.text, ''))"
|
||||||
SearchFilters,
|
_STT_TSV = "to_tsvector('russian', coalesce(md.extracted_text, ''))"
|
||||||
SearchHit,
|
_TSQUERY = "websearch_to_tsquery('russian', ${})"
|
||||||
SearchPage,
|
|
||||||
decode_cursor,
|
_HITS = "{cols} FROM messages m WHERE {where}" # text branch
|
||||||
encode_cursor,
|
_STT_HITS = (
|
||||||
|
"{cols} FROM messages m "
|
||||||
|
"JOIN media md ON md.account_id = m.account_id "
|
||||||
|
"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, "
|
||||||
|
"'text' AS source, NULL::text AS extracted_text"
|
||||||
|
)
|
||||||
|
_STT_COLS = (
|
||||||
|
"SELECT m.chat_id, m.message_id, m.date, m.sender_id, m.text, "
|
||||||
|
"'stt' AS source, md.extracted_text"
|
||||||
)
|
)
|
||||||
|
|
||||||
_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 = """
|
def _common_conditions(filters: SearchFilters, params: list[object]) -> list[str]:
|
||||||
SELECT coalesce(array_agg(chat_id), '{}'::bigint[]) FROM (
|
params.append(filters.account_id)
|
||||||
SELECT DISTINCT ON (chat_id) chat_id,
|
conds = [f"m.account_id = ${len(params)}"]
|
||||||
coalesce(raw->'chat'->>'type', raw->>'type') AS type
|
if filters.chat_id is not None:
|
||||||
FROM chat_history
|
params.append(filters.chat_id)
|
||||||
WHERE account_id = $1
|
conds.append(f"m.chat_id = ${len(params)}")
|
||||||
AND coalesce(raw->'chat'->>'type', raw->>'type') IS NOT NULL
|
if filters.sender_id is not None:
|
||||||
ORDER BY chat_id, ts DESC
|
params.append(filters.sender_id)
|
||||||
) typed WHERE type = 'ChatType.CHANNEL'
|
conds.append(f"m.sender_id = ${len(params)}")
|
||||||
"""
|
if filters.has_media is not None:
|
||||||
|
params.append(filters.has_media)
|
||||||
_KEY_ORDER = "s.date DESC, s.chat_id DESC, s.message_id DESC"
|
conds.append(f"m.has_media = ${len(params)}")
|
||||||
|
if filters.date_from is not None:
|
||||||
_COLS = (
|
params.append(filters.date_from)
|
||||||
"s.chat_id, s.message_id, s.date, s.sender_id, s.text, s.has_media, "
|
conds.append(f"m.date >= ${len(params)}")
|
||||||
"s.edited_at, s.deleted_at"
|
if filters.date_to is not None:
|
||||||
)
|
params.append(filters.date_to)
|
||||||
|
conds.append(f"m.date <= ${len(params)}")
|
||||||
_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")
|
|
||||||
|
|
||||||
|
|
||||||
class _Binder:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.params: list[object] = []
|
|
||||||
|
|
||||||
def __call__(self, value: object) -> str:
|
|
||||||
self.params.append(value)
|
|
||||||
return f"${len(self.params)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _kind(kind: ChatKind, channels: list[int], bind: _Binder) -> str:
|
|
||||||
if kind == "private":
|
|
||||||
return "s.chat_id > 0"
|
|
||||||
member = f"s.chat_id = ANY({bind(channels)}::bigint[])"
|
|
||||||
return member if kind == "channel" else f"s.chat_id < 0 AND NOT {member}"
|
|
||||||
|
|
||||||
|
|
||||||
def _conditions(
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
def _branch(sql: str, limit: str, order: str = _KEY_ORDER, **parts: str) -> str:
|
async def search_messages(
|
||||||
return f"({sql.format(**parts)} ORDER BY {order} LIMIT {limit})"
|
pool: asyncpg.Pool, filters: SearchFilters
|
||||||
|
) -> 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)
|
||||||
|
|
||||||
async def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage:
|
text_conds = [*common]
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
if q_idx is not None:
|
||||||
await conn.execute("SET LOCAL jit = off")
|
text_conds.append(f"{_TEXT_TSV} @@ {_TSQUERY.format(q_idx)}")
|
||||||
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)))
|
||||||
|
|
||||||
async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
|
params.append(min(filters.limit, MAX_LIMIT))
|
||||||
query = (filters.query or "").strip()
|
limit_idx = len(params)
|
||||||
terms: list[str] = []
|
params.append(filters.offset)
|
||||||
tsquery = None
|
offset_idx = len(params)
|
||||||
if query:
|
page = f"ORDER BY date DESC LIMIT ${limit_idx} OFFSET ${offset_idx}"
|
||||||
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: list[int] = []
|
if len(branches) == 1:
|
||||||
if filters.kind in {"group", "channel"}:
|
sql = f"SELECT * FROM ({branches[0]}) hits {page}" # noqa: S608
|
||||||
channels = await conn.fetchval(_CHANNELS, filters.account_id)
|
|
||||||
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:
|
else:
|
||||||
match = f"{bind(tsquery)}::tsquery"
|
union = " UNION ALL ".join(f"({b})" for b in branches)
|
||||||
branches = (
|
dedup = (
|
||||||
_branch(_TEXT, limit, where=f"{where} AND s.tsv @@ {match}"),
|
"SELECT DISTINCT ON (chat_id, message_id) * " # noqa: S608
|
||||||
_branch(_STT, limit, where=where, account=account, tsquery=match),
|
f"FROM ({union}) hits ORDER BY chat_id, message_id, source DESC"
|
||||||
_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}"
|
|
||||||
)
|
)
|
||||||
|
sql = f"SELECT * FROM ({dedup}) d {page}" # noqa: S608
|
||||||
|
|
||||||
rows = await conn.fetch(_WITH_MEDIA.format(hits=sql, account=account), *bind.params)
|
rows = await pool.fetch(sql, *params)
|
||||||
hits = [
|
return [SearchHit(**dict(row)) for row in rows]
|
||||||
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)
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
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,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -16,7 +15,6 @@ 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
|
||||||
@@ -31,7 +29,6 @@ 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
|
||||||
@@ -54,11 +51,9 @@ 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)
|
||||||
seen = seen_message(meta.chat_id, message)
|
await ctx.watches.on_text(
|
||||||
if message.edit_date is None:
|
meta.chat_id, message.id, message.text or message.caption
|
||||||
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
|
||||||
@@ -72,24 +67,14 @@ 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,
|
pool, ctx.account_id, "enrich_chat", {"chat_id": meta.chat_id}
|
||||||
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(
|
await enqueue(pool, ctx.account_id, "enrich_chat", {"chat_id": update.chat.id})
|
||||||
pool,
|
|
||||||
ctx.account_id,
|
|
||||||
"enrich_chat",
|
|
||||||
{"chat_id": update.chat.id},
|
|
||||||
cooldown=ENRICH_COOLDOWN,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
MessageHandler(on_message),
|
MessageHandler(on_message),
|
||||||
@@ -113,12 +98,6 @@ 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()
|
||||||
@@ -136,12 +115,6 @@ 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:
|
||||||
@@ -194,33 +167,17 @@ class BotRegistry:
|
|||||||
await client.stop()
|
await client.stop()
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
account_id = await accounts.sync_account(
|
||||||
account_id = await accounts.sync_account(
|
self._pool, me, str(bot_id), is_bot=True
|
||||||
self._pool, me, str(bot_id), is_bot=True
|
)
|
||||||
)
|
client.capture = await build_bot_capture_context(
|
||||||
client.capture = await build_bot_capture_context(
|
client, self._pool, self._storage, account_id
|
||||||
client, self._pool, self._storage, account_id
|
)
|
||||||
)
|
await bots.link_account(self._pool, bot_id, account_id)
|
||||||
await bots.link_account(self._pool, bot_id, account_id)
|
await bots.set_status(
|
||||||
await bots.set_status(
|
self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name
|
||||||
self._pool,
|
)
|
||||||
bot_id,
|
await accounts.notify_accounts_changed(self._pool)
|
||||||
bots.STATUS_OK,
|
|
||||||
username=me.username,
|
|
||||||
title=me.full_name,
|
|
||||||
)
|
|
||||||
await accounts.notify_accounts_changed(self._pool)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(
|
|
||||||
f"[red]Bot {bot_id} failed to start,[/] retrying on the next sync"
|
|
||||||
)
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await bots.set_status(
|
|
||||||
self._pool, bot_id, bots.STATUS_ERROR, detail=str(exc)
|
|
||||||
)
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await client.stop()
|
|
||||||
return
|
|
||||||
|
|
||||||
consumer = JobConsumer(client, self._pool, account_id)
|
consumer = JobConsumer(client, self._pool, account_id)
|
||||||
self._running[bot_id] = RunningBot(
|
self._running[bot_id] = RunningBot(
|
||||||
|
|||||||
@@ -6,57 +6,37 @@ 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.env import env
|
||||||
from utils.listen import Listener
|
|
||||||
from utils.logging import logger, setup_logging
|
from utils.logging import logger, setup_logging
|
||||||
from utils.read.bots import BOTS_CHANGED_CHANNEL
|
from utils.read.bots import BOTS_CHANGED_CHANNEL
|
||||||
from utils.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:
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(RETRY_INTERVAL)
|
await asyncio.sleep(RETRY_INTERVAL)
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
await registry.sync()
|
await registry.sync()
|
||||||
except Exception:
|
|
||||||
logger.exception("[red]Bot sync failed[/]")
|
|
||||||
|
|
||||||
|
|
||||||
async def _keep_uptime(registry: BotRegistry, pool: asyncpg.Pool) -> None:
|
async def _listen_changes(
|
||||||
while True:
|
registry: BotRegistry, tasks: set[asyncio.Task]
|
||||||
for account_id in registry.connected_accounts:
|
) -> asyncpg.Connection:
|
||||||
with contextlib.suppress(Exception):
|
def on_change(
|
||||||
await uptime.heartbeat(pool, account_id)
|
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
||||||
await asyncio.sleep(UPTIME_INTERVAL)
|
) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener:
|
|
||||||
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)
|
||||||
|
|
||||||
def on_watch(_payload: str) -> None:
|
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||||
task = asyncio.create_task(registry.refresh_watches())
|
await conn.add_listener(BOTS_CHANGED_CHANNEL, on_change)
|
||||||
tasks.add(task)
|
return conn
|
||||||
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:
|
||||||
@@ -64,24 +44,24 @@ async def runner() -> None:
|
|||||||
storage = await container.get(ContentAddressedStorage)
|
storage = await container.get(ContentAddressedStorage)
|
||||||
registry = BotRegistry(pool, storage)
|
registry = BotRegistry(pool, storage)
|
||||||
tasks: set[asyncio.Task] = set()
|
tasks: set[asyncio.Task] = set()
|
||||||
listener = _change_listener(registry, tasks)
|
listen_conn: asyncpg.Connection | None = 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:
|
||||||
logger.warning("[yellow]No bots yet. Add a bot token in the web UI.[/]")
|
logger.warning("[yellow]No bots yet. Add a bot token in the web UI.[/]")
|
||||||
await listener.start()
|
listen_conn = await _listen_changes(registry, tasks)
|
||||||
logger.info("[green]Bot watcher running.[/]")
|
logger.info("[green]Bot watcher running.[/]")
|
||||||
await stop.wait()
|
await stop.wait()
|
||||||
finally:
|
finally:
|
||||||
for task in (retry, beat):
|
retry.cancel()
|
||||||
task.cancel()
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
await retry
|
||||||
await task
|
if listen_conn is not None:
|
||||||
await listener.stop()
|
with contextlib.suppress(Exception):
|
||||||
|
await listen_conn.close()
|
||||||
await registry.close()
|
await registry.close()
|
||||||
await container.close()
|
await container.close()
|
||||||
|
|
||||||
|
|||||||
Generated
+7
-8
@@ -149,7 +149,6 @@ 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" },
|
||||||
@@ -167,7 +166,6 @@ 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" },
|
||||||
@@ -413,7 +411,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.143.0"
|
version = "0.143.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -423,9 +421,9 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/38/d5/b0aeb0b303650ac87b0411de4aaf3f901b06042dcc3fb00f30b97b7ff585/fastapi-0.143.3.tar.gz", hash = "sha256:b9ab567ee57afb4454004ce04c889f8f0160ac47946b99795887cd6e88484848", size = 469834, upload-time = "2026-10-16T14:14:13.31Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/1a/03d246befcf307219bb2161c97d8c5d314f84125bf7dd245730a28589828/fastapi-0.143.3-py3-none-any.whl", hash = "sha256:75e8bd68e2f52f77d29b2879749c257d64de2d85282d25270caf2bfccce5efe5", size = 144849, upload-time = "2026-10-16T14:14:11.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1082,16 +1080,17 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-settings"
|
name = "pydantic-settings"
|
||||||
version = "2.15.0"
|
version = "2.16.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/2e/3b/a5d2294799b53b448319978cfb5bd139d5a9d45e862af91661614f14c922/pydantic_settings-2.16.0.tar.gz", hash = "sha256:5b6c578049ede4db0e2ef3b4eaa4ad4069cfa9211f83fb38df899dfade50a614", size = 301031, upload-time = "2026-10-14T12:44:09.998Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl", hash = "sha256:7e73acf7f61936a15e5a3b6eedaea29f133357faf7272f2607ba479b049dd7f2", size = 80364, upload-time = "2026-10-14T12:44:08.233Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Vendored
+5
-6
@@ -1,14 +1,13 @@
|
|||||||
import type { PanelState } from "#lib/stores/ui.svelte.js";
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// 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 {};
|
||||||
+1
-10
@@ -1,18 +1,9 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||||
<meta content="scale" name="text-scale" />
|
<meta content="scale" name="text-scale" />
|
||||||
<meta content="#ffffff" media="(prefers-color-scheme: light)" name="theme-color" />
|
|
||||||
<meta content="#212121" media="(prefers-color-scheme: dark)" name="theme-color" />
|
|
||||||
<meta content="yes" name="mobile-web-app-capable" />
|
|
||||||
<meta content="black-translucent" name="apple-mobile-web-app-status-bar-style" />
|
|
||||||
<meta content="Beavergram" name="apple-mobile-web-app-title" />
|
|
||||||
<link href="/icon.svg" rel="icon" type="image/svg+xml" />
|
|
||||||
<link href="/apple-touch-icon.png" rel="apple-touch-icon" />
|
|
||||||
<link href="/manifest.webmanifest" rel="manifest" />
|
|
||||||
<title>Beavergram</title>
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const stored = localStorage.getItem("bg.theme");
|
const stored = localStorage.getItem("bg.theme");
|
||||||
|
|||||||
@@ -4,27 +4,13 @@ import { PUBLIC_API_BASE_URL } from "$app/env/public";
|
|||||||
|
|
||||||
const BASE = PUBLIC_API_BASE_URL;
|
const BASE = PUBLIC_API_BASE_URL;
|
||||||
const RETRY_DELAY = 2500;
|
const RETRY_DELAY = 2500;
|
||||||
const MISSING_TTL = 120_000;
|
|
||||||
|
|
||||||
export type AvatarKind = "peer" | "chat";
|
export type AvatarKind = "peer" | "chat";
|
||||||
|
|
||||||
const MAX_CACHED = 240;
|
const MAX_CACHED = 240;
|
||||||
|
|
||||||
const ready = new Map<string, string>();
|
const ready = new Map<string, string>();
|
||||||
const missing = new Map<string, number>();
|
const missing = new Set<string>();
|
||||||
|
|
||||||
function isMissing(key: string): boolean {
|
|
||||||
const since = missing.get(key);
|
|
||||||
if (since === undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (Date.now() - since < MISSING_TTL) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
missing.delete(key);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflight = new Map<string, Promise<string | null>>();
|
const inflight = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
function cacheKey(account: number, kind: AvatarKind, id: number): string {
|
function cacheKey(account: number, kind: AvatarKind, id: number): string {
|
||||||
@@ -74,15 +60,15 @@ async function fetchAvatar(
|
|||||||
await delay(RETRY_DELAY);
|
await delay(RETRY_DELAY);
|
||||||
return fetchAvatar(account, kind, id, key, false);
|
return fetchAvatar(account, kind, id, key, false);
|
||||||
}
|
}
|
||||||
missing.set(key, Date.now());
|
missing.add(key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAvatar(
|
export function loadAvatar(
|
||||||
kind: AvatarKind,
|
kind: AvatarKind,
|
||||||
id: number,
|
id: number
|
||||||
account: number | null = accounts.selectedId
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
|
const account = accounts.selectedId;
|
||||||
if (account === null) {
|
if (account === null) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
@@ -91,7 +77,7 @@ export function loadAvatar(
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
return Promise.resolve(cached);
|
return Promise.resolve(cached);
|
||||||
}
|
}
|
||||||
if (isMissing(key)) {
|
if (missing.has(key)) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
const existing = inflight.get(key);
|
const existing = inflight.get(key);
|
||||||
@@ -124,7 +110,7 @@ async function fetchVariant(
|
|||||||
await delay(RETRY_DELAY);
|
await delay(RETRY_DELAY);
|
||||||
return fetchVariant(account, kind, id, uniqueId, key, false);
|
return fetchVariant(account, kind, id, uniqueId, key, false);
|
||||||
}
|
}
|
||||||
missing.set(key, Date.now());
|
missing.add(key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +128,7 @@ export function loadAvatarVariant(
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
return Promise.resolve(cached);
|
return Promise.resolve(cached);
|
||||||
}
|
}
|
||||||
if (isMissing(key)) {
|
if (missing.has(key)) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
const existing = inflight.get(key);
|
const existing = inflight.get(key);
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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,6 +1,7 @@
|
|||||||
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,
|
||||||
@@ -9,11 +10,6 @@ import type {
|
|||||||
ChatLinkView,
|
ChatLinkView,
|
||||||
DayCount,
|
DayCount,
|
||||||
DiscoverItem,
|
DiscoverItem,
|
||||||
FeedChatKind,
|
|
||||||
FeedKind,
|
|
||||||
FeedMessagesPage,
|
|
||||||
FeedPage,
|
|
||||||
FeedUnseen,
|
|
||||||
Folder,
|
Folder,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
JobView,
|
JobView,
|
||||||
@@ -24,8 +20,7 @@ import type {
|
|||||||
MessageAt,
|
MessageAt,
|
||||||
MessageVersion,
|
MessageVersion,
|
||||||
MessageView,
|
MessageView,
|
||||||
PeerGroups,
|
PeerHistoryView,
|
||||||
PeerOverview,
|
|
||||||
PeerView,
|
PeerView,
|
||||||
PinnedView,
|
PinnedView,
|
||||||
PolicyChatKind,
|
PolicyChatKind,
|
||||||
@@ -33,16 +28,12 @@ import type {
|
|||||||
PolicyRecord,
|
PolicyRecord,
|
||||||
PolicyRule,
|
PolicyRule,
|
||||||
PolicyScope,
|
PolicyScope,
|
||||||
|
PresenceHourly,
|
||||||
PresenceSample,
|
PresenceSample,
|
||||||
PresenceSession,
|
|
||||||
PresenceStats,
|
|
||||||
ProfileChange,
|
|
||||||
ReactionView,
|
ReactionView,
|
||||||
ReadInfo,
|
|
||||||
ResponseStats,
|
ResponseStats,
|
||||||
ScopeTarget,
|
ScopeTarget,
|
||||||
SearchPage,
|
SearchHit,
|
||||||
SearchQuery,
|
|
||||||
StorageChat,
|
StorageChat,
|
||||||
StorageFile,
|
StorageFile,
|
||||||
StorageGroupName,
|
StorageGroupName,
|
||||||
@@ -52,6 +43,7 @@ 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";
|
||||||
@@ -213,38 +205,30 @@ export function getCurrentPresence(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPresenceStats(
|
export function getPresenceHistory(
|
||||||
peerId: number,
|
peerId: number,
|
||||||
days: number,
|
|
||||||
tz: string,
|
|
||||||
page: Page = {}
|
page: Page = {}
|
||||||
): Promise<PresenceStats> {
|
): Promise<PresenceSample[]> {
|
||||||
return request<PresenceStats>("/presence/stats", {
|
return request<PresenceSample[]>("/presence", {
|
||||||
account: true,
|
account: true,
|
||||||
query: { days, peer_id: peerId, tz, ...page },
|
query: { peer_id: peerId, ...page },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPresenceSessions(
|
export function getPresenceHourly(peerId: number): Promise<PresenceHourly[]> {
|
||||||
peerId: number,
|
return request<PresenceHourly[]>("/presence/hourly", {
|
||||||
days: number,
|
|
||||||
tz: string,
|
|
||||||
page: Page = {}
|
|
||||||
): Promise<PresenceSession[]> {
|
|
||||||
return request<PresenceSession[]>("/presence/sessions", {
|
|
||||||
account: true,
|
account: true,
|
||||||
query: { days, peer_id: peerId, tz, ...page },
|
query: { peer_id: peerId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMessageVolume(
|
export function getMessageVolume(
|
||||||
chatId: number,
|
chatId: number,
|
||||||
days: number,
|
days = 90
|
||||||
tz: string
|
|
||||||
): Promise<VolumeBucket[]> {
|
): Promise<VolumeBucket[]> {
|
||||||
return request<VolumeBucket[]>("/analytics/volume", {
|
return request<VolumeBucket[]>("/analytics/volume", {
|
||||||
account: true,
|
account: true,
|
||||||
query: { chat_id: chatId, days, tz },
|
query: { chat_id: chatId, days },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,53 +264,12 @@ 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 getPeerOverview(peerId: number): Promise<PeerOverview> {
|
export function getPeerHistory(peerId: number): Promise<PeerHistoryView[]> {
|
||||||
return request<PeerOverview>(`/peers/${peerId}/overview`, { account: true });
|
return request<PeerHistoryView[]>(`/peers/${peerId}/history`, {
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -361,25 +304,6 @@ export function getChatLinks(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReadInfo(
|
|
||||||
chatId: number,
|
|
||||||
messageId: number
|
|
||||||
): Promise<ReadInfo> {
|
|
||||||
return request<ReadInfo>(`/messages/${chatId}/${messageId}/read`, {
|
|
||||||
account: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function refreshReadInfo(
|
|
||||||
chatId: number,
|
|
||||||
messageId: number
|
|
||||||
): Promise<{ job_id: number | null }> {
|
|
||||||
return request<{ job_id: number | null }>(
|
|
||||||
`/messages/${chatId}/${messageId}/read/refresh`,
|
|
||||||
{ body: { account_id: accounts.selectedId }, method: "POST" }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMessageReactions(
|
export function getMessageReactions(
|
||||||
chatId: number,
|
chatId: number,
|
||||||
messageId: number
|
messageId: number
|
||||||
@@ -556,8 +480,14 @@ export function getMessageMedia(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchMessages(query: SearchQuery): Promise<SearchPage> {
|
export function searchMessages(
|
||||||
return request<SearchPage>("/search", { account: true, query: { ...query } });
|
query: string,
|
||||||
|
options: Page & { chat_id?: number } = {}
|
||||||
|
): Promise<SearchHit[]> {
|
||||||
|
return request<SearchHit[]>("/search", {
|
||||||
|
account: true,
|
||||||
|
query: { query, ...options },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchMedia(
|
export function fetchMedia(
|
||||||
@@ -588,6 +518,46 @@ 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[]> {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
+34
-216
@@ -1,5 +1,3 @@
|
|||||||
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"
|
||||||
@@ -7,9 +5,7 @@ export type PolicyScopeType =
|
|||||||
| "default_channel"
|
| "default_channel"
|
||||||
| "folder"
|
| "folder"
|
||||||
| "chat";
|
| "chat";
|
||||||
export type SearchSource = "text" | "stt" | "version";
|
export type SearchSource = "text" | "stt";
|
||||||
|
|
||||||
export type SearchChatKind = "private" | "group" | "channel";
|
|
||||||
export type JobStatus =
|
export type JobStatus =
|
||||||
| "pending"
|
| "pending"
|
||||||
| "running"
|
| "running"
|
||||||
@@ -45,8 +41,6 @@ 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;
|
||||||
@@ -198,7 +192,6 @@ 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;
|
||||||
@@ -211,7 +204,6 @@ export interface MessageView {
|
|||||||
quote: string | null;
|
quote: string | null;
|
||||||
reactions: ReactionCount[];
|
reactions: ReactionCount[];
|
||||||
read: boolean;
|
read: boolean;
|
||||||
read_at: string | null;
|
|
||||||
reply: ReplyView | null;
|
reply: ReplyView | null;
|
||||||
sender_id: number | null;
|
sender_id: number | null;
|
||||||
service: ServiceView | null;
|
service: ServiceView | null;
|
||||||
@@ -227,53 +219,6 @@ 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;
|
||||||
@@ -286,37 +231,11 @@ 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;
|
||||||
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;
|
|
||||||
terms: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchQuery {
|
|
||||||
chat_id?: number;
|
|
||||||
cursor?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
deleted?: boolean;
|
|
||||||
edited?: boolean;
|
|
||||||
has_media?: boolean;
|
|
||||||
kind?: SearchChatKind;
|
|
||||||
limit?: number;
|
|
||||||
query?: string;
|
|
||||||
sender_id?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaView {
|
export interface MediaView {
|
||||||
@@ -416,57 +335,12 @@ export interface PresenceSample {
|
|||||||
ts: string;
|
ts: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PresenceSession {
|
export interface PresenceHourly {
|
||||||
ended: string;
|
bucket: string;
|
||||||
ongoing: boolean;
|
|
||||||
online_seconds: number;
|
|
||||||
seconds: number;
|
|
||||||
started: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresenceHour {
|
|
||||||
covered_hours: number;
|
|
||||||
hour: number;
|
|
||||||
hours: number;
|
|
||||||
online_minutes: number | null;
|
|
||||||
weekday: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresenceDay {
|
|
||||||
covered_seconds: number;
|
|
||||||
day: string;
|
|
||||||
online_seconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CoverageGap {
|
|
||||||
ended: string;
|
|
||||||
started: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresenceSummary {
|
|
||||||
covered_seconds: number;
|
|
||||||
daily_seconds: number | null;
|
|
||||||
first_online: number | null;
|
|
||||||
last_offline: number | null;
|
|
||||||
last_seen: string | null;
|
last_seen: string | null;
|
||||||
median_session_seconds: number | null;
|
online_samples: number;
|
||||||
online_seconds: number;
|
|
||||||
session_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresenceStats {
|
|
||||||
current: PresenceSample | null;
|
|
||||||
days: PresenceDay[];
|
|
||||||
gaps: CoverageGap[];
|
|
||||||
hidden: boolean;
|
|
||||||
online: boolean;
|
|
||||||
peer_id: number;
|
peer_id: number;
|
||||||
sessions: PresenceSession[];
|
samples: number;
|
||||||
since: string;
|
|
||||||
summary: PresenceSummary;
|
|
||||||
tz: string;
|
|
||||||
until: string;
|
|
||||||
week: PresenceHour[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VolumeBucket {
|
export interface VolumeBucket {
|
||||||
@@ -495,88 +369,14 @@ export interface PeerView {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReaderView {
|
export interface PeerHistoryView {
|
||||||
peer: PeerView | null;
|
first_name: string | null;
|
||||||
peer_id: number;
|
is_deleted_account: boolean;
|
||||||
read_at: string;
|
last_name: string | null;
|
||||||
}
|
observed_at: string;
|
||||||
|
phone: string | null;
|
||||||
export type ReadOutcome =
|
photo_unique_id: string | null;
|
||||||
| "ok"
|
username: string | null;
|
||||||
| "not_read_yet"
|
|
||||||
| "user_privacy"
|
|
||||||
| "your_privacy"
|
|
||||||
| "chat_too_big"
|
|
||||||
| "too_old"
|
|
||||||
| "unavailable"
|
|
||||||
| "flood"
|
|
||||||
| "error";
|
|
||||||
|
|
||||||
export interface ReadInfo {
|
|
||||||
can_fetch: boolean;
|
|
||||||
chat_id: number;
|
|
||||||
chat_kind: "dm" | "group" | "channel";
|
|
||||||
exact_at: string | null;
|
|
||||||
fetched_at: string | null;
|
|
||||||
listened_at: string | null;
|
|
||||||
message_id: number;
|
|
||||||
observed_at: string | null;
|
|
||||||
outcome: ReadOutcome | null;
|
|
||||||
own: boolean;
|
|
||||||
read: boolean;
|
|
||||||
readers: ReaderView[];
|
|
||||||
unread: boolean;
|
|
||||||
voice: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProfileField = "name" | "username" | "phone" | "deleted" | "title";
|
|
||||||
|
|
||||||
export interface ProfileChange {
|
|
||||||
actor_id: number | null;
|
|
||||||
changed_at: string;
|
|
||||||
field: ProfileField;
|
|
||||||
message_id: number | null;
|
|
||||||
new: string | null;
|
|
||||||
old: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MembershipView {
|
|
||||||
actor_id: number | null;
|
|
||||||
actor_name: string | null;
|
|
||||||
chat_id: number;
|
|
||||||
chat_title: string | null;
|
|
||||||
event: "join" | "leave";
|
|
||||||
has_avatar: boolean;
|
|
||||||
message_id: number;
|
|
||||||
ts: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DirectChatView {
|
|
||||||
chat_id: number;
|
|
||||||
last_date: string | null;
|
|
||||||
message_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PeerOverview {
|
|
||||||
avatars: AvatarHistoryView[];
|
|
||||||
changes: ProfileChange[];
|
|
||||||
dm: DirectChatView | null;
|
|
||||||
memberships: MembershipView[];
|
|
||||||
peer: PeerView | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SharedGroupView {
|
|
||||||
chat_id: number;
|
|
||||||
has_avatar: boolean;
|
|
||||||
last_date: string;
|
|
||||||
last_message_id: number;
|
|
||||||
message_count: number;
|
|
||||||
title: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PeerGroups {
|
|
||||||
deleted_count: number;
|
|
||||||
groups: SharedGroupView[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoryView {
|
export interface StoryView {
|
||||||
@@ -663,6 +463,26 @@ 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;
|
||||||
@@ -698,7 +518,6 @@ export interface LivePresenceEvent {
|
|||||||
|
|
||||||
export interface LiveReceiptEvent {
|
export interface LiveReceiptEvent {
|
||||||
chat_id: number;
|
chat_id: number;
|
||||||
read_at: string;
|
|
||||||
read_up_to: number;
|
read_up_to: number;
|
||||||
type: "receipt";
|
type: "receipt";
|
||||||
}
|
}
|
||||||
@@ -707,8 +526,7 @@ export type LiveEvent =
|
|||||||
| LiveMessageEvent
|
| LiveMessageEvent
|
||||||
| LiveDeleteEvent
|
| LiveDeleteEvent
|
||||||
| LivePresenceEvent
|
| LivePresenceEvent
|
||||||
| LiveReceiptEvent
|
| LiveReceiptEvent;
|
||||||
| LiveAlertEvent;
|
|
||||||
|
|
||||||
export interface FileShare {
|
export interface FileShare {
|
||||||
account_id: number;
|
account_id: number;
|
||||||
|
|||||||
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user