Compare commits

..
126 Commits
Author SHA1 Message Date
renovate Bot b9e8a96774 chore(deps): update ghcr.io/astral-sh/uv:python3.13-trixie-slim docker digest to 331fbb1 (main) (#22)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-25 04:11:41 +00:00
hh d660079c02 feat(backend,frontend): search everywhere in buttons, links and message data, a jump from search or an alert lights up the found words and buttons 2026-10-23 22:59:29 +00:00
hh acfa726e61 fix(backend,frontend): expired story keeps its kind and archive heals stories that lost it, phone inputs no longer zoom the page 2026-10-23 16:41:42 +02:00
hh d9378056f4 fix(frontend): chat header keeps the name readable on a phone, all its actions are in its menu, menus open below the pointer and stay on screen 2026-10-19 06:19:46 +00:00
hh c307426f4e fix(frontend): context menus stay inside a narrow screen 2026-10-19 06:12:48 +00:00
hh 977dee3cb2 chore(frontend): toasts lose the success kind nobody calls anymore 2026-10-19 06:07:54 +00:00
hh bca0b34636 feat(frontend): a finished action answers on the button that was pressed instead of a toast 2026-10-19 06:07:54 +00:00
hh e228d999dd fix(frontend): account phone stored with a plus is not shown with two 2026-10-19 05:47:09 +00:00
hh aff8a9e443 fix(frontend): empty media and links tabs keep a gap under the tabs instead of sticking to them 2026-10-19 05:38:24 +00:00
hh 25364f4523 feat(frontend): forwarded header, pinned bar, folder tabs, inline buttons, edit history and deleted accounts speak russian and english 2026-10-19 05:38:24 +00:00
hh 0cd34f3037 feat(frontend): profile media, files and links tabs speak russian and english 2026-10-19 05:38:24 +00:00
hh cff493f5dd feat(frontend): analytics panel and online statuses speak russian and english with real plurals 2026-10-19 05:37:44 +00:00
hh 543e6eaa50 feat(frontend): service messages speak russian and english 2026-10-19 05:32:34 +00:00
hh 7baa32f400 chore(frontend): notes dictionary drops a string the shared ui one replaced 2026-10-19 05:26:53 +00:00
hh e977ab77a8 fix(frontend): a filter written to the url survives another url write, the chat picked with alt and arrows stays in view 2026-10-19 05:26:20 +00:00
hh 8a1723e99f fix(frontend): panel history survives a link that rewrites its own url, dictionaries reuse the thread and panel strings of the feed 2026-10-19 05:26:20 +00:00
hh c06597aba2 chore(backend): chat stats migration follows the search migration 2026-10-19 05:26:11 +00:00
hh 7f3458b3ef docs: mcp tool list matches the server 2026-10-19 05:26:11 +00:00
hh 4a32e32114 feat(frontend): online dots on private chats, deleted last message struck through, back from a chat goes through history 2026-10-19 05:26:11 +00:00
hh 9c556ff8b9 feat(frontend): notes list with tag chips and the tag in the url, from the menu and settings, capture status dot on the menu and a line in settings 2026-10-19 05:26:11 +00:00
hh 171d8d922d feat(frontend): exact time on the message time, deletion and edit times in the meta, note marker, copy link answered in the menu, media menu keeps the message items, date pill opens the calendar, capture gaps between messages 2026-10-19 05:25:54 +00:00
hh 848d2aca1a feat(frontend): right panel lives in browser history and back closes it first, message panels close on chat switch, hotkeys for search, chats, in-chat search and closing 2026-10-19 05:25:54 +00:00
hh 7f7fc1296e feat(watcher): watched bots report capture uptime like the userbot 2026-10-19 05:25:44 +00:00
hh f62c345e2b feat(backend): who of the given people is online now in one request 2026-10-19 05:25:44 +00:00
hh 9ae8b34005 feat(backend): capture status and the stretches the archive was not recording 2026-10-19 05:25:44 +00:00
hh a0e134756c feat(backend): notes list across chats with the message and chat they belong to, filter by tag, tag counts 2026-10-19 05:25:44 +00:00
hh 97f9082f5e feat(backend): chat list marks a deleted last message, history pages mark messages with a note 2026-10-19 05:25:44 +00:00
hh 90b37ec708 fix(frontend): jobs name their chats, open links tab counts only links that are really open 2026-10-19 03:53:08 +00:00
hh 4082adba25 fix(frontend): buttons keep a gap between icon and text, viewer counter stays clear of the actions on mobile 2026-10-19 03:53:08 +00:00
hh b3805123b5 fix(frontend): english storage and policy labels fit on mobile, phone placeholder no longer peeks out from under its label 2026-10-19 03:53:08 +00:00
hh 896b89f3b0 refactor(frontend): stories archives drop tile styles and imports that live in the tile 2026-10-19 03:53:08 +00:00
hh 4a5c92f0e9 feat(frontend): storage, trash, policy, stories, shares, jobs, bots, media, login and notes speak russian and english 2026-10-19 03:53:08 +00:00
hh e5a5edcaae perf(backend): search stays in tens of milliseconds at archive size for rare words, channels, deleted and transcripts 2026-10-19 03:41:49 +00:00
hh 9a6bd8a714 feat(frontend): search results show deleted, edited and earlier version marks with highlighted words, filter chips live in the url, results load on scroll 2026-10-19 03:41:44 +00:00
hh d45c96c259 feat(backend): search finds word prefixes, earlier versions and deleted messages, with filters and cursor paging 2026-10-19 03:41:39 +00:00
hh 42b4e1d7e6 fix(frontend): chips of the changes panel stay on top while the list scrolls 2026-10-19 03:27:27 +00:00
hh 309614c979 fix(frontend): channel posts show no sender id, a post sent as a chat shows its title and avatar 2026-10-19 03:25:48 +00:00
hh 98dda99c5c feat(frontend): profiles chip in the feed shows name, username, photo and title changes 2026-10-19 03:25:48 +00:00
hh 8e0f828dd4 feat(backend): feed lists name, username and photo changes of people and chat renames, merged per day 2026-10-19 03:25:43 +00:00
hh 9aa8690e33 chore(backend): feed migrations follow the profile history migration 2026-10-19 03:06:20 +00:00
hh dadba4d545 fix(frontend): message highlight and active folder tab are visible in the light theme 2026-10-19 03:00:23 +00:00
hh fffbb08d36 fix(frontend): a link to a message opens the chat right at it without loading the latest page first 2026-10-19 03:00:23 +00:00
hh c247dd0217 docs: readme mentions the feed and the get_feed tool 2026-10-19 03:00:23 +00:00
hh b9a0604426 fix(frontend): jumps to replies, pins and album photos load the message when it is not on screen 2026-10-19 03:00:12 +00:00
hh 9f16e2aa56 feat(frontend): feed of deleted and edited messages above the chat list and per chat in the changes panel 2026-10-19 03:00:12 +00:00
hh 010fa29ce0 feat(backend): feed items carry the message id even when the message row is gone 2026-10-19 02:59:07 +00:00
hh 395d58464d feat(backend): get_feed mcp tool reads the feed of deleted and edited messages 2026-10-19 02:59:07 +00:00
hh 83c57ac96b feat(backend): feed of deleted and edited messages with bulk deletions grouped and a shared seen marker 2026-10-19 02:58:51 +00:00
hh 45c19cac20 feat(backend): edit versions are kept in groups and channels by default 2026-10-19 02:58:51 +00:00
hh 0dd14c5d5e fix(backend): person overview reads live in their own module, no import cycle with read receipts 2026-10-19 02:57:49 +00:00
hh 1abb396c36 feat(frontend): sender avatar and name in groups open the person panel without leaving the chat 2026-10-19 02:57:49 +00:00
hh 143408cd7f feat(frontend): profile panel about one person with real changes, shared groups, joins and leaves and deleted messages 2026-10-19 02:57:49 +00:00
hh c8d485fe0e fix(backend): migration vacuums profile history so change reads stay index only 2026-10-19 02:57:49 +00:00
hh b86e2627d8 feat(backend): mcp get_person returns everything the archive knows about one person 2026-10-19 02:57:49 +00:00
hh 8f9ceb6af8 feat(backend): person overview with real profile changes, shared groups, joins and leaves, deleted count and group title changes 2026-10-19 02:57:49 +00:00
hh 0c9c848fb1 fix(backend): profile history gets a row only when a field really changed, partial snapshots keep the known phone and username 2026-10-19 02:57:49 +00:00
hh aa41bbbc74 fix(backend): group title changes found by enrichment are recorded, enrichment and syncs skip unchanged profiles 2026-10-19 02:57:49 +00:00
hh efc431c969 fix(backend): watched bot enriches a group at most once in six hours 2026-10-19 02:57:49 +00:00
hh 7d7cf3cbbb feat(backend): history keys cover the profile fields, group snapshots keyed by time, indexes for memberships and job lookups 2026-10-19 02:57:49 +00:00
hh 97ccfbbffd fix(backend): watch times stay in utc for naive telegram dates, webhook outbox survives a database hiccup at start 2026-10-19 02:57:27 +00:00
hh 2c56934133 feat(frontend): live unseen alerts badge on the menu button and in settings 2026-10-19 02:57:27 +00:00
hh ea3a7c0a61 feat(frontend): watches, webhooks and alerts panels read humanly, with chat picker, url filter, test send and delivery log 2026-10-19 02:57:27 +00:00
hh 6a4fd6ad47 feat(backend): watches report alert counts, alerts carry watch params and time away, webhooks their last delivery 2026-10-19 02:57:27 +00:00
hh 9da0c06ac1 feat(backend): webhooks deliver signed alerts from an outbox with retries and a test send 2026-10-19 02:57:27 +00:00
hh 2bb8ca45fb feat(backend): alerts come enriched and paged with an unseen count and a live alert frame 2026-10-19 02:57:27 +00:00
hh 4469938f47 feat(backend): watches fire on new, edited and deleted messages and on coming online, legacy keyword included 2026-10-19 02:57:27 +00:00
hh 8ed9ca1951 fix(frontend): chat list shows its chats on the first load again 2026-10-19 02:57:17 +00:00
hh 73b8903de1 feat(frontend): analytics shows minutes online by hour of week and by day, sessions with start and end, says when last seen is hidden, chat status is live and in russian 2026-10-19 02:41:39 +00:00
hh f44597b2dc feat(backend): presence stats count time online from sessions in the browser time zone, capture uptime marks hours without data, presence reaches every stream of the account, message volume keeps empty days 2026-10-19 02:41:39 +00:00
hh bb38e23f29 feat(frontend): my message menu shows when it was read or listened, group readers open in a right panel 2026-10-19 02:40:49 +00:00
hh f03d7c2890 fix(frontend): right panel on phones stays above the sticky dates of the chat 2026-10-19 02:40:49 +00:00
hh d8dd08d7de feat(backend): read time of my messages from the archive, exact dates and group readers asked from telegram on demand 2026-10-19 02:40:49 +00:00
hh f27943aae6 feat(backend): supergroup read receipts are captured like basic groups 2026-10-19 02:40:30 +00:00
hh d8ad62fb59 fix(frontend): switching accounts never shows the previous account's chats for a moment 2026-10-19 02:33:44 +00:00
hh f5a8649855 perf(backend): message writes look up the stored copy by its date and touch one chunk 2026-10-19 02:29:19 +00:00
hh 23c1504001 feat(userbot): after a restart the userbot catches up on what it missed while down, from a saved cursor and within bounds 2026-10-19 02:29:19 +00:00
hh 92e5e737d2 fix(userbot): presence, story and profile history toggles of the capture policy take effect for each user peer 2026-10-19 02:29:19 +00:00
hh f32b772ddc fix(userbot): a deletion that arrives before its message is applied once the message is stored 2026-10-19 02:29:19 +00:00
hh 516b8e307f fix(backend): a re-captured or bot-edited message keeps its earlier text as a version, an older copy never overwrites a newer edit 2026-10-19 02:29:19 +00:00
hh 33ceea3dfc fix(backend): a bulk deletion is announced in chunks that fit the notify payload limit 2026-10-19 02:29:19 +00:00
hh 4ce9c5d340 fix(backend): a session or bot that fails to start is retried and the failure is logged 2026-10-19 02:29:19 +00:00
hh 42469eb9f1 fix(userbot): job queue survives a consumer failure, a flood wait defers its job instead of holding the queue 2026-10-19 02:29:19 +00:00
hh 3373c31a02 fix(backend): listen connections reconnect after a dropped connection or a postgres restart and catch up on what they missed 2026-10-19 02:29:19 +00:00
hh 17e27beb59 fix(userbot): repeated link rows no longer abort the message insert 2026-10-19 02:29:19 +00:00
hh 54424d0aa0 feat(frontend): browser tab shows the open chat, empty state speaks the chosen language 2026-10-19 02:06:13 +00:00
hh 50922ef69a feat(frontend): installable on the phone with its own icon, title and manifest, missing avatars are asked again later 2026-10-19 02:05:41 +00:00
hh a1d3552519 feat(frontend): main menu lists accounts with their avatars, switching opens the last chat of that account 2026-10-19 02:02:31 +00:00
hh 3460246749 feat(frontend): russian and english interface with a language choice in settings, soon stubs removed 2026-10-19 01:54:34 +00:00
renovate Bot 3d2f670e87 chore(deps): update ghcr.io/astral-sh/uv:python3.13-trixie-slim docker digest to 7dd72af (main) (#20)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-18 04:09:11 +00:00
renovate Bot 305de9f871 chore(deps): pin ghcr.io/astral-sh/uv docker tag to 40215b6 (main) (#15)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-14 04:07:55 +00:00
hh dcd6786ca5 fix(backend): avatar job ends without a crash when telegram does not know the owner 2026-10-14 05:25:48 +02:00
hh 32d58193f3 fix(backend): raw update reaches every handler that declares it, contact list follows user updates 2026-10-14 05:25:45 +02:00
hh 59d0e2c8ad fix(backend): shared contact card keeps its phone number in the stored message 2026-10-14 05:25:42 +02:00
hh 850d39ad6f fix(frontend): cleanup block shows its failure in the error color 2026-10-14 05:17:58 +02:00
hh b8a38092eb fix(frontend): emptied chat keeps its storage head, shared-only chat explains why nothing can be bulk moved, back from a chat returns to the same place in the list, cleanup wording agrees in number 2026-10-14 05:15:27 +02:00
hh 2836dcb3a9 fix(backend): stale avatar is refetched again under kurigram 2.2.26, profile photos come as chat photos 2026-10-14 05:09:38 +02:00
hh 01cf01b629 fix(backend): rich messages of the new telegram layer read as plain text instead of no text 2026-10-14 05:06:35 +02:00
hh 7f124059be feat(frontend): storage shows the heaviest chats, a chat opens its own files with selective cleanup and a whole-chat move to trash 2026-10-14 05:02:06 +02:00
hh 5eacabd101 feat(backend): storage lists the heaviest chats, summary and file list take a chat filter 2026-10-14 05:02:03 +02:00
hh 0362777a51 fix(frontend): opened policy rule stays in view while its card grows, chevrons hold one column, switch and field sizes match the rest 2026-10-14 04:31:18 +02:00
hh dfc726976b fix(frontend): policy card keeps its wells in the light theme, folder rules show their content, opened rule scrolls into view, rule removal asks first 2026-10-14 04:27:59 +02:00
hh cb01d7dff8 feat(frontend): capture policy shows what each rule changes, rule card opens from folder and chat menus and can trash downloaded files, trash is paged 2026-10-14 04:20:38 +02:00
hh f646f29ff9 feat(backend): policy rules show what they inherit, files of a chat or folder go to trash in one batch, trash list is paged 2026-10-14 04:13:13 +02:00
hh 945adcf879 fix(backend): userbot follows kurigram 2.2.26, user and chat parsing is awaited, missing chat is handled 2026-10-14 03:55:33 +02:00
renovate Bot d213dfdf64 chore(deps): update dependency uv_build to >=0.13.0,<0.14.0 (dev) (#19)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-14 03:54:09 +02:00
renovate Bot 566ee09431 chore(deps): update code dependencies (dev) (#17)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-14 03:54:09 +02:00
hh 09c5f78061 fix(frontend,backend): all stories panel groups by peer again, missing trashed file answers 404, access log hides the token 2026-10-14 03:53:56 +02:00
hh 897421e7c4 fix(frontend): storage failures use the error colour next to the warning, shared mark on its own line in the list, trash key is visible 2026-10-14 03:28:29 +02:00
hh 3223f62dea fix(frontend): selected storage chip keeps its fill in the light theme, tile label uses the medium weight 2026-10-14 03:24:31 +02:00
hh c9c088ed73 fix(frontend): shared mark on its own line in trash rows, undo ignores the trailing click, trash segment has a key 2026-10-14 03:23:22 +02:00
hh 580927da0a fix(frontend,backend): storage categories keep one hue in both themes, trash counted in the total, undo keeps the list, larger touch targets 2026-10-14 03:18:59 +02:00
hh 06ae3e928f refactor(frontend): storage panel answers in place instead of toasts, empty groups hidden, weight bar animates by transform 2026-10-14 03:14:51 +02:00
hh 55634984fd feat(frontend,backend): storage panel with weight rows and trash, heavy media streams by url in the viewer 2026-10-14 03:09:37 +02:00
hh f2c245707a feat(backend): storage trash with seven day retention, trashed files are not fetched again automatically 2026-10-14 02:52:21 +02:00
renovate Bot c20e132872 chore(deps): pin timescale/timescaledb docker tag to b346edc (main) (#16)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-10 23:22:30 +00:00
hh 4d16105e92 feat(frontend)!: sveltekit 3 on the v0.7.0 template, typescript 6, adapter-auto dropped; backend base moves to trixie 2026-10-10 23:18:57 +00:00
hh b1ffebe858 fix(infra): timescaledb image 2.27.1 to 2.30.2, the high advisory fixed in 2.29.1 was still open 2026-10-10 20:50:06 +00:00
hh 2512a7988d chore(frontend): biome 2.5.15 stops flagging regexp exec results as always truthy, svelte files reformatted by it 2026-10-10 21:05:02 +02:00
hh 56146e4e8d chore(backend,infra): resolve template update conflicts, import check knows the watcher and the api login 2026-10-10 20:59:12 +02:00
hh 5fb8f8a534 style(backend): one-line import, ruff 0.17 keeps the pragma out of the line width 2026-10-10 20:59:12 +02:00
renovate Bot 98ab8a579b chore(infra): update template to v0.6.0, conflicts left 2026-10-10 20:59:12 +02:00
renovate Bot a5d10146fd chore(frontend): update template to v0.6.0 2026-10-10 20:59:12 +02:00
renovate Bot 5f1f18b343 chore(caddy): update template to v0.1.4 2026-10-10 20:59:12 +02:00
renovate Bot 8eb31ed4d3 chore(backend): update template to v0.6.0, conflicts left 2026-10-10 20:59:12 +02:00
renovate Bot e67dbdb325 chore(deps): update dependency ultracite to v7.12.4 (dev) (#13)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-10 20:59:12 +02:00
hh 869f8291fb fix(api): access log keeps the mcp path but not its query, the token rode in it 2026-10-09 02:44:01 +00:00
371 changed files with 27952 additions and 4197 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
_commit: v0.4.0 _commit: v0.6.0
_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 -1
View File
@@ -1,4 +1,4 @@
_commit: v0.1.2 _commit: v0.1.4
_src_path: https://git.kotikot.com/templates/caddy.git _src_path: https://git.kotikot.com/templates/caddy.git
project_slug: beavergram project_slug: beavergram
routes: routes:
+2 -1
View File
@@ -1,7 +1,8 @@
_commit: v0.4.2 _commit: v0.7.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
linter: biome
project_name: beavergram project_name: beavergram
project_slug: beavergram project_slug: beavergram
serve: api serve: api
+1 -1
View File
@@ -1,4 +1,4 @@
_commit: v0.5.0 _commit: v0.6.0
_src_path: https://git.kotikot.com/templates/infra.git _src_path: https://git.kotikot.com/templates/infra.git
init_git: false init_git: false
layout: parts layout: parts
+1 -1
View File
@@ -8,4 +8,4 @@ docs
storage storage
# Local playground # Local playground
t/ /t/
+2 -6
View File
@@ -1,6 +1,6 @@
PARTS := $(patsubst %/Makefile,%,$(wildcard */Makefile)) PARTS := $(patsubst %/Makefile,%,$(wildcard */Makefile))
.PHONY: env recreate rebuild restart logs down deploy fmt check build pre-deploy post-deploy .PHONY: env recreate rebuild restart logs down fmt check build
env: env:
@fresh=; test -f .env || { awk 'FNR == 1 && NR > 1 { print "" } 1' .env.example $(addsuffix /.env.example,$(PARTS)) > .env; fresh=1; }; \ @fresh=; test -f .env || { awk 'FNR == 1 && NR > 1 { print "" } 1' .env.example $(addsuffix /.env.example,$(PARTS)) > .env; fresh=1; }; \
@@ -30,9 +30,5 @@ logs:
down: down:
docker compose down docker compose down
deploy: pre-deploy fmt check build:
docker compose up -d
$(MAKE) post-deploy
fmt check build pre-deploy post-deploy:
@for part in $(PARTS); do $(MAKE) -C $$part $@ || exit 1; done @for part in $(PARTS); do $(MAKE) -C $$part $@ || exit 1; done
+5 -4
View File
@@ -11,7 +11,7 @@ Useful on its own: a Telegram where nothing disappears. In the [beaver](https://
- **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out. - **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out.
- **Presence** as a time series (TimescaleDB), read receipts, stories. - **Presence** as a time series (TimescaleDB), read receipts, stories.
- **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default. - **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default.
- **Annotations and watches**: your notes on chats and rules that fire alerts, both yours to write in the web client. - **Annotations and watches**: your notes on messages, with `#tags` and one list of them all, and rules that fire alerts, both yours to write in the web client.
- **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API. - **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API.
Nothing is ever deleted from the archive. Nothing is ever deleted from the archive.
@@ -19,19 +19,20 @@ Nothing is ever deleted from the archive.
## Parts ## Parts
- `backend/` - `userbot` (kurigram, the capture loop and jobs), `watcher` (kurigram again, one bot-token session per watched bot) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files). - `backend/` - `userbot` (kurigram, the capture loop and jobs), `watcher` (kurigram again, one bot-token session per watched bot) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files).
- `frontend/` - SvelteKit, `adapter-static`; a chat client over the archive: folders, chat list, thread with media, versions and the deleted messages inline, search, stories, presence graphs. - `frontend/` - SvelteKit, `adapter-static`; a chat client over the archive: folders, chat list, thread with media, versions and the deleted messages inline, a feed of what was deleted or edited across chats, search, stories, presence graphs.
- `docker-compose.yml` - includes `backend/` (Postgres with TimescaleDB, `userbot`, `api`) and `frontend/` (the dev server); profiles `external` and `services`. - `docker-compose.yml` - includes `backend/` (Postgres with TimescaleDB, `userbot`, `api`) and `frontend/` (the dev server); profiles `external` and `services`.
## MCP ## MCP
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be. Read-only: `search_messages_tool`, `list_chats`, `get_chat_history` (a readable transcript, deleted messages included) and `get_chat_history_raw` (the same as JSON), `get_deleted_messages`, `get_feed` (deleted and edited messages and profile changes across chats), `get_message_versions`, `get_media`, `get_callbacks`, `get_read_info`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. Two tools write: `set_watch`, and `download_media`, which pulls any attachment and leaves a public link to it for a day, visible and revocable in the web client. There are no send, edit or delete tools and there will not be.
## Run ## Run
```sh ```sh
make env # .env and docker-compose.override.yml from the examples make env # .env and docker-compose.override.yml from the examples
make -C backend session # log the account in once; the session file stays in backend/sessions make -C backend session # log the account in once; the session file stays in backend/sessions
make deploy # build what changed, migrate, start the containers make -C backend migrate # the schema
make recreate # build and start the services in COMPOSE_PROFILES
``` ```
`COMPOSE_PROFILES` in `.env` selects what runs on the machine, `docker-compose.override.yml` in each part holds the published ports and the `caddy` network aliases. The web client and the MCP endpoint are served by `api`; the bearer for both is `AUTH__TOKEN`. `COMPOSE_PROFILES` in `.env` selects what runs on the machine, `docker-compose.override.yml` in each part holds the published ports and the `caddy` network aliases. The web client and the MCP endpoint are served by `api`; the bearer for both is `AUTH__TOKEN`.
+2
View File
@@ -4,6 +4,7 @@ TZ=UTC
LOG__LEVEL=INFO LOG__LEVEL=INFO
LOG__LEVEL_EXTERNAL=WARNING LOG__LEVEL_EXTERNAL=WARNING
LOG__SHOW_TIME=false LOG__SHOW_TIME=false
LOG__SHOW_LOCALS=false
LOG__CONSOLE_WIDTH=150 LOG__CONSOLE_WIDTH=150
DB__HOST=postgres DB__HOST=postgres
@@ -24,6 +25,7 @@ BOTS__BACKFILL_PER_CHAT=0
API__HOST=0.0.0.0 API__HOST=0.0.0.0
API__PORT=8080 API__PORT=8080
API__PUBLIC_URL=
AUTH__TOKEN= AUTH__TOKEN=
+4 -3
View File
@@ -1,7 +1,8 @@
## Checking commands ## Checking commands
After writing code, always run from `backend/`: After writing code, always run from `backend/`:
```shell ```shell
ruff format make fmt
ruff check --fix make check
ty check
``` ```
Mind startup imports: `make check` runs `scripts/check_imports.py`.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:331fbb158258608b9391e802305a9a2c9e42d689435c1d98706647122a122ab0
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
+2 -21
View File
@@ -1,12 +1,6 @@
COMPOSE := cd .. && docker compose COMPOSE := cd .. && docker compose
IMAGE := beavergram/backend
IMAGE_INPUTS := pyproject.toml uv.lock Dockerfile
CODE := src scripts ../frontend
SERVICES := api userbot watcher
changed = ! git diff --quiet $(1) -- $(2) 2>/dev/null
stamp = { git update-ref $(1) HEAD 2>/dev/null || true; }
.PHONY: fmt check build pre-deploy post-deploy migrate revision session .PHONY: fmt check build migrate revision session
fmt: fmt:
uv run ruff format uv run ruff format
@@ -16,24 +10,11 @@ check:
uv run ruff format --check uv run ruff format --check
uv run ruff check uv run ruff check
uv run ty check uv run ty check
uv run python scripts/check_imports.py
build: build:
$(COMPOSE) build api $(COMPOSE) build api
pre-deploy:
@if $(call changed,refs/deploy/backend,$(IMAGE_INPUTS)) || ! docker image inspect $(IMAGE) >/dev/null 2>&1; then \
$(MAKE) build && $(call stamp,refs/deploy/backend) && $(call stamp,refs/deploy/backend-code); \
fi
@$(COMPOSE) config --services | grep -qx postgres && $(COMPOSE) up -d postgres || true
$(MAKE) migrate
post-deploy:
@if $(call changed,refs/deploy/backend-code,$(CODE)); then \
running=$$($(COMPOSE) ps --services --status running | grep -x $(addprefix -e ,$(SERVICES))); \
[ -z "$$running" ] || $(COMPOSE) restart $$running; \
$(call stamp,refs/deploy/backend-code); \
fi
migrate: migrate:
$(COMPOSE) run --rm migrator $(or $(filter-out $@,$(MAKECMDGOALS)),upgrade head) $(COMPOSE) run --rm migrator $(or $(filter-out $@,$(MAKECMDGOALS)),upgrade head)
+1 -1
View File
@@ -80,7 +80,7 @@ services:
database: database:
postgres: postgres:
image: timescale/timescaledb:2.27.1-pg17 image: timescale/timescaledb:2.30.2-pg17@sha256:b346edcdb51a1fd6020e3965e0bd1c9f3406fa6d5fbce1e28f4852587ef934e2
profiles: [postgres, external] profiles: [postgres, external]
restart: unless-stopped restart: unless-stopped
environment: environment:
+10 -1
View File
@@ -20,10 +20,15 @@ def _async_url() -> str:
return env.db.connection_url.replace("postgresql://", "postgresql+asyncpg://", 1) return env.db.connection_url.replace("postgresql://", "postgresql+asyncpg://", 1)
def include_name(name: str | None, type_: str, _parents: object) -> bool:
return not (type_ == "table" and name is not None and name.startswith("taskiq_"))
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
context.configure( context.configure(
url=_async_url(), url=_async_url(),
target_metadata=target_metadata, target_metadata=target_metadata,
include_name=include_name,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
) )
@@ -32,7 +37,11 @@ def run_migrations_offline() -> None:
def _do_run_migrations(connection) -> None: # noqa: ANN001 def _do_run_migrations(connection) -> None: # noqa: ANN001
context.configure(connection=connection, target_metadata=target_metadata) context.configure(
connection=connection,
target_metadata=target_metadata,
include_name=include_name,
)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
@@ -0,0 +1,32 @@
"""edit versions everywhere
Revision ID: a2c4e6f8b1d3
Revises: e8b3f1a6c2d4
Create Date: 2026-10-19 02:30:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "a2c4e6f8b1d3"
down_revision: str | None = "e8b3f1a6c2d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"UPDATE capture_policy SET track_edits_deletes = TRUE, updated_at = now() "
"WHERE scope_type IN ('default_group', 'default_channel') "
"AND NOT track_edits_deletes"
)
def downgrade() -> None:
op.execute(
"UPDATE capture_policy SET track_edits_deletes = FALSE, updated_at = now() "
"WHERE scope_type IN ('default_group', 'default_channel') "
"AND track_edits_deletes"
)
@@ -0,0 +1,198 @@
"""feed of deleted and edited messages
Revision ID: b3d5f7a9c2e4
Revises: a2c4e6f8b1d3
Create Date: 2026-10-19 02:40:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "b3d5f7a9c2e4"
down_revision: str | None = "a2c4e6f8b1d3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_ITEMS = """
CREATE TABLE feed_items (
id bigserial PRIMARY KEY,
account_id integer NOT NULL,
kind text NOT NULL,
chat_id bigint NOT NULL,
chat_kind text NOT NULL,
message_id bigint NOT NULL,
message_date timestamptz,
at timestamptz NOT NULL,
since timestamptz NOT NULL,
message_count integer NOT NULL DEFAULT 1
)
"""
_SEEN = """
CREATE TABLE feed_seen (
account_id integer PRIMARY KEY,
seen_at timestamptz NOT NULL
)
"""
_CHAT_KIND = """
CREATE FUNCTION feed_chat_kind(chat_id bigint, chat_type text)
RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT CASE
WHEN chat_id > 0 THEN 'dm'
WHEN chat_type = 'ChatType.CHANNEL' THEN 'channel'
ELSE 'group' END
$$
"""
_MESSAGE_DATE = """
CREATE FUNCTION feed_message_date(stamp text)
RETURNS timestamptz LANGUAGE sql STABLE AS $$
SELECT CASE
WHEN stamp ~ '^\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?$'
THEN stamp::timestamp AT TIME ZONE 'UTC' END
$$
"""
_ON_DELETE = """
CREATE FUNCTION feed_on_delete() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
item feed_items%ROWTYPE;
BEGIN
SELECT * INTO item FROM feed_items
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
AND kind = 'deleted'
ORDER BY at DESC, id DESC LIMIT 1;
IF FOUND AND NEW.deleted_at BETWEEN item.since - interval '5 seconds'
AND item.at + interval '5 seconds' THEN
IF (NEW.date, NEW.message_id) > (item.message_date, item.message_id) THEN
item.message_id := NEW.message_id;
item.message_date := NEW.date;
END IF;
UPDATE feed_items SET
message_count = message_count + 1,
at = greatest(at, NEW.deleted_at),
since = least(since, NEW.deleted_at),
message_id = item.message_id,
message_date = item.message_date
WHERE id = item.id;
ELSE
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, message_date,
at, since)
VALUES (NEW.account_id, 'deleted', NEW.chat_id,
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
NEW.message_id, NEW.date, NEW.deleted_at, NEW.deleted_at);
END IF;
RETURN NULL;
END;
$$
"""
_ON_VERSION = """
CREATE FUNCTION feed_on_version() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.edit_date IS NULL AND NOT EXISTS (
SELECT 1 FROM message_versions v
WHERE v.account_id = NEW.account_id AND v.chat_id = NEW.chat_id
AND v.message_id = NEW.message_id AND v.observed_at < NEW.observed_at
) THEN
RETURN NULL;
END IF;
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, message_date, at, since)
VALUES (NEW.account_id, 'edited', NEW.chat_id,
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
NEW.message_id, feed_message_date(NEW.raw->>'date'),
NEW.observed_at, NEW.observed_at);
RETURN NULL;
END;
$$
"""
_BACKFILL = """
WITH d AS (
SELECT account_id, chat_id, message_id, date, deleted_at,
raw->'chat'->>'type' AS chat_type,
deleted_at - lag(deleted_at) OVER (
PARTITION BY account_id, chat_id ORDER BY deleted_at, message_id
) AS gap
FROM messages WHERE deleted_at IS NOT NULL
), b AS (
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '5 seconds')
OVER (PARTITION BY account_id, chat_id ORDER BY deleted_at, message_id)
AS burst
FROM d
), deleted AS (
SELECT account_id, 'deleted' AS kind, chat_id,
feed_chat_kind(chat_id,
(array_agg(chat_type ORDER BY date DESC, message_id DESC))[1])
AS chat_kind,
(array_agg(message_id ORDER BY date DESC, message_id DESC))[1]
AS message_id,
max(date) AS message_date, max(deleted_at) AS at,
min(deleted_at) AS since, count(*)::integer AS message_count
FROM b GROUP BY account_id, chat_id, burst
), edited AS (
SELECT v.account_id, 'edited' AS kind, v.chat_id,
feed_chat_kind(v.chat_id, v.raw->'chat'->>'type') AS chat_kind,
v.message_id, feed_message_date(v.raw->>'date') AS message_date,
v.observed_at AS at, v.observed_at AS since, 1 AS message_count
FROM message_versions v
WHERE v.edit_date IS NOT NULL OR EXISTS (
SELECT 1 FROM message_versions p
WHERE p.account_id = v.account_id AND p.chat_id = v.chat_id
AND p.message_id = v.message_id AND p.observed_at < v.observed_at
)
)
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, message_date, at, since,
message_count)
SELECT * FROM (SELECT * FROM deleted UNION ALL SELECT * FROM edited) items
ORDER BY at, chat_id, message_id
"""
def upgrade() -> None:
op.execute("SET LOCAL jit = off")
op.execute(_ITEMS)
op.execute(_SEEN)
op.execute(_CHAT_KIND)
op.execute(_MESSAGE_DATE)
op.execute(_ON_DELETE)
op.execute(_ON_VERSION)
op.execute(
"CREATE TRIGGER messages_feed_deleted AFTER UPDATE OF deleted_at "
"ON messages FOR EACH ROW "
"WHEN (OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL) "
"EXECUTE FUNCTION feed_on_delete()"
)
op.execute(
"CREATE TRIGGER message_versions_feed AFTER INSERT ON message_versions "
"FOR EACH ROW EXECUTE FUNCTION feed_on_version()"
)
op.execute(_BACKFILL)
op.execute(
"CREATE INDEX ix_feed_items_recent ON feed_items (account_id, at DESC, id DESC)"
)
op.execute(
"CREATE INDEX ix_feed_items_chat ON feed_items "
"(account_id, chat_id, at DESC, id DESC)"
)
op.execute(
"INSERT INTO feed_seen (account_id, seen_at) SELECT account_id, now() "
"FROM accounts"
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS message_versions_feed ON message_versions")
op.execute("DROP TRIGGER IF EXISTS messages_feed_deleted ON messages")
op.execute("DROP FUNCTION IF EXISTS feed_on_version()")
op.execute("DROP FUNCTION IF EXISTS feed_on_delete()")
op.execute("DROP FUNCTION IF EXISTS feed_message_date(text)")
op.execute("DROP FUNCTION IF EXISTS feed_chat_kind(bigint, text)")
op.execute("DROP TABLE IF EXISTS feed_seen")
op.execute("DROP TABLE IF EXISTS feed_items")
@@ -0,0 +1,321 @@
"""feed of profile changes and chat renames
Revision ID: c5e7a9b1d3f6
Revises: b3d5f7a9c2e4
Create Date: 2026-10-19 04:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "c5e7a9b1d3f6"
down_revision: str | None = "b3d5f7a9c2e4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_PEER_VALUE = """
CREATE FUNCTION feed_peer_value(
field text, first_name text, last_name text, username text, photo text
) RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT CASE field
WHEN 'name' THEN NULLIF(trim(concat_ws(' ', first_name, last_name)), '')
WHEN 'username' THEN NULLIF(username, '')
WHEN 'photo' THEN NULLIF(photo, '') END
$$
"""
_PEER_SCOPE = """
CREATE FUNCTION feed_peer_in_scope(account integer, peer bigint)
RETURNS boolean LANGUAGE sql STABLE AS $$
SELECT EXISTS (
SELECT 1 FROM dialogs WHERE account_id = account AND chat_id = peer
) OR EXISTS (
SELECT 1 FROM peers
WHERE account_id = account AND peer_id = peer
AND (raw->>'is_contact' = 'true' OR raw->>'contact' = 'true')
)
$$
"""
_MERGE_DETAIL = """
CREATE FUNCTION feed_merge_detail(base jsonb, incoming jsonb)
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
SELECT coalesce(jsonb_object_agg(key, pair), '{}'::jsonb)
FROM (
SELECT key, jsonb_build_array(
coalesce(base->key->0, incoming->key->0),
coalesce(incoming->key->1, base->key->1)) AS pair
FROM (
SELECT jsonb_object_keys(coalesce(base, '{}'::jsonb)) AS key
UNION
SELECT jsonb_object_keys(incoming)
) keys
) merged
WHERE pair->0 IS DISTINCT FROM pair->1
$$
"""
_UPSERT = """
CREATE FUNCTION feed_profile_upsert(
account integer, chat bigint, kind_of_chat text, message bigint,
changed_at timestamptz, changes jsonb
) RETURNS void LANGUAGE plpgsql AS $$
DECLARE
item feed_items%ROWTYPE;
merged jsonb;
BEGIN
SELECT * INTO item FROM feed_items
WHERE account_id = account AND chat_id = chat AND kind = 'profile'
ORDER BY at DESC, id DESC LIMIT 1;
IF FOUND AND changed_at BETWEEN item.since - interval '1 day'
AND item.at + interval '1 day' THEN
merged := feed_merge_detail(item.detail, changes);
IF merged = '{}'::jsonb THEN
DELETE FROM feed_items WHERE id = item.id;
ELSE
UPDATE feed_items SET
detail = merged,
message_count = message_count
+ (SELECT count(*) FROM jsonb_object_keys(changes)),
at = greatest(at, changed_at),
since = least(since, changed_at),
message_id = greatest(message_id, message)
WHERE id = item.id;
END IF;
ELSE
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, at, since,
message_count, detail)
VALUES (account, 'profile', chat, kind_of_chat, message, changed_at,
changed_at,
(SELECT count(*) FROM jsonb_object_keys(changes)), changes);
END IF;
END;
$$
"""
_ON_PEER = """
CREATE FUNCTION feed_on_peer_change() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
changes jsonb := '{}'::jsonb;
field text;
fresh text;
old text;
BEGIN
IF NEW.peer_id <= 0 OR NOT feed_peer_in_scope(NEW.account_id, NEW.peer_id) THEN
RETURN NULL;
END IF;
FOREACH field IN ARRAY ARRAY['name', 'username', 'photo'] LOOP
fresh := feed_peer_value(field, NEW.first_name, NEW.last_name,
NEW.username, NEW.photo_unique_id);
CONTINUE WHEN fresh IS NULL;
SELECT value INTO old FROM (
SELECT feed_peer_value(field, h.first_name, h.last_name, h.username,
h.photo_unique_id) AS value, h.observed_at
FROM peer_history h
WHERE h.account_id = NEW.account_id AND h.peer_id = NEW.peer_id
AND h.observed_at < NEW.observed_at
) earlier
WHERE value IS NOT NULL
ORDER BY observed_at DESC LIMIT 1;
CONTINUE WHEN old IS NULL OR old = fresh;
CONTINUE WHEN EXISTS (
SELECT 1 FROM peer_history h
WHERE h.account_id = NEW.account_id AND h.peer_id = NEW.peer_id
AND h.observed_at < NEW.observed_at
AND feed_peer_value(field, h.first_name, h.last_name, h.username,
h.photo_unique_id) = fresh
);
changes := changes || jsonb_build_object(field, jsonb_build_array(old, fresh));
END LOOP;
IF changes <> '{}'::jsonb THEN
PERFORM feed_profile_upsert(NEW.account_id, NEW.peer_id, 'dm', 0,
NEW.observed_at, changes);
END IF;
RETURN NULL;
END;
$$
"""
_ON_TITLE = """
CREATE FUNCTION feed_on_chat_title() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
previous text;
BEGIN
IF NOT EXISTS (
SELECT 1 FROM dialogs
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
) THEN
RETURN NULL;
END IF;
SELECT title INTO previous FROM chat_history
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
AND event IN ('meta', 'title') AND title IS NOT NULL
AND (ts, message_id) < (NEW.ts, NEW.message_id)
ORDER BY ts DESC, message_id DESC LIMIT 1;
IF previous IS NULL OR previous = NEW.title THEN
RETURN NULL;
END IF;
PERFORM feed_profile_upsert(
NEW.account_id, NEW.chat_id,
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
CASE WHEN NEW.event = 'title' THEN NEW.message_id ELSE 0 END,
NEW.ts, jsonb_build_object('title', jsonb_build_array(previous, NEW.title)));
RETURN NULL;
END;
$$
"""
_BACKFILL_PEERS = """
WITH scope AS (
SELECT account_id::integer AS account_id, chat_id AS peer_id
FROM dialogs WHERE chat_id > 0
UNION
SELECT account_id, peer_id FROM peers
WHERE raw->>'is_contact' = 'true' OR raw->>'contact' = 'true'
), firsts AS (
SELECT h.account_id, h.peer_id, f.field, f.value,
min(h.observed_at) AS at
FROM peer_history h
JOIN scope s ON s.account_id = h.account_id AND s.peer_id = h.peer_id
CROSS JOIN LATERAL (
SELECT field, feed_peer_value(field, h.first_name, h.last_name,
h.username, h.photo_unique_id) AS value
FROM unnest(ARRAY['name', 'username', 'photo']) AS field
) f
WHERE f.value IS NOT NULL
GROUP BY 1, 2, 3, 4
), ranked AS (
SELECT *, row_number() OVER (
PARTITION BY account_id, peer_id, field ORDER BY at
) AS position
FROM firsts
), changes AS (
SELECT r.account_id, r.peer_id, r.field, r.value AS fresh, r.at, o.value AS old
FROM ranked r
CROSS JOIN LATERAL (
SELECT value FROM (
SELECT feed_peer_value(r.field, h.first_name, h.last_name,
h.username, h.photo_unique_id) AS value,
h.observed_at
FROM peer_history h
WHERE h.account_id = r.account_id AND h.peer_id = r.peer_id
AND h.observed_at < r.at
) earlier
WHERE value IS NOT NULL
ORDER BY observed_at DESC LIMIT 1
) o
WHERE r.position > 1 AND r.at > now() - interval '90 days'
AND o.value <> r.value
), gaps AS (
SELECT *, at - lag(at) OVER (PARTITION BY account_id, peer_id ORDER BY at)
AS gap
FROM changes
), bursts AS (
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '1 day')
OVER (PARTITION BY account_id, peer_id ORDER BY at) AS burst
FROM gaps
), fields AS (
SELECT account_id, peer_id, burst, field,
(array_agg(old ORDER BY at))[1] AS old,
(array_agg(fresh ORDER BY at DESC))[1] AS fresh,
count(*) AS changes, min(at) AS since, max(at) AS at
FROM bursts GROUP BY 1, 2, 3, 4
)
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, at, since,
message_count, detail)
SELECT account_id, 'profile', peer_id, 'dm', 0, max(at), min(since),
sum(changes)::integer,
jsonb_object_agg(field, jsonb_build_array(old, fresh))
FILTER (WHERE old <> fresh)
FROM fields
GROUP BY account_id, peer_id, burst
HAVING bool_or(old <> fresh)
ORDER BY max(at)
"""
_BACKFILL_CHATS = """
WITH titles AS (
SELECT account_id, chat_id, message_id, event, ts, title,
raw->'chat'->>'type' AS chat_type,
lag(title) OVER (
PARTITION BY account_id, chat_id ORDER BY ts, message_id
) AS previous
FROM chat_history
WHERE event IN ('meta', 'title') AND title IS NOT NULL
), changes AS (
SELECT * FROM titles t
WHERE previous IS NOT NULL AND previous <> title
AND ts > now() - interval '90 days'
AND EXISTS (
SELECT 1 FROM dialogs d
WHERE d.account_id = t.account_id AND d.chat_id = t.chat_id
)
), gaps AS (
SELECT *, ts - lag(ts) OVER (PARTITION BY account_id, chat_id ORDER BY ts)
AS gap
FROM changes
), bursts AS (
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '1 day')
OVER (PARTITION BY account_id, chat_id ORDER BY ts) AS burst
FROM gaps
), grouped AS (
SELECT account_id, chat_id, burst,
(array_agg(chat_type ORDER BY ts DESC))[1] AS chat_type,
max(CASE WHEN event = 'title' THEN message_id ELSE 0 END) AS message_id,
(array_agg(previous ORDER BY ts))[1] AS old,
(array_agg(title ORDER BY ts DESC))[1] AS fresh,
count(*) AS changes, min(ts) AS since, max(ts) AS at
FROM bursts GROUP BY 1, 2, 3
)
INSERT INTO feed_items
(account_id, kind, chat_id, chat_kind, message_id, at, since,
message_count, detail)
SELECT account_id, 'profile', chat_id, feed_chat_kind(chat_id, chat_type),
message_id, at, since, changes::integer,
jsonb_build_object('title', jsonb_build_array(old, fresh))
FROM grouped
WHERE old <> fresh
ORDER BY at
"""
def upgrade() -> None:
op.execute("SET LOCAL jit = off")
op.execute("ALTER TABLE feed_items ADD COLUMN detail jsonb")
op.execute(_PEER_VALUE)
op.execute(_PEER_SCOPE)
op.execute(_MERGE_DETAIL)
op.execute(_UPSERT)
op.execute(_ON_PEER)
op.execute(_ON_TITLE)
op.execute(
"CREATE TRIGGER peer_history_feed AFTER INSERT ON peer_history "
"FOR EACH ROW EXECUTE FUNCTION feed_on_peer_change()"
)
op.execute(
"CREATE TRIGGER chat_history_feed AFTER INSERT ON chat_history "
"FOR EACH ROW WHEN (NEW.event IN ('meta', 'title') AND NEW.title IS NOT NULL) "
"EXECUTE FUNCTION feed_on_chat_title()"
)
op.execute(_BACKFILL_PEERS)
op.execute(_BACKFILL_CHATS)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS chat_history_feed ON chat_history")
op.execute("DROP TRIGGER IF EXISTS peer_history_feed ON peer_history")
op.execute("DROP FUNCTION IF EXISTS feed_on_chat_title()")
op.execute("DROP FUNCTION IF EXISTS feed_on_peer_change()")
op.execute(
"DROP FUNCTION IF EXISTS "
"feed_profile_upsert(integer, bigint, text, bigint, timestamptz, jsonb)"
)
op.execute("DROP FUNCTION IF EXISTS feed_merge_detail(jsonb, jsonb)")
op.execute("DROP FUNCTION IF EXISTS feed_peer_in_scope(integer, bigint)")
op.execute("DROP FUNCTION IF EXISTS feed_peer_value(text, text, text, text, text)")
op.execute("DELETE FROM feed_items WHERE kind = 'profile'")
op.execute("ALTER TABLE feed_items DROP COLUMN detail")
@@ -0,0 +1,57 @@
"""media trash
Revision ID: c9e2a7f4b1d8
Revises: b6d1f4a82e57
Create Date: 2026-10-14 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "c9e2a7f4b1d8"
down_revision: str | None = "b6d1f4a82e57"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TRASH = """
CREATE TABLE media_trash (
storage_key text PRIMARY KEY,
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
kind text NOT NULL,
mime text,
file_name text NOT NULL,
file_size bigint,
trashed_at timestamptz NOT NULL DEFAULT now()
)
"""
def upgrade() -> None:
op.execute(
"ALTER TABLE media ADD COLUMN evicted_at timestamptz, "
"ADD COLUMN evicted_key text"
)
op.execute(
"CREATE INDEX ix_media_evicted_unique ON media (unique_id) "
"WHERE evicted_at IS NOT NULL"
)
op.execute(
"CREATE INDEX ix_media_evicted_key ON media (evicted_key) "
"WHERE evicted_key IS NOT NULL"
)
op.execute(_TRASH)
op.execute(
"CREATE INDEX ix_media_trash_account ON media_trash "
"(account_id, trashed_at DESC)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS media_trash")
op.execute("DROP INDEX IF EXISTS ix_media_evicted_key")
op.execute("DROP INDEX IF EXISTS ix_media_evicted_unique")
op.execute("ALTER TABLE media DROP COLUMN evicted_key, DROP COLUMN evicted_at")
@@ -0,0 +1,51 @@
"""message reads
Revision ID: d7a3c5e9f2b1
Revises: f8c2d6e1a4b7
Create Date: 2026-10-19 01:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d7a3c5e9f2b1"
down_revision: str | None = "f8c2d6e1a4b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_READS = """
CREATE TABLE message_reads (
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
reader_id bigint NOT NULL,
read_at timestamptz NOT NULL,
source text NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, chat_id, message_id, reader_id)
)
"""
_FETCHES = """
CREATE TABLE read_fetches (
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
outcome text NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now(),
retry_at timestamptz NOT NULL,
PRIMARY KEY (account_id, chat_id, message_id)
)
"""
def upgrade() -> None:
op.execute(_READS)
op.execute(_FETCHES)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS read_fetches")
op.execute("DROP TABLE IF EXISTS message_reads")
@@ -0,0 +1,86 @@
"""watch names and webhooks
Revision ID: d8e3b5a1c7f4
Revises: d8f4a1c6e2b7
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d8e3b5a1c7f4"
down_revision: str | None = "d8f4a1c6e2b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_WEBHOOKS = """
CREATE TABLE webhooks (
id serial PRIMARY KEY,
account_id integer NOT NULL,
url text NOT NULL,
secret text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
watch_ids integer[],
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
"""
_DELIVERIES = """
CREATE TABLE webhook_deliveries (
id bigserial PRIMARY KEY,
webhook_id integer NOT NULL REFERENCES webhooks (id) ON DELETE CASCADE,
alert_id integer REFERENCES alerts (id) ON DELETE CASCADE,
event text NOT NULL,
status text NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
response_code integer,
last_error text,
body text,
created_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz
)
"""
_LEGACY_KEYWORD = """
UPDATE watches
SET params = (params - 'keyword') || jsonb_build_object('pattern', params->'keyword')
WHERE kind = 'keyword' AND params ? 'keyword' AND NOT params ? 'pattern'
"""
def upgrade() -> None:
op.execute("ALTER TABLE watches ADD COLUMN name text")
op.execute(_LEGACY_KEYWORD)
op.execute("ALTER TABLE alerts DROP CONSTRAINT alerts_watch_id_fkey")
op.execute(
"ALTER TABLE alerts ADD CONSTRAINT alerts_watch_id_fkey "
"FOREIGN KEY (watch_id) REFERENCES watches (id) ON DELETE CASCADE"
)
op.execute("CREATE INDEX ix_alerts_account_id ON alerts (account_id, id DESC)")
op.execute(_WEBHOOKS)
op.execute("CREATE INDEX ix_webhooks_account ON webhooks (account_id)")
op.execute(_DELIVERIES)
op.execute(
"CREATE INDEX ix_webhook_deliveries_due ON webhook_deliveries "
"(next_attempt_at) WHERE status = 'pending'"
)
op.execute(
"CREATE INDEX ix_webhook_deliveries_webhook ON webhook_deliveries "
"(webhook_id, id DESC)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS webhook_deliveries")
op.execute("DROP TABLE IF EXISTS webhooks")
op.execute("DROP INDEX IF EXISTS ix_alerts_account_id")
op.execute("ALTER TABLE alerts DROP CONSTRAINT alerts_watch_id_fkey")
op.execute(
"ALTER TABLE alerts ADD CONSTRAINT alerts_watch_id_fkey "
"FOREIGN KEY (watch_id) REFERENCES watches (id)"
)
op.execute("ALTER TABLE watches DROP COLUMN name")
@@ -0,0 +1,148 @@
"""message search index outside the hypertable, versions searchable
Revision ID: d8f1a6c3e5b7
Revises: c5e7a9b1d3f6
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d8f1a6c3e5b7"
down_revision: str | None = "c5e7a9b1d3f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_VECTOR = """
CREATE FUNCTION search_vector(body text) RETURNS tsvector
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT to_tsvector('russian', translate(coalesce(body, ''), 'Ёё', 'Ее'))
|| to_tsvector('simple', translate(coalesce(body, ''), 'Ёё', 'Ее'))
$$
"""
_TABLE = """
CREATE TABLE message_search (
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
date timestamptz NOT NULL,
sender_id bigint,
text text,
has_media boolean NOT NULL DEFAULT false,
edited_at timestamptz,
deleted_at timestamptz,
tsv tsvector GENERATED ALWAYS AS (search_vector(text)) STORED,
PRIMARY KEY (account_id, chat_id, message_id, date)
)
"""
_BACKFILL = """
INSERT INTO message_search (account_id, chat_id, message_id, date, sender_id,
text, has_media, edited_at, deleted_at)
SELECT account_id, chat_id, message_id, date, sender_id,
text, has_media, edited_at, deleted_at
FROM messages
"""
_SYNC = """
CREATE FUNCTION message_search_sync() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
DELETE FROM message_search
WHERE account_id = OLD.account_id AND chat_id = OLD.chat_id
AND message_id = OLD.message_id AND date = OLD.date;
RETURN NULL;
END IF;
INSERT INTO message_search (account_id, chat_id, message_id, date,
sender_id, text, has_media, edited_at,
deleted_at)
VALUES (NEW.account_id, NEW.chat_id, NEW.message_id, NEW.date,
NEW.sender_id, NEW.text, NEW.has_media, NEW.edited_at,
NEW.deleted_at)
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
sender_id = EXCLUDED.sender_id,
text = EXCLUDED.text,
has_media = EXCLUDED.has_media,
edited_at = EXCLUDED.edited_at,
deleted_at = EXCLUDED.deleted_at;
RETURN NULL;
END;
$$
"""
_CHANGED = (
"OLD.text IS DISTINCT FROM NEW.text "
"OR OLD.sender_id IS DISTINCT FROM NEW.sender_id "
"OR OLD.has_media IS DISTINCT FROM NEW.has_media "
"OR OLD.edited_at IS DISTINCT FROM NEW.edited_at "
"OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at"
)
def upgrade() -> None:
op.execute(_VECTOR)
op.execute(_TABLE)
op.execute(_BACKFILL)
op.execute("CREATE INDEX ix_message_search_tsv ON message_search USING gin (tsv)")
op.execute(
"CREATE INDEX ix_message_search_recent ON message_search "
"(account_id, date DESC, chat_id DESC, message_id DESC)"
)
op.execute(
"CREATE INDEX ix_message_search_sender ON message_search "
"(account_id, sender_id, date DESC)"
)
for flag in ("deleted", "edited"):
op.execute(
f"CREATE INDEX ix_message_search_{flag} ON message_search "
"(account_id, date DESC, chat_id DESC, message_id DESC) "
f"WHERE {flag}_at IS NOT NULL"
)
op.execute("ALTER TABLE message_search ALTER COLUMN tsv SET STATISTICS 1000")
op.execute(_SYNC)
op.execute(
"CREATE TRIGGER messages_search_insert AFTER INSERT ON messages "
"FOR EACH ROW EXECUTE FUNCTION message_search_sync()"
)
op.execute(
"CREATE TRIGGER messages_search_update AFTER UPDATE ON messages "
f"FOR EACH ROW WHEN ({_CHANGED}) EXECUTE FUNCTION message_search_sync()"
)
op.execute(
"CREATE TRIGGER messages_search_delete AFTER DELETE ON messages "
"FOR EACH ROW EXECUTE FUNCTION message_search_sync()"
)
op.execute(
"CREATE INDEX ix_message_versions_tsv ON message_versions USING gin "
"(search_vector(text))"
)
op.execute("DROP INDEX IF EXISTS ix_media_tsv")
op.execute(
"CREATE INDEX ix_media_tsv ON media USING gin (search_vector(extracted_text))"
)
op.execute("DROP INDEX IF EXISTS ix_messages_tsv")
for index in ("ix_message_versions_tsv", "ix_media_tsv"):
op.execute(f"ALTER INDEX {index} ALTER COLUMN 1 SET STATISTICS 1000")
op.execute("ANALYZE message_search, message_versions, media")
def downgrade() -> None:
op.execute(
"CREATE INDEX ix_messages_tsv ON messages USING gin "
"(to_tsvector('russian', coalesce(text, '')))"
)
op.execute("DROP INDEX IF EXISTS ix_media_tsv")
op.execute(
"CREATE INDEX ix_media_tsv ON media USING gin "
"(to_tsvector('russian', coalesce(extracted_text, '')))"
)
op.execute("DROP INDEX IF EXISTS ix_message_versions_tsv")
op.execute("DROP TRIGGER IF EXISTS messages_search_delete ON messages")
op.execute("DROP TRIGGER IF EXISTS messages_search_update ON messages")
op.execute("DROP TRIGGER IF EXISTS messages_search_insert ON messages")
op.execute("DROP FUNCTION IF EXISTS message_search_sync()")
op.execute("DROP TABLE IF EXISTS message_search")
op.execute("DROP FUNCTION IF EXISTS search_vector(text)")
@@ -0,0 +1,50 @@
"""capture uptime
Revision ID: d8f4a1c6e2b7
Revises: d7a3c5e9f2b1
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d8f4a1c6e2b7"
down_revision: str | None = "d7a3c5e9f2b1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_UPTIME = """
CREATE TABLE capture_uptime (
account_id integer NOT NULL,
started_at timestamptz NOT NULL,
seen_at timestamptz NOT NULL,
PRIMARY KEY (account_id, started_at)
)
"""
_BACKFILL = """
INSERT INTO capture_uptime (account_id, started_at, seen_at)
SELECT account_id, min(ts), max(ts)
FROM (
SELECT account_id, ts,
count(*) FILTER (WHERE gap > interval '30 minutes')
OVER (PARTITION BY account_id ORDER BY ts) AS stretch
FROM (
SELECT account_id, ts,
ts - lag(ts) OVER (PARTITION BY account_id ORDER BY ts) AS gap
FROM presence
) gaps
) stretches
GROUP BY account_id, stretch
"""
def upgrade() -> None:
op.execute(_UPTIME)
op.execute(_BACKFILL)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS capture_uptime")
@@ -0,0 +1,54 @@
"""profile history reads
Revision ID: e8b3f1a6c2d4
Revises: d8e3b5a1c7f4
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "e8b3f1a6c2d4"
down_revision: str | None = "d8e3b5a1c7f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_PEER_STATE = (
"first_name, last_name, username, phone, photo_unique_id, is_deleted_account"
)
def upgrade() -> None:
op.execute(
"ALTER TABLE peer_history DROP CONSTRAINT peer_history_pkey, "
"ADD CONSTRAINT peer_history_pkey "
f"PRIMARY KEY (account_id, peer_id, observed_at) INCLUDE ({_PEER_STATE})"
)
op.execute(
"ALTER TABLE chat_history DROP CONSTRAINT chat_history_pkey, "
"ADD CONSTRAINT chat_history_pkey "
"PRIMARY KEY (account_id, chat_id, message_id, ts)"
)
op.execute(
"CREATE INDEX ix_memberships_user ON memberships (account_id, user_id, ts DESC)"
)
op.execute(
"CREATE INDEX ix_jobs_kind_created ON jobs (account_id, kind, created_at DESC)"
)
with op.get_context().autocommit_block():
op.execute("VACUUM (ANALYZE) peer_history")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_jobs_kind_created")
op.execute("DROP INDEX IF EXISTS ix_memberships_user")
op.execute(
"ALTER TABLE chat_history DROP CONSTRAINT chat_history_pkey, "
"ADD CONSTRAINT chat_history_pkey PRIMARY KEY (account_id, chat_id, message_id)"
)
op.execute(
"ALTER TABLE peer_history DROP CONSTRAINT peer_history_pkey, "
"ADD CONSTRAINT peer_history_pkey PRIMARY KEY (account_id, peer_id, observed_at)"
)
@@ -0,0 +1,97 @@
"""chat stats last deleted
Revision ID: f1c4a8e2d6b3
Revises: d8f1a6c3e5b7
Create Date: 2026-10-19 04:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "f1c4a8e2d6b3"
down_revision: str | None = "d8f1a6c3e5b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_APPLY = """
CREATE OR REPLACE FUNCTION chat_stats_apply() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO chat_stats AS cs (account_id, chat_id, message_count,
last_date, last_message_id,
last_text, last_sender_id{column})
VALUES (NEW.account_id, NEW.chat_id, 1,
CASE WHEN NEW.date <= now() + interval '1 day'
THEN NEW.date END,
CASE WHEN NEW.date <= now() + interval '1 day'
THEN NEW.message_id END,
NEW.text, NEW.sender_id{value})
ON CONFLICT (account_id, chat_id) DO UPDATE SET
message_count = cs.message_count + 1,
last_date = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_date ELSE cs.last_date END,
last_message_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_message_id
ELSE cs.last_message_id END,
last_text = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_text ELSE cs.last_text END,
last_sender_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_sender_id
ELSE cs.last_sender_id END{merge};
ELSE
UPDATE chat_stats
SET last_text = NEW.text, last_sender_id = NEW.sender_id{update}
WHERE account_id = NEW.account_id
AND chat_id = NEW.chat_id
AND last_message_id = NEW.message_id;
END IF;
RETURN NULL;
END;
$$
"""
_DELETED_PARTS = {
"column": ", last_deleted",
"value": ", NEW.deleted_at IS NOT NULL",
"merge": (
",\n last_deleted = CASE WHEN chat_stats_newer(cs, EXCLUDED)"
" THEN EXCLUDED.last_deleted ELSE cs.last_deleted END"
),
"update": ", last_deleted = NEW.deleted_at IS NOT NULL",
"deleted": " OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at",
}
_PLAIN_PARTS = dict.fromkeys(_DELETED_PARTS, "")
_UPDATE_TRIGGER = (
"CREATE TRIGGER messages_chat_stats_update AFTER UPDATE ON messages "
"FOR EACH ROW WHEN (OLD.text IS DISTINCT FROM NEW.text "
"OR OLD.sender_id IS DISTINCT FROM NEW.sender_id{deleted}) "
"EXECUTE FUNCTION chat_stats_apply()"
)
_BACKFILL = """
UPDATE chat_stats cs SET last_deleted = true
FROM messages m
WHERE m.account_id = cs.account_id AND m.chat_id = cs.chat_id
AND m.message_id = cs.last_message_id AND m.date = cs.last_date
AND m.deleted_at IS NOT NULL
"""
def upgrade() -> None:
op.execute(
"ALTER TABLE chat_stats ADD COLUMN last_deleted boolean NOT NULL DEFAULT false"
)
op.execute(_BACKFILL)
op.execute(_APPLY.format(**_DELETED_PARTS))
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
op.execute(_UPDATE_TRIGGER.format(**_DELETED_PARTS))
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
op.execute(_UPDATE_TRIGGER.format(**_PLAIN_PARTS))
op.execute(_APPLY.format(**_PLAIN_PARTS))
op.execute("ALTER TABLE chat_stats DROP COLUMN last_deleted")
@@ -0,0 +1,39 @@
"""capture reliability
Revision ID: f8c2d6e1a4b7
Revises: c9e2a7f4b1d8
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "f8c2d6e1a4b7"
down_revision: str | None = "c9e2a7f4b1d8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"jobs", sa.Column("run_after", sa.DateTime(timezone=True), nullable=True)
)
op.execute(
"ALTER TABLE accounts "
"ADD COLUMN catchup_pts integer, "
"ADD COLUMN catchup_qts integer, "
"ADD COLUMN catchup_date integer"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE accounts "
"DROP COLUMN IF EXISTS catchup_pts, "
"DROP COLUMN IF EXISTS catchup_qts, "
"DROP COLUMN IF EXISTS catchup_date"
)
op.drop_column("jobs", "run_after")
+3 -1
View File
@@ -20,10 +20,11 @@ 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]
requires = ["uv_build>=0.12.13,<0.13.0"] requires = ["uv_build>=0.13.0,<0.14.0"]
build-backend = "uv_build" build-backend = "uv_build"
[tool.uv.build-backend] [tool.uv.build-backend]
@@ -39,6 +40,7 @@ unfixable = ["F401"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"scripts/*" = ["INP"] "scripts/*" = ["INP"]
"scripts/check_imports.py" = ["T201"]
"t/*" = ["ALL"] "t/*" = ["ALL"]
"*.lock" = ["ALL"] "*.lock" = ["ALL"]
"migrations/*" = ["INP", "D", "E501"] "migrations/*" = ["INP", "D", "E501"]
+45
View File
@@ -0,0 +1,45 @@
import os
import subprocess
import sys
HEAVY = ["aiogram", "pyrogram", "fastapi", "google.genai"]
SERVICES = {
"api": (["api.app"], {"fastapi", "pyrogram"}),
"userbot": (["userbot.runner", "userbot.handlers"], {"pyrogram"}),
"watcher": (["watcher.runner"], {"pyrogram"}),
}
PROBE = """
import importlib, sys
for module in sys.argv[1].split(","):
importlib.import_module(module)
print(" ".join(name for name in sys.argv[2:] if name in sys.modules))
"""
ENV = {**os.environ, "AUTH__TOKEN": "check"}
def loaded(modules: list[str]) -> tuple[set[str], str]:
result = subprocess.run( # noqa: S603
[sys.executable, "-c", PROBE, ",".join(modules), *HEAVY],
capture_output=True,
text=True,
cwd="src",
env=ENV,
check=False,
)
error = (result.stderr.strip().splitlines() or ["exit code"])[-1]
return set(result.stdout.split()) & set(HEAVY), error if result.returncode else ""
def main() -> int:
failed = False
for service, (modules, allowed) in SERVICES.items():
found, error = loaded(modules)
extra = sorted(found - allowed)
status = error or ("imports " + ", ".join(extra) if extra else "")
print(f"{service:10s} {'FAIL: ' + status if status else 'ok'}")
failed |= bool(status)
return int(failed)
if __name__ == "__main__":
sys.exit(main())
+23
View File
@@ -0,0 +1,23 @@
import logging
import re
QUERYLESS_PREFIXES = ("/mcp",)
_PATH_ARG = 2
_TOKEN = re.compile(r"(?<=[?&]token=)[^&]+")
class ScrubQueryFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
args = record.args
if isinstance(args, tuple) and len(args) > _PATH_ARG:
path = str(args[_PATH_ARG])
if path.startswith(QUERYLESS_PREFIXES):
path = path.partition("?")[0]
else:
path = _TOKEN.sub("***", path)
record.args = (*args[:_PATH_ARG], path, *args[_PATH_ARG + 1 :])
return True
def scrub_access_log() -> None:
logging.getLogger("uvicorn.access").addFilter(ScrubQueryFilter())
+22
View File
@@ -1,3 +1,4 @@
import asyncio
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -9,6 +10,7 @@ from fastapi.responses import FileResponse, RedirectResponse
from fastmcp.utilities.lifespan import combine_lifespans from fastmcp.utilities.lifespan import combine_lifespans
from starlette.applications import Starlette from starlette.applications import Starlette
from api.access_log import scrub_access_log
from api.auth import BearerAuthMiddleware from api.auth import BearerAuthMiddleware
from api.mcp.server import mcp from api.mcp.server import mcp
from api.realtime import hub from api.realtime import hub
@@ -19,10 +21,12 @@ from api.routers import (
avatars, avatars,
backfill, backfill,
bots, bots,
capture,
chats, chats,
custom_emoji, custom_emoji,
discover, discover,
events, events,
feed,
files, files,
folders, folders,
media, media,
@@ -33,26 +37,40 @@ from api.routers import (
search, search,
shares, shares,
social, social,
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
from utils.storage import ContentAddressedStorage
from utils.trash import purge_loop
if env.auth.token is None: if env.auth.token is None:
msg = "AUTH__TOKEN is required for the api process" msg = "AUTH__TOKEN is required for the api process"
raise RuntimeError(msg) raise RuntimeError(msg)
_token = env.auth.token.get_secret_value() _token = env.auth.token.get_secret_value()
scrub_access_log()
mcp_app = mcp.http_app(path="/") 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(
purge_loop(pool, await container.get(ContentAddressedStorage))
)
deliverer = asyncio.create_task(outbox.run(pool))
yield yield
deliverer.cancel()
purger.cancel()
await hub.stop() await hub.stop()
await app_.state.dishka_container.close() await app_.state.dishka_container.close()
@@ -80,6 +98,7 @@ app.include_router(folders.router)
app.include_router(backfill.router) app.include_router(backfill.router)
app.include_router(search.router) app.include_router(search.router)
app.include_router(chats.router) app.include_router(chats.router)
app.include_router(feed.router)
app.include_router(media.router) app.include_router(media.router)
app.include_router(avatars.router) app.include_router(avatars.router)
app.include_router(custom_emoji.router) app.include_router(custom_emoji.router)
@@ -92,8 +111,11 @@ app.include_router(peers.router)
app.include_router(discover.router) app.include_router(discover.router)
app.include_router(annotations.router) app.include_router(annotations.router)
app.include_router(watches.router) app.include_router(watches.router)
app.include_router(webhooks.router)
app.include_router(shares.router) app.include_router(shares.router)
app.include_router(storage.router)
app.include_router(files.router) app.include_router(files.router)
app.include_router(capture.router)
app.mount("/mcp", mcp_app) app.mount("/mcp", mcp_app)
+1 -1
View File
@@ -9,7 +9,7 @@ from pyrogram.errors import AuthTokenExpired, SessionPasswordNeeded
from pyrogram.qrlogin import QRLogin from pyrogram.qrlogin import QRLogin
from pyrogram.types import User from pyrogram.types import User
from userbot import PyroClient from userbot.modules.client import PyroClient
from utils.env import env from utils.env import env
LOGIN_TTL_SECONDS = 900 LOGIN_TTL_SECONDS = 900
+200 -24
View File
@@ -1,3 +1,4 @@
import re
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -20,12 +21,27 @@ 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 annotations, chats, media, peers, presence, social, watches from utils.read import (
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.search.models import SearchFilters from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
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,
@@ -56,17 +72,25 @@ Typical flows:
"📝 [your private note, NOT in Telegram]" are the user's own annotations "📝 [your private note, NOT in Telegram]" are the user's own annotations
attached locally via the web UI; they never existed in Telegram. Treat them attached locally via the web UI; they never existed in Telegram. Treat them
as private notes from the user to you, not as chat content. as private notes from the user to you, not as chat content.
- Find something: `search_messages_tool` (full-text over message text and STT - Find something: `search_messages_tool` (words by prefix over message text,
transcripts; supports chat/sender/date filters and regex). STT transcripts and earlier versions of edited messages; filters by chat,
- Forensics: `get_deleted_messages` and `get_message_versions` recover content sender, chat kind, media, deleted, edited, dates, regex).
removed or edited in Telegram but kept in the archive. - People: `get_person` gives one person in full: profile changes, shared groups,
joins and leaves, deleted message count, the private chat.
- Forensics: `get_feed` lists what was deleted or edited in Telegram across
all chats, newest first, with the text before and after each edit; a bulk
deletion is one item with `message_count`. Its `profile` kind lists name,
username and photo changes of people and renames of chats. `get_deleted_messages` and
`get_message_versions` recover the content of one chat or one message.
- Files: `download_media` gets any message attachment (photo, video, voice, - Files: `download_media` gets any message attachment (photo, video, voice,
document, ...). It pulls the file from Telegram if the archive lacks it and document, ...). It pulls the file from Telegram if the archive lacks it and
returns a public link valid 24h that needs no auth (`curl -O <link>`), plus returns a public link valid 24h that needs no auth (`curl -O <link>`), plus
the content itself for small images and text files. `get_media` returns the content itself for small images and text files. `get_media` returns
metadata only. metadata only.
- Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and - Monitoring: `set_watch` creates a local rule (kinds `keyword`,
`list_alerts` reads what those rules fired. `new_message`, `message_deleted`, `message_edited`, `peer_online`; params in
its description), `list_watches` lists rules, and `list_alerts` reads what
those rules fired, with chat, message preview and a web client `path`.
Watched bots: Watched bots:
- a bot whose token the user added is just another archived account: it has its - a bot whose token the user added is just another archived account: it has its
@@ -76,7 +100,8 @@ Watched bots:
Writes: everything is read-only except `set_watch` and the share links Writes: everything is read-only except `set_watch` and the share links
`download_media` creates (visible and revocable in the web UI). `download_media` creates (visible and revocable in the web UI).
Paging: list/search tools accept `limit` and `offset`. Paging: list tools accept `limit` and `offset`; `search_messages_tool` returns
`next_cursor` to pass back as `cursor`.
""" """
mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS) mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS)
@@ -115,27 +140,49 @@ async def search_messages_tool(
query: str | None = None, query: str | None = None,
chat_id: int | None = None, chat_id: int | None = None,
sender_id: int | None = None, sender_id: int | None = None,
kind: ChatKind | None = None,
has_media: bool | None = None, has_media: bool | None = None,
deleted: bool | None = None,
edited: bool | None = None,
date_from: datetime | None = None, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None,
regex: str | None = None, regex: str | None = None,
cursor: str | None = None,
limit: int = DEFAULT_LIMIT, limit: int = DEFAULT_LIMIT,
offset: int = 0, ) -> dict[str, Any]:
) -> list[dict[str, Any]]: """Search messages by words, newest first, including deleted ones.
"""Full-text search over message text and STT transcripts."""
Words match by prefix (3+ letters) and by stem, Russian and English.
`"exact phrase"`, `-word` and `or` work as in web search. Besides the
current text it searches voice transcripts and earlier versions of edited
messages: a hit with `source="version"` matched only an old text, given in
`version_text`. Every hit carries `deleted_at` and `edited_at`.
Filters: `chat_id`, `sender_id` (peer id), `kind` (private, group,
channel), `has_media`, `deleted` / `edited` (true for only those, false to
exclude them), `date_from` / `date_to`, `regex` over the current text. With
no `query` it lists messages that pass the filters.
Paging: pass `next_cursor` from the previous result as `cursor`; it is null
on the last page.
"""
filters = SearchFilters( filters = SearchFilters(
account_id=account_id, account_id=account_id,
query=query, query=query,
chat_id=chat_id, chat_id=chat_id,
sender_id=sender_id, sender_id=sender_id,
kind=kind,
has_media=has_media, has_media=has_media,
deleted=deleted,
edited=edited,
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
regex=regex, regex=regex,
cursor=cursor,
limit=limit, limit=limit,
offset=offset,
) )
return _dump(await search_messages(await _pool(), filters)) page = await search_messages(await _pool(), filters)
return page.model_dump(mode="json", exclude={"terms"})
@mcp.tool @mcp.tool
@@ -238,6 +285,41 @@ async def get_deleted_messages(
) )
@mcp.tool
async def get_feed(
account_id: int,
kind: FeedKind = FeedKind.ALL,
since: datetime | None = None,
chat_id: int | None = None,
chat_kind: FeedChatKind | None = None,
limit: int = DEFAULT_LIMIT,
cursor: str | None = None,
) -> dict[str, Any]:
"""Deleted and edited messages and profile changes across chats, newest first.
`kind` picks deleted, edited, profile, messages (deleted and edited) or all;
`since` keeps items at or after that time; `chat_id` or `chat_kind` (dm,
group, channel) narrow it down. Edited items carry `before` and `after`
texts. A deleted item with `message_count` above 1 is a bulk deletion in
one chat; read its messages with `get_deleted_messages` for that chat.
Profile items cover people in private chats or contacts and chats in the
dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or
`title` to `[old, new]`, changes a day apart or closer are merged. Pass
`next_cursor` back as `cursor` for the next page.
"""
if cursor is not None and re.fullmatch(CURSOR_PATTERN, cursor) is None:
msg = "cursor must be a next_cursor returned by get_feed"
raise ToolError(msg)
page = await feed.list_feed(
await _pool(),
account_id,
FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since),
limit=limit,
cursor=cursor,
)
return page.model_dump(mode="json")
@mcp.tool @mcp.tool
async def get_message_versions( async def get_message_versions(
account_id: int, chat_id: int, message_id: int account_id: int, chat_id: int, message_id: int
@@ -295,6 +377,23 @@ 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,
@@ -317,12 +416,58 @@ async def presence_history(
) )
@mcp.tool(name="presence_stats")
async def presence_stats_tool(
account_id: int,
peer_id: int,
days: int = 30,
tz: str = DEFAULT_TZ,
limit: int = DEFAULT_LIMIT,
offset: int = 0,
) -> dict[str, Any]:
"""Time a peer spent online, from status pushes turned into online sessions.
`tz` is an IANA zone name; days and hours are counted in it. `week` holds
the average online minutes for each weekday (0 is Monday) and hour, null
where the archive has no data for that hour. `days` gives online seconds
per day, `gaps` the stretches when capture was down, `sessions` the latest
sessions (online stretches with breaks under 5 minutes), paged by
`limit`/`offset`. `hidden` means the peer hides their last seen time.
"""
pool = await _pool()
if not 1 <= days <= MAX_DAYS:
msg = f"days must be between 1 and {MAX_DAYS}"
raise ToolError(msg)
if not await known_timezone(pool, tz):
msg = f"Unknown time zone: {tz}"
raise ToolError(msg)
stats = await presence_stats.presence_stats(
pool, account_id, peer_id, Page(limit=limit, offset=offset), days=days, tz=tz
)
return stats.model_dump(mode="json")
@mcp.tool @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,
@@ -361,13 +506,37 @@ async def get_annotations(
@mcp.tool @mcp.tool
async def set_watch( async def set_watch(
account_id: int, account_id: int,
kind: str, kind: WatchKind,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
name: str | None = None,
enabled: bool = True, enabled: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a local watch rule (the only local write MCP is allowed).""" """Create a local watch rule (the only local write MCP is allowed).
A watch fires an alert into the web client (live counter and list) and to
the user's webhooks. The user's own messages never fire. Kinds and params:
- `keyword`: `{"pattern": str, "regex": bool = false, "chat_id"?: int,
"sender_id"?: int}`. Case-insensitive substring, or a Python regex when
`regex` is true (at most 200 characters, no nested repeats like
`(a+)+`). Checked on new messages, on edits (the new text) and on voice
transcripts. Without `chat_id` it watches every chat.
- `new_message`: `{"chat_id"?: int, "sender_id"?: int}`, at least one: any
new message in that chat, or from that person anywhere, or both.
- `message_deleted`: `{"chat_id"?: int}`. A stored message deleted in that
chat; without `chat_id`, in any private chat.
- `message_edited`: `{"chat_id"?: int}`. Same scopes as `message_deleted`.
- `peer_online`: `{"peer_id": int}`. The person comes online after at
least 15 minutes away.
`name` is an optional human label shown in the list and in alerts.
"""
try:
clean = normalize_params(kind, params or {})
except WatchParamsError as exc:
raise ToolError(str(exc)) from exc
watch = await watches.create_watch( watch = await watches.create_watch(
await _pool(), account_id, kind, params or {}, enabled=enabled await _pool(), account_id, kind, clean, name=clean_name(name), enabled=enabled
) )
return watch.model_dump(mode="json") return watch.model_dump(mode="json")
@@ -382,12 +551,19 @@ async def list_watches(account_id: int) -> list[dict[str, Any]]:
async def list_alerts( async def list_alerts(
account_id: int, account_id: int,
seen: bool | None = None, seen: bool | None = None,
before: int | None = None,
limit: int = DEFAULT_LIMIT, limit: int = DEFAULT_LIMIT,
offset: int = 0, ) -> dict[str, Any]:
) -> list[dict[str, Any]]: """List fired alerts from watch rules, newest first.
"""List fired alerts from watch rules."""
return _dump( Each alert names its watch (id, kind, name), the event (`message`, `edit`,
await watches.list_alerts( `transcript`, `delete`, `online`), the chat, the message preview with its
await _pool(), account_id, Page(limit=limit, offset=offset), seen=seen sender and deleted/edited flags, the peer for `peer_online`, and `path`,
) the web client link. `seen=false` keeps unseen ones. Page older with
`before` set to `next_before` from the previous call; `unseen` is the
account's unseen count.
"""
page = await watches.list_alerts(
await _pool(), account_id, seen=seen, before=before, limit=limit
) )
return page.model_dump(mode="json")
+54 -19
View File
@@ -1,14 +1,19 @@
import asyncio import asyncio
import json import json
import logging import logging
from typing import Any from datetime import UTC, datetime
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__)
@@ -26,8 +31,15 @@ class EventHub:
def __init__(self) -> None: def __init__(self) -> None:
self._subscribers: set[Subscriber] = set() self._subscribers: set[Subscriber] = set()
self._pool: asyncpg.Pool | None = None self._pool: asyncpg.Pool | None = None
self._conn: asyncpg.Connection | None = None self._listener = Listener(
"realtime",
{
BG_EVENTS_CHANNEL: self._on_notify,
watches_read.ALERTS_CHANGED_CHANNEL: self._on_alert,
},
)
self._tasks: set[asyncio.Task] = set() self._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)
@@ -39,25 +51,51 @@ class EventHub:
async def start(self, pool: asyncpg.Pool) -> None: async def start(self, pool: asyncpg.Pool) -> None:
self._pool = pool self._pool = pool
conn = await asyncpg.connect(dsn=env.db.connection_url) await self._listener.start()
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()
if self._conn is not None: await self._listener.stop()
await self._conn.close()
self._conn = None
def _on_notify( def _on_notify(self, payload: str) -> None:
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)
@@ -65,16 +103,12 @@ 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")
scoped = event.get("kind") == "presence" account_wide = 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 ( and (account_wide or sub.chat_id is None or sub.chat_id == chat_id)
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
@@ -121,6 +155,7 @@ 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
+1 -1
View File
@@ -9,7 +9,7 @@ from pyrogram.errors import FloodWait, RPCError
from pyrogram.types import User from pyrogram.types import User
from api.login import LoginError, login_manager from api.login import LoginError, login_manager
from userbot import DEVICE_MODEL_LIMIT from userbot.modules.client import DEVICE_MODEL_LIMIT
from utils.read import accounts from utils.read import accounts
from utils.read.models import AccountView from utils.read.models import AccountView
+7 -2
View File
@@ -4,8 +4,10 @@ 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)
@@ -18,9 +20,12 @@ 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()] = 90, days: Annotated[int, Query(ge=1, le=MAX_DAYS)] = 90,
tz: Annotated[str, Query()] = DEFAULT_TZ,
) -> list[VolumeBucket]: ) -> list[VolumeBucket]:
return await analytics.message_volume(pool, account_id, chat_id, days=days) return await analytics.message_volume(
pool, account_id, chat_id, days=days, tz=await checked_tz(pool, tz)
)
@router.get("/response-time") @router.get("/response-time")
+21 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.read import annotations from utils.read import annotations
from utils.read.models import DEFAULT_LIMIT, AnnotationView, Page from utils.read.models import DEFAULT_LIMIT, AnnotationView, NoteTag, NoteView, Page
router = APIRouter( router = APIRouter(
prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute
@@ -42,6 +42,26 @@ async def list_annotations(
) )
@router.get("/notes")
async def list_notes(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
tag: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[NoteView]:
return await annotations.list_notes(
pool, account_id, Page(limit=limit, offset=offset), tag=tag
)
@router.get("/tags")
async def list_tags(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[NoteTag]:
return await annotations.list_tags(pool, account_id)
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_annotation( async def create_annotation(
pool: FromDishka[asyncpg.Pool], body: AnnotationCreate pool: FromDishka[asyncpg.Pool], body: AnnotationCreate
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from utils.read import capture
from utils.read.models import CaptureGap, CaptureStatus
router = APIRouter(prefix="/api/capture", tags=["capture"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
@router.get("/status")
async def capture_status(
pool: FromDishka[asyncpg.Pool], account_id: AccountId
) -> CaptureStatus:
return await capture.capture_status(pool, account_id)
@router.get("/gaps")
async def capture_gaps(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
) -> list[CaptureGap]:
return await capture.capture_gaps(
pool, account_id, date_from=date_from, date_to=date_to
)
+70
View File
@@ -0,0 +1,70 @@
from datetime import datetime
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from utils.read import feed
from utils.read.feed import CURSOR_PATTERN, FeedChatKind, FeedFilter, FeedKind
from utils.read.models import DEFAULT_LIMIT, FeedMessagesPage, FeedPage, FeedUnseen
router = APIRouter(prefix="/api/feed", tags=["feed"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
Limit = Annotated[int, Query(ge=1)]
Cursor = Annotated[str | None, Query(pattern=CURSOR_PATTERN)]
class SeenRequest(BaseModel):
account_id: int
at: datetime | None = None
@router.get("")
async def list_feed(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
kind: Annotated[FeedKind, Query()] = FeedKind.ALL,
chat_id: Annotated[int | None, Query()] = None,
chat_kind: Annotated[FeedChatKind | None, Query()] = None,
since: Annotated[datetime | None, Query()] = None,
cursor: Cursor = None,
limit: Limit = DEFAULT_LIMIT,
) -> FeedPage:
return await feed.list_feed(
pool,
account_id,
FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since),
limit=limit,
cursor=cursor,
)
@router.get("/unseen")
async def feed_unseen(
pool: FromDishka[asyncpg.Pool], account_id: AccountId
) -> FeedUnseen:
return await feed.unseen(pool, account_id)
@router.post("/seen")
async def feed_seen(pool: FromDishka[asyncpg.Pool], body: SeenRequest) -> FeedUnseen:
return await feed.mark_seen(pool, body.account_id, body.at)
@router.get("/{item_id}/messages")
async def feed_item_messages(
pool: FromDishka[asyncpg.Pool],
item_id: int,
account_id: AccountId,
cursor: Cursor = None,
limit: Limit = DEFAULT_LIMIT,
) -> FeedMessagesPage:
page = await feed.item_messages(
pool, account_id, item_id, limit=limit, cursor=cursor
)
if page is None:
raise HTTPException(status_code=404, detail="Feed item not found")
return page
+3 -1
View File
@@ -54,7 +54,7 @@ async def serve_media_version(
download: Download = False, download: Download = False,
) -> FileResponse: ) -> FileResponse:
version = await get_media_version(pool, version_id) version = await get_media_version(pool, version_id)
if version is None: if version is None or not storage.exists(version.storage_key):
raise HTTPException(status_code=404, detail="media version not found") raise HTTPException(status_code=404, detail="media version not found")
headers = IMMUTABLE_HEADERS headers = IMMUTABLE_HEADERS
if download: if download:
@@ -96,6 +96,8 @@ async def serve_media(
status_code=409, status_code=409,
detail="media not downloaded; enqueue fetch via POST /api/media/fetch", detail="media not downloaded; enqueue fetch via POST /api/media/fetch",
) )
if not storage.exists(media.storage_key):
raise HTTPException(status_code=404, detail="media file is missing")
headers = DAY_HEADERS headers = DAY_HEADERS
if download: if download:
headers = _attachment(headers, media.file_name or f"media_{media_id}") headers = _attachment(headers, media.file_name or f"media_{media_id}")
+29 -2
View File
@@ -4,8 +4,14 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from utils.read import peers from utils.read import peers, people
from utils.read.models import PeerHistoryView, PeerView from utils.read.models import (
PeerGroups,
PeerHistoryView,
PeerOverview,
PeerView,
ProfileChange,
)
router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute) router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute)
@@ -35,3 +41,24 @@ async def peer_history(
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
) -> list[PeerHistoryView]: ) -> list[PeerHistoryView]:
return await peers.get_peer_history(pool, account_id, peer_id) return await peers.get_peer_history(pool, account_id, peer_id)
@router.get("/peers/{peer_id}/overview")
async def peer_overview(
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
) -> PeerOverview:
return await people.get_overview(pool, account_id, peer_id)
@router.get("/peers/{peer_id}/groups")
async def peer_groups(
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
) -> PeerGroups:
return await people.get_groups(pool, account_id, peer_id)
@router.get("/chats/{chat_id}/changes")
async def chat_changes(
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
) -> list[ProfileChange]:
return await people.get_chat_changes(pool, account_id, chat_id)
+29 -1
View File
@@ -5,13 +5,15 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.policy import repository from utils.policy import overview, repository
from utils.policy.models import ( from utils.policy.models import (
CaptureToggles, CaptureToggles,
ChatKind, ChatKind,
ChatMeta, ChatMeta,
PolicyCreate, PolicyCreate,
PolicyRecord, PolicyRecord,
PolicyRuleView,
PolicyScopeView,
) )
from utils.policy.resolver import resolve from utils.policy.resolver import resolve
@@ -50,6 +52,32 @@ async def effective_policy(
return resolve(chat, folders, policies) return resolve(chat, folders, policies)
@router.get("/overview")
async def policy_overview(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[PolicyRuleView]:
return await overview.list_rules(pool, account_id)
@router.get("/scope")
async def policy_scope(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
chat_id: Annotated[int | None, Query()] = None,
folder_id: Annotated[int | None, Query()] = None,
) -> PolicyScopeView:
if chat_id is not None:
return await overview.chat_scope(pool, account_id, chat_id)
scope = (
await overview.folder_scope(pool, account_id, folder_id)
if folder_id is not None
else None
)
if scope is None:
raise HTTPException(status_code=404, detail="scope not found")
return scope
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_policy( async def create_policy(
pool: FromDishka[asyncpg.Pool], body: PolicyCreate pool: FromDishka[asyncpg.Pool], body: PolicyCreate
+64 -2
View File
@@ -5,8 +5,18 @@ 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.read import presence from api.timezone import checked_tz
from utils.read.models import DEFAULT_LIMIT, Page, PresenceHourly, PresenceSample from utils.read import presence, presence_stats
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)
@@ -14,6 +24,8 @@ 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("")
@@ -43,6 +55,16 @@ async def current_presence(
return await presence.current_presence(pool, account_id, peer_id) return await presence.current_presence(pool, account_id, peer_id)
@router.get("/online")
async def online_now(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_ids: Annotated[str, Query()],
) -> list[PresenceSample]:
parsed = [int(part) for part in peer_ids.split(",") if part.strip()]
return await presence.online_now(pool, account_id, parsed[:MAX_LIMIT])
@router.get("/hourly") @router.get("/hourly")
async def presence_hourly( async def presence_hourly(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
@@ -54,3 +76,43 @@ 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),
)
+2 -2
View File
@@ -4,7 +4,7 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from utils.search.models import SearchFilters, SearchHit from utils.search.models import SearchFilters, SearchPage
from utils.search.repository import search_messages from utils.search.repository import search_messages
router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRoute) router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRoute)
@@ -13,5 +13,5 @@ router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRout
@router.get("") @router.get("")
async def search( async def search(
pool: FromDishka[asyncpg.Pool], filters: Annotated[SearchFilters, Query()] pool: FromDishka[asyncpg.Pool], filters: Annotated[SearchFilters, Query()]
) -> list[SearchHit]: ) -> SearchPage:
return await search_messages(pool, filters) return await search_messages(pool, filters)
+35 -2
View File
@@ -2,10 +2,13 @@ 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, Query from fastapi import APIRouter, HTTPException, 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 from utils.read.models import CallbackView, LinkView, ReactionView, ReadInfoView
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}",
@@ -16,6 +19,10 @@ 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
@@ -35,3 +42,29 @@ 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}
+166
View File
@@ -0,0 +1,166 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from utils import trash
from utils.cache import NO_STORE_HEADERS
from utils.files import resolve_mime
from utils.read import storage as storage_read
from utils.read.models import (
DEFAULT_LIMIT,
Page,
StorageChatView,
StorageFileView,
StorageScopeView,
StorageSummaryView,
TrashItemView,
TrashOutcome,
)
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/storage", tags=["storage"], route_class=DishkaRoute)
class TrashRequest(BaseModel):
account_id: int
media_ids: list[int]
class RestoreRequest(BaseModel):
account_id: int
keys: list[str] | None = None
class ScopeRequest(BaseModel):
account_id: int
chat_id: int | None = None
folder_id: int | None = None
class PurgeRequest(BaseModel):
account_id: int
keys: list[str] | None = None
@router.get("/summary")
async def summary(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
chat_id: Annotated[int | None, Query()] = None,
) -> StorageSummaryView:
return await storage_read.get_summary(pool, account_id, chat_id)
@router.get("/files")
async def files(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
group: Annotated[str | None, Query()] = None,
chat_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StorageFileView]:
page = Page(limit=limit, offset=offset)
return await storage_read.list_files(pool, account_id, group, page, chat_id)
@router.get("/chats")
async def chats(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
group: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StorageChatView]:
page = Page(limit=limit, offset=offset)
return await storage_read.list_chats(pool, account_id, group, page)
async def _chat_ids(
pool: asyncpg.Pool, account_id: int, chat_id: int | None, folder_id: int | None
) -> list[int]:
chat_ids = await storage_read.scope_chat_ids(pool, account_id, chat_id, folder_id)
if chat_ids is None:
raise HTTPException(status_code=404, detail="scope not found")
return chat_ids
@router.get("/scope")
async def scope(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
chat_id: Annotated[int | None, Query()] = None,
folder_id: Annotated[int | None, Query()] = None,
) -> StorageScopeView:
chat_ids = await _chat_ids(pool, account_id, chat_id, folder_id)
return await storage_read.get_scope(pool, account_id, chat_ids)
@router.get("/trash")
async def trash_items(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[TrashItemView]:
page = Page(limit=limit, offset=offset)
return await storage_read.list_trash(pool, account_id, page)
@router.post("/trash/scope")
async def trash_scope(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: ScopeRequest,
) -> TrashOutcome:
chat_ids = await _chat_ids(pool, body.account_id, body.chat_id, body.folder_id)
blobs = await pool.fetch(storage_read.SCOPE_BLOBS, body.account_id, chat_ids)
free = [blob for blob in blobs if blob["free"]]
return await trash.trash_blobs(pool, storage, body.account_id, free)
@router.post("/trash")
async def move_to_trash(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: TrashRequest,
) -> TrashOutcome:
return await trash.move_to_trash(pool, storage, body.account_id, body.media_ids)
@router.post("/trash/restore")
async def restore(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: RestoreRequest,
) -> TrashOutcome:
return await trash.restore(pool, storage, body.account_id, body.keys)
@router.post("/trash/purge")
async def purge(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: PurgeRequest,
) -> TrashOutcome:
return await trash.purge(pool, storage, body.account_id, body.keys)
@router.get("/trash/{storage_key}/file")
async def serve_trashed(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
storage_key: str,
) -> FileResponse:
item = await storage_read.get_trash_item(pool, storage_key)
if item is None or not storage.trashed(storage_key):
raise HTTPException(status_code=404, detail="file is not in trash")
return FileResponse(
storage.trash_url(storage_key),
media_type=resolve_mime(item["kind"], item["mime"], item["file_name"]),
headers=NO_STORE_HEADERS,
)
+19 -2
View File
@@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from utils.files import content_disposition, story_file_name from utils.files import content_disposition, sniff_media_kind, story_file_name
from utils.read import peers from utils.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, StoryView from utils.read.models import DEFAULT_LIMIT, Page, StoryView
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
@@ -17,17 +17,34 @@ AccountId = Annotated[int, Query()]
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"} _STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
async def _with_kind(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
story: StoryView,
) -> StoryView:
if story.media_kind or not story.storage_key:
return story
if not storage.exists(story.storage_key):
return story
kind = sniff_media_kind(storage.head(story.storage_key))
await peers.set_story_kind(pool, account_id, story.peer_id, story.story_id, kind)
return story.model_copy(update={"media_kind": kind})
@router.get("/stories") @router.get("/stories")
async def list_stories( async def list_stories(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
account_id: AccountId, account_id: AccountId,
peer_id: Annotated[int | None, Query()] = None, peer_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT, limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0, offset: Annotated[int, Query()] = 0,
) -> list[StoryView]: ) -> list[StoryView]:
return await peers.get_stories( stories = await peers.get_stories(
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
) )
return [await _with_kind(pool, storage, account_id, story) for story in stories]
@router.get("/stories/{peer_id}/{story_id}/media") @router.get("/stories/{peer_id}/{story_id}/media")
+69 -21
View File
@@ -6,55 +6,88 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.read import watches from utils.read import watches
from utils.read.models import DEFAULT_LIMIT, AlertView, Page, WatchView from utils.read.models import AlertPage, WatchItem, WatchView
from utils.watch_rules import WatchParamsError, clean_name, normalize_params
router = APIRouter(prefix="/api", tags=["watches"], route_class=DishkaRoute) router = APIRouter(prefix="/api", tags=["watches"], route_class=DishkaRoute)
class WatchCreate(BaseModel): class WatchUpdate(BaseModel):
kind: str | None = None
params: dict[str, Any] = {}
name: str | None = None
enabled: bool = True
class WatchCreate(WatchUpdate):
account_id: int account_id: int
kind: str kind: str
params: dict[str, Any] = {}
enabled: bool = True
class WatchUpdate(BaseModel): class SeenAll(BaseModel):
params: dict[str, Any] = {} account_id: int
enabled: bool = True up_to: int | None = None
def _params(kind: str, params: dict[str, Any]) -> dict[str, Any]:
try:
return normalize_params(kind, params)
except WatchParamsError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
async def _item(pool: asyncpg.Pool, watch: WatchView) -> WatchItem:
return (await watches.describe_watches(pool, watch.account_id, [watch]))[0]
@router.get("/watches") @router.get("/watches")
async def list_watches( async def list_watches(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()] pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[WatchView]: ) -> list[WatchItem]:
return await watches.list_watches(pool, account_id) rows = await watches.list_watches(pool, account_id)
return await watches.describe_watches(pool, account_id, rows)
@router.post("/watches", status_code=201) @router.post("/watches", status_code=201)
async def create_watch(pool: FromDishka[asyncpg.Pool], body: WatchCreate) -> WatchView: async def create_watch(pool: FromDishka[asyncpg.Pool], body: WatchCreate) -> WatchItem:
return await watches.create_watch( watch = await watches.create_watch(
pool, body.account_id, body.kind, body.params, enabled=body.enabled pool,
body.account_id,
body.kind,
_params(body.kind, body.params),
name=clean_name(body.name),
enabled=body.enabled,
) )
return await _item(pool, watch)
@router.get("/watches/{watch_id}") @router.get("/watches/{watch_id}")
async def get_watch(pool: FromDishka[asyncpg.Pool], watch_id: int) -> WatchView: async def get_watch(pool: FromDishka[asyncpg.Pool], watch_id: int) -> WatchItem:
watch = await watches.get_watch(pool, watch_id) watch = await watches.get_watch(pool, watch_id)
if watch is None: if watch is None:
raise HTTPException(status_code=404, detail="watch not found") raise HTTPException(status_code=404, detail="watch not found")
return watch return await _item(pool, watch)
@router.put("/watches/{watch_id}") @router.put("/watches/{watch_id}")
async def update_watch( async def update_watch(
pool: FromDishka[asyncpg.Pool], watch_id: int, body: WatchUpdate pool: FromDishka[asyncpg.Pool], watch_id: int, body: WatchUpdate
) -> WatchView: ) -> WatchItem:
current = await watches.get_watch(pool, watch_id)
if current is None:
raise HTTPException(status_code=404, detail="watch not found")
kind = body.kind or current.kind
watch = await watches.update_watch( watch = await watches.update_watch(
pool, watch_id, body.params, enabled=body.enabled pool,
watch_id,
kind,
_params(kind, body.params),
name=clean_name(body.name),
enabled=body.enabled,
) )
if watch is None: if watch is None:
raise HTTPException(status_code=404, detail="watch not found") raise HTTPException(status_code=404, detail="watch not found")
return watch return await _item(pool, watch)
@router.delete("/watches/{watch_id}", status_code=204) @router.delete("/watches/{watch_id}", status_code=204)
@@ -68,14 +101,29 @@ async def list_alerts(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()], account_id: Annotated[int, Query()],
seen: Annotated[bool | None, Query()] = None, seen: Annotated[bool | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT, before: Annotated[int | None, Query()] = None,
offset: Annotated[int, Query()] = 0, limit: Annotated[int, Query()] = watches.ALERT_PAGE,
) -> list[AlertView]: ) -> AlertPage:
return await watches.list_alerts( return await watches.list_alerts(
pool, account_id, Page(limit=limit, offset=offset), seen=seen pool, account_id, seen=seen, before=before, limit=limit
) )
@router.get("/alerts/unseen")
async def unseen_alerts(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> dict[str, int]:
return {"unseen": await watches.unseen_count(pool, account_id)}
@router.post("/alerts/seen")
async def mark_all_seen(
pool: FromDishka[asyncpg.Pool], body: SeenAll
) -> dict[str, int]:
marked = await watches.mark_all_seen(pool, body.account_id, body.up_to)
return {"marked": marked}
@router.post("/alerts/{alert_id}/seen", status_code=204) @router.post("/alerts/{alert_id}/seen", status_code=204)
async def mark_alert_seen(pool: FromDishka[asyncpg.Pool], alert_id: int) -> None: async def mark_alert_seen(pool: FromDishka[asyncpg.Pool], alert_id: int) -> None:
if not await watches.mark_alert_seen(pool, alert_id): if not await watches.mark_alert_seen(pool, alert_id):
+121
View File
@@ -0,0 +1,121 @@
from typing import Annotated
from urllib.parse import urlsplit
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from api.webhooks import send_test
from utils.read import webhooks
from utils.read.models import DeliveryView, WebhookView
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"], route_class=DishkaRoute)
MAX_URL = 2048
class WebhookUpdate(BaseModel):
url: str
watch_ids: list[int] | None = None
enabled: bool = True
class WebhookCreate(WebhookUpdate):
account_id: int
def _url(value: str) -> str:
url = value.strip()
parts = urlsplit(url)
if (
len(url) > MAX_URL
or parts.scheme not in {"http", "https"}
or not parts.hostname
):
raise HTTPException(
status_code=422, detail="url must be an http or https address"
)
return url
def _watch_ids(ids: list[int] | None) -> list[int] | None:
return sorted(set(ids)) if ids else None
async def _webhook(pool: asyncpg.Pool, webhook_id: int) -> WebhookView:
webhook = await webhooks.get_webhook(pool, webhook_id)
if webhook is None:
raise HTTPException(status_code=404, detail="webhook not found")
return webhook
@router.get("")
async def list_webhooks(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[WebhookView]:
return await webhooks.list_webhooks(pool, account_id)
@router.post("", status_code=201)
async def create_webhook(
pool: FromDishka[asyncpg.Pool], body: WebhookCreate
) -> WebhookView:
webhook = await webhooks.create_webhook(
pool,
body.account_id,
_url(body.url),
_watch_ids(body.watch_ids),
enabled=body.enabled,
)
if webhook is None:
raise HTTPException(status_code=500, detail="webhook was not saved")
return webhook
@router.put("/{webhook_id}")
async def update_webhook(
pool: FromDishka[asyncpg.Pool], webhook_id: int, body: WebhookUpdate
) -> WebhookView:
webhook = await webhooks.update_webhook(
pool,
webhook_id,
_url(body.url),
_watch_ids(body.watch_ids),
enabled=body.enabled,
)
if webhook is None:
raise HTTPException(status_code=404, detail="webhook not found")
return webhook
@router.delete("/{webhook_id}", status_code=204)
async def delete_webhook(pool: FromDishka[asyncpg.Pool], webhook_id: int) -> None:
if not await webhooks.delete_webhook(pool, webhook_id):
raise HTTPException(status_code=404, detail="webhook not found")
@router.post("/{webhook_id}/secret")
async def rotate_secret(pool: FromDishka[asyncpg.Pool], webhook_id: int) -> WebhookView:
webhook = await webhooks.rotate_secret(pool, webhook_id)
if webhook is None:
raise HTTPException(status_code=404, detail="webhook not found")
return webhook
@router.post("/{webhook_id}/test")
async def test_webhook(pool: FromDishka[asyncpg.Pool], webhook_id: int) -> DeliveryView:
delivery = await send_test(pool, await _webhook(pool, webhook_id))
if delivery is None:
raise HTTPException(status_code=500, detail="test delivery was not recorded")
return delivery
@router.get("/{webhook_id}/deliveries")
async def list_deliveries(
pool: FromDishka[asyncpg.Pool],
webhook_id: int,
limit: Annotated[int, Query(ge=1, le=100)] = webhooks.DELIVERY_LOG,
) -> list[DeliveryView]:
await _webhook(pool, webhook_id)
return await webhooks.list_deliveries(pool, webhook_id, limit)
+10
View File
@@ -0,0 +1,10 @@
import asyncpg
from fastapi import HTTPException
from utils.read.period import known_timezone
async def checked_tz(pool: asyncpg.Pool, tz: str) -> str:
if not await known_timezone(pool, tz):
raise HTTPException(status_code=422, detail=f"Unknown time zone: {tz}")
return tz
+191
View File
@@ -0,0 +1,191 @@
import asyncio
import contextlib
import hashlib
import hmac
import json
import logging
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
import asyncpg
import httpx2
from utils.env import env
from utils.read import watches
from utils.read import webhooks as webhooks_read
from utils.read.models import AlertItem, DeliveryView, WebhookView
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 10
SCAN_SECONDS = 30
BATCH = 20
RETRY_DELAYS = (30, 90, 270, 810, 2430)
ERROR_CHARS = 300
USER_AGENT = "beavergram-webhooks"
def sign(secret: str, body: bytes) -> str:
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
def _encode(body: dict[str, Any]) -> str:
return json.dumps(body, ensure_ascii=False, separators=(",", ":"))
def _with_url(body: dict[str, Any], path: str) -> dict[str, Any]:
if env.api.public_url:
body["url"] = env.api.public_url.rstrip("/") + path
return body
def alert_body(item: AlertItem) -> dict[str, Any]:
data = item.model_dump(mode="json", exclude={"id", "seen"})
return _with_url({"type": "alert", "alert_id": item.id, **data}, item.path)
def test_body(webhook: WebhookView) -> dict[str, Any]:
body = {
"type": "test",
"alert_id": None,
"account_id": webhook.account_id,
"ts": datetime.now(UTC).isoformat(),
"event": "test",
"watch": None,
"chat": None,
"message": None,
"peer": None,
"path": "/app",
}
return _with_url(body, "/app")
@dataclass(frozen=True, slots=True)
class Target:
delivery_id: int
event: str
url: str
secret: str
async def post(
client: httpx2.AsyncClient, target: Target, body: str
) -> tuple[int | None, str | None]:
raw = body.encode()
headers = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"X-Beavergram-Event": target.event,
"X-Beavergram-Delivery": str(target.delivery_id),
"X-Beavergram-Signature": sign(target.secret, raw),
}
try:
response = await client.post(target.url, content=raw, headers=headers)
except httpx2.HTTPError as exc:
return None, (str(exc) or type(exc).__name__)[:ERROR_CHARS]
if response.is_success:
return response.status_code, None
reason = response.text.strip()[:ERROR_CHARS] or response.reason_phrase
return response.status_code, reason or f"HTTP {response.status_code}"
def new_client() -> httpx2.AsyncClient:
return httpx2.AsyncClient(timeout=TIMEOUT_SECONDS, follow_redirects=False)
async def _body(pool: asyncpg.Pool, row: asyncpg.Record) -> str | None:
if row["body"] is not None:
return row["body"]
if row["alert_id"] is None:
return None
item = await watches.get_alert(pool, row["alert_id"])
if item is None:
return None
body = _encode(alert_body(item))
await webhooks_read.save_body(pool, row["id"], body)
return body
async def _deliver(
pool: asyncpg.Pool, client: httpx2.AsyncClient, row: asyncpg.Record
) -> None:
body = await _body(pool, row)
if body is None:
await webhooks_read.finish(
pool, row["id"], webhooks_read.STATUS_FAILED, None, "alert is gone"
)
return
target = Target(row["id"], row["event"], row["url"], row["secret"])
code, error = await post(client, target, body)
if error is None:
await webhooks_read.finish(
pool, row["id"], webhooks_read.STATUS_DELIVERED, code, None
)
return
attempt = row["attempts"]
if attempt > len(RETRY_DELAYS):
await webhooks_read.finish(
pool, row["id"], webhooks_read.STATUS_FAILED, code, error
)
return
retry_at = datetime.now(UTC) + timedelta(seconds=RETRY_DELAYS[attempt - 1])
await webhooks_read.finish(
pool, row["id"], webhooks_read.STATUS_PENDING, code, error, retry_at
)
async def send_test(pool: asyncpg.Pool, webhook: WebhookView) -> DeliveryView | None:
body = _encode(test_body(webhook))
delivery_id = await webhooks_read.start_test(pool, webhook.id, body)
async with new_client() as client:
target = Target(delivery_id, "test", webhook.url, webhook.secret)
code, error = await post(client, target, body)
status = webhooks_read.STATUS_FAILED if error else webhooks_read.STATUS_DELIVERED
await webhooks_read.finish(pool, delivery_id, status, code, error)
return await webhooks_read.get_delivery(pool, delivery_id)
class Outbox:
def __init__(self) -> None:
self._wake = asyncio.Event()
def wake(self) -> None:
self._wake.set()
async def run(self, pool: asyncpg.Pool) -> None:
released = False
async with new_client() as client:
while True:
self._wake.clear()
try:
if not released:
await webhooks_read.release_stuck(pool)
released = True
await self._drain(pool, client)
delay = await self._delay(pool)
except Exception:
logger.exception("Webhook delivery failed")
delay = SCAN_SECONDS
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._wake.wait(), timeout=delay)
async def _drain(self, pool: asyncpg.Pool, client: httpx2.AsyncClient) -> None:
while rows := await webhooks_read.claim_due(pool, BATCH):
results = await asyncio.gather(
*(_deliver(pool, client, row) for row in rows), return_exceptions=True
)
for result in results:
if isinstance(result, Exception):
logger.error("Webhook delivery crashed: %r", result)
async def _delay(self, pool: asyncpg.Pool) -> float:
due = await webhooks_read.next_due(pool)
if due is None:
return SCAN_SECONDS
wait = (due - datetime.now(UTC)).total_seconds()
return min(SCAN_SECONDS, max(wait, 1.0))
outbox = Outbox()
-3
View File
@@ -1,3 +0,0 @@
from userbot.modules.client import DEVICE_MODEL, DEVICE_MODEL_LIMIT, PyroClient
__all__ = ["DEVICE_MODEL", "DEVICE_MODEL_LIMIT", "PyroClient"]
+7 -2
View File
@@ -1,8 +1,8 @@
from pyrogram.types import Message from pyrogram.types import Message
from userbot import PyroClient
from userbot.modules.capture import repository from userbot.modules.capture import repository
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
from userbot.modules.client import PyroClient
from utils.events import notify_bg_event from utils.events import notify_bg_event
@@ -20,14 +20,19 @@ 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
) )
handlers = on_deleted_messages.handlers handlers = on_deleted_messages.handlers # ty: ignore[unresolved-attribute]
+8 -18
View File
@@ -1,11 +1,11 @@
from pyrogram.types import Message from pyrogram.types import Message
from userbot import PyroClient
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 sender_id from userbot.modules.capture.message import message_row
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl from userbot.modules.client import PyroClient
from userbot.modules.raw_json import raw_json from userbot.modules.media import capture_media
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event from utils.events import notify_bg_event
@@ -23,22 +23,12 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
chat = message.chat chat = message.chat
chat_id = chat.id or 0 chat_id = chat.id or 0
meta = meta_from_chat(chat, ctx.contacts.ids) meta = meta_from_chat(chat, ctx.contacts.ids)
await ctx.watches.on_edit(seen_message(chat_id, message))
toggles = ctx.resolve(meta) toggles = ctx.resolve(meta)
if not toggles.track_edits_deletes: if not (toggles.messages and toggles.track_edits_deletes):
return return
changed = await repository.add_version( changed = await repository.add_version(
ctx.pool, ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
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
@@ -49,4 +39,4 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
) )
handlers = on_edited_message.handlers handlers = on_edited_message.handlers # ty: ignore[unresolved-attribute]
+4 -3
View File
@@ -1,10 +1,11 @@
from pyrogram.types import Message from pyrogram.types import Message
from userbot import PyroClient
from userbot.modules.capture import capture_message 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.stt import is_transcribable from userbot.modules.stt import is_transcribable
from userbot.modules.stt.gate import safe_transcribe from userbot.modules.stt.gate import safe_transcribe
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event from utils.events import notify_bg_event
@@ -20,7 +21,7 @@ async def on_message(client: PyroClient, message: Message) -> None:
): ):
return return
meta = meta_from_chat(message.chat, ctx.contacts.ids) meta = meta_from_chat(message.chat, ctx.contacts.ids)
await ctx.watches.on_text(meta.chat_id, message.id, message.text or message.caption) await ctx.watches.on_message(seen_message(meta.chat_id, message))
toggles = ctx.resolve(meta) toggles = ctx.resolve(meta)
if not toggles.messages: if not toggles.messages:
return return
@@ -36,4 +37,4 @@ async def on_message(client: PyroClient, message: Message) -> None:
await safe_transcribe(client, ctx, meta.chat_id, message.id) await safe_transcribe(client, ctx, meta.chat_id, message.id)
handlers = on_message.handlers handlers = on_message.handlers # ty: ignore[unresolved-attribute]
+8 -5
View File
@@ -2,7 +2,7 @@ from datetime import UTC, datetime
from pyrogram.types import User from pyrogram.types import User
from userbot import PyroClient from userbot.modules.client import PyroClient
from userbot.modules.presence import repository from userbot.modules.presence import repository
from userbot.modules.raw_json import raw_json from userbot.modules.raw_json import raw_json
from utils.events import notify_bg_event from utils.events import notify_bg_event
@@ -13,18 +13,21 @@ async def on_user_status(client: PyroClient, user: User) -> None:
ctx = client.capture ctx = client.capture
if ctx is None or user.status is None: if ctx is None or user.status is None:
return return
at = datetime.now(UTC)
status = user.status.name.lower()
if ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).presence:
await repository.insert_status( await repository.insert_status(
ctx.pool, ctx.pool,
ctx.account_id, ctx.account_id,
user.id, user.id,
datetime.now(UTC), at,
user.status.name.lower(), status,
user.last_online_date, user.last_online_date,
user.next_offline_date, user.next_offline_date,
raw_json(user.raw), raw_json(user.raw),
) )
await ctx.watches.on_status(user.id, is_online=user.status.name.lower() == "online")
await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id) 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 handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
+1 -1
View File
@@ -1,6 +1,6 @@
from pyrogram import raw from pyrogram import raw
from userbot import PyroClient from userbot.modules.client import PyroClient
HANDLES = (raw.types.UpdateContactsReset, raw.types.UpdateUser) HANDLES = (raw.types.UpdateContactsReset, raw.types.UpdateUser)
@@ -1,6 +1,6 @@
from pyrogram import raw from pyrogram import raw
from userbot import PyroClient from userbot.modules.client import PyroClient
HANDLES = ( HANDLES = (
raw.types.UpdateDialogFilter, raw.types.UpdateDialogFilter,
@@ -2,7 +2,6 @@ from collections.abc import Awaitable, Callable
from pyrogram import raw from pyrogram import raw
from userbot import PyroClient
from userbot.handlers.raw import ( from userbot.handlers.raw import (
contacts, contacts,
dialog_filters, dialog_filters,
@@ -12,6 +11,7 @@ from userbot.handlers.raw import (
read_receipts, read_receipts,
transcribed, transcribed,
) )
from userbot.modules.client import PyroClient
RawHandler = Callable[..., Awaitable[None]] RawHandler = Callable[..., Awaitable[None]]
@@ -24,8 +24,9 @@ _MODULES = (
read_receipts, read_receipts,
transcribed, transcribed,
) )
_REGISTRY: dict[type, RawHandler] = { _REGISTRY: dict[type, list[RawHandler]] = {
update_type: module.handle for module in _MODULES for update_type in module.HANDLES update_type: [module.handle for module in _MODULES if update_type in module.HANDLES]
for update_type in {kind for module in _MODULES for kind in module.HANDLES}
} }
@@ -33,9 +34,8 @@ _REGISTRY: dict[type, RawHandler] = {
async def dispatch( async def dispatch(
client: PyroClient, update: raw.base.Update, users: dict, chats: dict client: PyroClient, update: raw.base.Update, users: dict, chats: dict
) -> None: ) -> None:
handler = _REGISTRY.get(type(update)) for handler in _REGISTRY.get(type(update), ()):
if handler is not None:
await handler(client, update, users, chats) await handler(client, update, users, chats)
handlers = dispatch.handlers handlers = dispatch.handlers # ty: ignore[unresolved-attribute]
+17 -4
View File
@@ -2,8 +2,8 @@ from dataclasses import replace
from pyrogram import raw from pyrogram import raw
from userbot import PyroClient
from userbot.modules.avatars import capture_avatar from userbot.modules.avatars import capture_avatar
from userbot.modules.client import PyroClient
from userbot.modules.profiles import active_username, snapshot_from_user from userbot.modules.profiles import active_username, snapshot_from_user
from userbot.modules.profiles.parse import ProfileFields from userbot.modules.profiles.parse import ProfileFields
from userbot.modules.profiles.repository import get_peer, write_profile from userbot.modules.profiles.repository import get_peer, write_profile
@@ -21,12 +21,18 @@ async def _handle_user(
raw_user = users.get(update.user_id) raw_user = users.get(update.user_id)
if raw_user is None: if raw_user is None:
return return
fields, photo_file_id, photo_unique_id = snapshot_from_user(client, raw_user) fields, photo_file_id, photo_unique_id = await snapshot_from_user(client, raw_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.account_id, update.user_id, fields, raw_json(raw_user) ctx.pool,
ctx.account_id,
update.user_id,
fields,
raw_json(raw_user),
history=ctx.resolve_peer(update.user_id, is_bot=is_bot).profile_history,
) )
changed_photo = current is None or current.photo_unique_id != photo_unique_id 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:
@@ -51,7 +57,14 @@ 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(ctx.pool, ctx.account_id, peer_id, merged, raw_str) await write_profile(
ctx.pool,
ctx.account_id,
peer_id,
merged,
raw_str,
history=ctx.resolve_peer(peer_id).profile_history,
)
async def handle( async def handle(
@@ -1,8 +1,8 @@
from pyrogram import raw, utils from pyrogram import raw, utils
from userbot import PyroClient
from userbot.modules.capture import repository from userbot.modules.capture import repository
from userbot.modules.capture.chat_meta import meta_from_peer from userbot.modules.capture.chat_meta import meta_from_peer
from userbot.modules.client import PyroClient
from utils.events import notify_bg_event from utils.events import notify_bg_event
HANDLES = (raw.types.UpdateMessageReactions,) HANDLES = (raw.types.UpdateMessageReactions,)
@@ -2,8 +2,8 @@ from datetime import UTC, datetime
from pyrogram import raw, utils from pyrogram import raw, utils
from userbot import PyroClient
from userbot.modules.capture.chat_meta import meta_from_chat_id from userbot.modules.capture.chat_meta import meta_from_chat_id
from userbot.modules.client import PyroClient
from userbot.modules.raw_json import raw_json from userbot.modules.raw_json import raw_json
from userbot.modules.read_receipts import repository as receipts from userbot.modules.read_receipts import repository as receipts
from userbot.modules.stt import repository from userbot.modules.stt import repository
@@ -2,30 +2,40 @@ from datetime import UTC, datetime
from pyrogram import raw, utils from pyrogram import raw, utils
from userbot import PyroClient from userbot.modules.client import PyroClient
from userbot.modules.raw_json import raw_json 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,) HANDLES = (raw.types.UpdateReadHistoryOutbox, raw.types.UpdateReadChannelOutbox)
def _watermark(update: raw.base.Update) -> tuple[int, int] | None:
if isinstance(update, raw.types.UpdateReadHistoryOutbox):
return utils.get_peer_id(update.peer), update.max_id
if isinstance(update, raw.types.UpdateReadChannelOutbox):
peer = raw.types.PeerChannel(channel_id=update.channel_id)
return utils.get_peer_id(peer), update.max_id
return None
async def handle( 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
if ctx is None or not isinstance(update, raw.types.UpdateReadHistoryOutbox): watermark = _watermark(update)
if ctx is None or watermark is None:
return return
chat_id = utils.get_peer_id(update.peer) chat_id, max_id = watermark
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),
update.max_id, 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=update.max_id ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=max_id
) )
@@ -1,7 +1,7 @@
from pyrogram import raw from pyrogram import raw
from userbot import PyroClient
from userbot.modules.capture.chat_meta import meta_from_peer from userbot.modules.capture.chat_meta import meta_from_peer
from userbot.modules.client import PyroClient
from userbot.modules.stt import repository from userbot.modules.stt import repository
HANDLES = (raw.types.UpdateTranscribedAudio,) HANDLES = (raw.types.UpdateTranscribedAudio,)
@@ -20,4 +20,4 @@ async def handle(
await repository.set_extracted_text( await repository.set_extracted_text(
ctx.pool, ctx.account_id, meta.chat_id, update.msg_id, update.text ctx.pool, ctx.account_id, meta.chat_id, update.msg_id, update.text
) )
await ctx.watches.on_text(meta.chat_id, update.msg_id, update.text) await ctx.watches.on_transcript(meta.chat_id, update.msg_id, update.text)
+10 -5
View File
@@ -1,14 +1,19 @@
from pyrogram.types import Story from pyrogram.types import Story
from userbot import PyroClient from userbot.modules.client import PyroClient
from userbot.modules.stories.service import save_story from userbot.modules.stories.service import save_story, story_peer_id
@PyroClient.on_story() @PyroClient.on_story()
async def on_story(client: PyroClient, story: Story) -> None: async def on_story(client: PyroClient, story: Story) -> None:
if client.capture is None: ctx = client.capture
if ctx is None:
return return
await save_story(client, client.capture, story) peer_id = story_peer_id(story)
is_bot = bool(story.from_user and story.from_user.is_bot)
if peer_id > 0 and not ctx.resolve_peer(peer_id, is_bot=is_bot).stories:
return
await save_story(client, ctx, story)
handlers = on_story.handlers handlers = on_story.handlers # ty: ignore[unresolved-attribute]
+13 -1
View File
@@ -2,10 +2,11 @@ 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, ChatMeta, PolicySet from utils.policy.models import CaptureToggles, ChatKind, 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
@@ -28,6 +29,7 @@ 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:
@@ -36,6 +38,16 @@ 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,
@@ -41,9 +41,7 @@ class ChatMetaCache:
chat_id: int, chat_id: int,
meta: tuple[str | None, str | None], meta: tuple[str | None, str | None],
) -> bool: ) -> bool:
from userbot.modules.groups.repository import ( # noqa: PLC0415 from userbot.modules.groups.repository import get_latest_chat_meta # noqa: PLC0415
get_latest_chat_meta,
)
if chat_id not in self._cache: if chat_id not in self._cache:
self._cache[chat_id] = await get_latest_chat_meta(pool, account_id, chat_id) self._cache[chat_id] = await get_latest_chat_meta(pool, account_id, chat_id)
@@ -60,7 +58,14 @@ 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(ctx.pool, ctx.account_id, user.id, fields, raw_json(user)) await write_profile(
ctx.pool,
ctx.account_id,
user.id,
fields,
raw_json(user),
history=ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).profile_history,
)
if photo_file_id and photo_unique_id: 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
@@ -84,17 +89,15 @@ async def _capture_chat(message: Message, ctx: CaptureContext) -> None:
meta = (chat.title, photo_unique_id) meta = (chat.title, photo_unique_id)
if not await ctx.chat_meta.changed(ctx.pool, ctx.account_id, chat.id, meta): if not await ctx.chat_meta.changed(ctx.pool, ctx.account_id, chat.id, meta):
return return
from userbot.modules.groups.repository import insert_chat_history # noqa: PLC0415 from userbot.modules.groups.repository import record_chat_meta # noqa: PLC0415
await insert_chat_history( await record_chat_meta(
ctx.pool, ctx.pool,
ctx.account_id, ctx.account_id,
chat.id, chat.id,
message.id, message.id,
"meta",
chat.title, chat.title,
photo_unique_id, photo_unique_id,
None,
message.date, message.date,
raw_json(message), raw_json(message),
) )
+27 -12
View File
@@ -1,11 +1,14 @@
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, self_destruct_ttl from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
from userbot.modules.raw_json import raw_json from userbot.modules.raw_json import raw_json
from utils.policy.models import CaptureToggles from utils.policy.models import CaptureToggles
@@ -34,27 +37,39 @@ 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
await repository.upsert_message( current = await repository.upsert_message(
ctx.pool, ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
ctx.account_id, )
chat_id, if ctx.pending_deletes.take(chat_id, message.id):
message.id, await repository.mark_deleted_late(
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,
) )
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:
@@ -0,0 +1,36 @@
import time
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
PENDING_DELETE_SECONDS = 120.0
PENDING_DELETE_LIMIT = 10_000
DeleteKey = tuple[int | None, int]
def _key(chat_id: int | None, message_id: int) -> DeleteKey:
if chat_id is None or chat_id > CHANNEL_ID_THRESHOLD:
return None, message_id
return chat_id, message_id
class PendingDeletes:
def __init__(self) -> None:
self._expires: dict[DeleteKey, float] = {}
def remember(self, chat_id: int | None, message_ids: list[int]) -> None:
now = time.monotonic()
while self._expires:
oldest = next(iter(self._expires))
fresh = self._expires[oldest] > now
if fresh and len(self._expires) < PENDING_DELETE_LIMIT:
break
del self._expires[oldest]
for message_id in message_ids:
key = _key(chat_id, message_id)
self._expires.pop(key, None)
self._expires[key] = now + PENDING_DELETE_SECONDS
def take(self, chat_id: int, message_id: int) -> bool:
expires = self._expires.pop(_key(chat_id, message_id), None)
return expires is not None and expires > time.monotonic()
+199 -68
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
import asyncpg import asyncpg
@@ -21,13 +22,19 @@ _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, now()) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, CASE WHEN $10 THEN now() END)
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET 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 = now() edited_at = coalesce(EXCLUDED.edited_at, messages.edited_at)
"""
_LOCK_MESSAGE = """
SELECT pg_advisory_xact_lock(
hashtextextended(concat_ws(':', 'message', $1::int, $2::bigint, $3::bigint), 0)
)
""" """
_FIND_DELETED = """ _FIND_DELETED = """
@@ -40,6 +47,12 @@ UPDATE messages SET deleted_at = now()
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4 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)
@@ -52,7 +65,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 WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
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
@@ -61,15 +74,37 @@ WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
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 WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3 AND m.date = $4
ORDER BY m.date DESC LIMIT 1
""" """
_CURRENT_MEDIA = """ _CURRENT_MEDIA = """
@@ -88,8 +123,8 @@ ON CONFLICT (account_id, chat_id, message_id, storage_key) DO NOTHING
_INSERT_MEDIA = """ _INSERT_MEDIA = """
INSERT INTO media INSERT INTO media
(account_id, chat_id, message_id, kind, storage_key, file_size, mime, (account_id, chat_id, message_id, kind, storage_key, file_size, mime,
ttl_seconds, downloaded, unique_id) ttl_seconds, downloaded, unique_id, evicted_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CASE WHEN $11 THEN now() END)
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
kind = EXCLUDED.kind, kind = EXCLUDED.kind,
storage_key = EXCLUDED.storage_key, storage_key = EXCLUDED.storage_key,
@@ -97,37 +132,134 @@ ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
mime = EXCLUDED.mime, mime = EXCLUDED.mime,
ttl_seconds = EXCLUDED.ttl_seconds, ttl_seconds = EXCLUDED.ttl_seconds,
downloaded = EXCLUDED.downloaded, downloaded = EXCLUDED.downloaded,
unique_id = EXCLUDED.unique_id unique_id = EXCLUDED.unique_id,
evicted_at = CASE
WHEN EXCLUDED.downloaded THEN NULL
WHEN media.unique_id IS DISTINCT FROM EXCLUDED.unique_id
THEN EXCLUDED.evicted_at
ELSE coalesce(media.evicted_at, EXCLUDED.evicted_at) END,
evicted_key = CASE WHEN EXCLUDED.downloaded
OR media.unique_id IS DISTINCT FROM EXCLUDED.unique_id
THEN NULL ELSE media.evicted_key END
"""
_IS_EVICTED = """
SELECT EXISTS (
SELECT 1 FROM media WHERE unique_id = $1 AND evicted_at IS NOT NULL
)
"""
_UNEVICT = """
UPDATE media SET storage_key = $2, file_size = $3, downloaded = true,
evicted_at = NULL, evicted_key = NULL
WHERE evicted_key = $2 OR (unique_id = $1 AND evicted_at IS NOT NULL)
""" """
async def upsert_message( # noqa: PLR0913 @dataclass(frozen=True, slots=True)
pool: asyncpg.Pool, class MessageRow:
account_id: int, chat_id: int
chat_id: int, message_id: int
message_id: int, date: datetime
date: datetime, sender_id: int | None
sender_id: int | None, text: str | None
text: str | None, raw: str
raw: str, has_media: bool
*, is_self_destruct: bool
has_media: bool, edit_date: datetime | None
is_self_destruct: bool, media_unique_id: str | None
) -> None:
await pool.execute( @property
_UPSERT_MESSAGE, def columns(self) -> tuple:
account_id, return (
chat_id, self.chat_id,
message_id, self.message_id,
date, self.date,
sender_id, self.sender_id,
text, self.text,
raw, self.raw,
has_media, self.has_media,
is_self_destruct, self.is_self_destruct,
) )
async def _lock_current(
conn: asyncpg.Connection, account_id: int, row: MessageRow
) -> asyncpg.Record | None:
await conn.execute(_LOCK_MESSAGE, account_id, row.chat_id, row.message_id)
return await conn.fetchrow(
_CURRENT_CONTENT, account_id, row.chat_id, row.message_id, row.date
)
def _is_stale(current: asyncpg.Record, row: MessageRow) -> bool:
stored = current["edit_date"]
if stored is None:
return False
if row.edit_date is None:
return True
try:
return row.edit_date < datetime.fromisoformat(stored)
except (TypeError, ValueError):
return False
async def _record_past(
conn: asyncpg.Connection, account_id: int, row: MessageRow
) -> None:
await conn.execute(
_INSERT_PAST_VERSION,
account_id,
row.chat_id,
row.message_id,
row.edit_date,
row.date,
row.text,
row.raw,
)
async def _record_edit(
conn: asyncpg.Connection, account_id: int, row: MessageRow, *, mark_edited: bool
) -> None:
await conn.execute(
_SNAPSHOT_ORIGINAL, account_id, row.chat_id, row.message_id, row.date
)
await conn.execute(_TOUCH_EDITED, account_id, *row.columns, mark_edited)
await conn.execute(
_INSERT_VERSION,
account_id,
row.chat_id,
row.message_id,
row.edit_date,
row.text,
row.raw,
)
async def upsert_message(pool: asyncpg.Pool, account_id: int, row: MessageRow) -> bool:
async with pool.acquire() as conn, conn.transaction():
current = await _lock_current(conn, account_id, row)
if current is None:
await conn.execute(_UPSERT_MESSAGE, account_id, *row.columns)
return True
stored_media = current["media_unique_id"]
changed = current["text"] != row.text or (
stored_media is not None and stored_media != row.media_unique_id
)
if _is_stale(current, row):
if changed:
await _record_past(conn, account_id, row)
return False
if changed:
await _record_edit(
conn, account_id, row, mark_edited=row.edit_date is not None
)
else:
await conn.execute(_UPSERT_MESSAGE, account_id, *row.columns)
return True
async def max_message_id( 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:
@@ -168,48 +300,29 @@ async def mark_deleted_channel(
await _mark_deleted(pool, account_id, rows) await _mark_deleted(pool, account_id, rows)
async def add_version( # noqa: PLR0913 async def add_version(pool: asyncpg.Pool, account_id: int, row: MessageRow) -> bool:
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 conn.fetchrow(_CURRENT_CONTENT, account_id, chat_id, message_id) current = await _lock_current(conn, account_id, row)
text_changed = current is None or current["text"] != text if current is not None:
media_changed = ( changed = (
current is not None and current["media_unique_id"] != media_unique_id current["text"] != row.text
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:
@@ -229,6 +342,7 @@ async def insert_media( # noqa: PLR0913
unique_id: str | None, unique_id: str | None,
*, *,
downloaded: bool, downloaded: bool,
evicted: bool = False,
) -> None: ) -> None:
async with pool.acquire() as conn, conn.transaction(): async with pool.acquire() as conn, conn.transaction():
await conn.execute( await conn.execute(
@@ -243,6 +357,7 @@ async def insert_media( # noqa: PLR0913
ttl_seconds, ttl_seconds,
downloaded, downloaded,
unique_id, unique_id,
evicted,
) )
if storage_key is not None: if storage_key is not None:
await conn.execute( await conn.execute(
@@ -259,6 +374,22 @@ async def insert_media( # noqa: PLR0913
) )
async def is_evicted(pool: asyncpg.Pool, unique_id: str | None) -> bool:
return unique_id is not None and await pool.fetchval(_IS_EVICTED, unique_id)
async def unevict(
pool: asyncpg.Pool, unique_id: str | None, storage_key: str, file_size: int | None
) -> bool:
async with pool.acquire() as conn, conn.transaction():
await conn.execute(_UNEVICT, unique_id, storage_key, file_size)
dropped = await conn.fetchval(
"DELETE FROM media_trash WHERE storage_key = $1 RETURNING storage_key",
storage_key,
)
return dropped is not None
async def insert_callbacks( async def insert_callbacks(
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int, account_id: int,
@@ -0,0 +1,3 @@
from userbot.modules.catchup.service import SavedState, catch_up, read_saved_state
__all__ = ["SavedState", "catch_up", "read_saved_state"]
@@ -0,0 +1,41 @@
from dataclasses import dataclass
import asyncpg
_LOAD = """
SELECT catchup_pts, catchup_qts, catchup_date FROM accounts WHERE account_id = $1
"""
_SAVE = """
UPDATE accounts SET catchup_pts = $2, catchup_qts = $3, catchup_date = $4
WHERE account_id = $1
"""
_CLEAR = """
UPDATE accounts SET catchup_pts = NULL, catchup_qts = NULL, catchup_date = NULL
WHERE account_id = $1
"""
@dataclass(frozen=True)
class UpdateCursor:
pts: int
qts: int
date: int
async def load_cursor(pool: asyncpg.Pool, account_id: int) -> UpdateCursor | None:
row = await pool.fetchrow(_LOAD, account_id)
if row is None or row["catchup_pts"] is None:
return None
return UpdateCursor(row["catchup_pts"], row["catchup_qts"], row["catchup_date"])
async def save_cursor(
pool: asyncpg.Pool, account_id: int, cursor: UpdateCursor
) -> None:
await pool.execute(_SAVE, account_id, cursor.pts, cursor.qts, cursor.date)
async def clear_cursor(pool: asyncpg.Pool, account_id: int) -> None:
await pool.execute(_CLEAR, account_id)
@@ -0,0 +1,255 @@
import asyncio
import sqlite3
import time
from contextlib import closing
from dataclasses import dataclass, field
from pathlib import Path
from pyrogram import raw, utils
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from userbot.modules.capture.chat_meta import meta_from_peer
from userbot.modules.capture.context import CaptureContext
from userbot.modules.catchup import repository
from userbot.modules.catchup.repository import UpdateCursor
from userbot.modules.client import PyroClient
from utils.logging import logger
from utils.policy.models import ChatKind
BOX_SLICES = 50
CHANNEL_SLICES = 10
CHANNEL_LIMIT = 100
MAX_CHANNELS = 50
QUEUE_HIGH_WATER = 200
QUEUE_WAIT_SECONDS = 0.5
NO_QTS = -1
MAX_AGE_SECONDS = 3 * 24 * 3600
@dataclass(frozen=True)
class SavedState:
box: UpdateCursor | None = None
channels: dict[int, int] = field(default_factory=dict)
@dataclass
class BoxWalk:
cursor: UpdateCursor
done: bool = False
updates: int = 0
channels: dict[int, raw.base.Chat] = field(default_factory=dict)
def read_saved_state(path: Path) -> SavedState:
try:
uri = f"{path.resolve().as_uri()}?mode=ro"
with closing(sqlite3.connect(uri, uri=True)) as conn:
rows = conn.execute(
"SELECT id, pts, qts, date FROM update_state"
).fetchall()
except Exception as exc:
logger.warning(f"[yellow]Session {path.stem}: no saved update state ({exc})[/]")
return SavedState()
box = None
channels: dict[int, int] = {}
for state_id, pts, qts, date in rows:
if pts is None:
continue
if state_id == 0:
box = UpdateCursor(
pts, NO_QTS if qts is None else qts, date or int(time.time())
)
else:
channels[state_id] = pts
return SavedState(box, channels)
async def _dispatch(
client: PyroClient,
new_messages: list,
other_updates: list,
users: list,
chats: list,
) -> int:
await client.fetch_peers(users)
await client.fetch_peers(chats)
user_map = {user.id: user for user in users}
chat_map = {chat.id: chat for chat in chats}
queue = client.dispatcher.updates_queue
for message in new_messages:
update = raw.types.UpdateNewMessage(message=message, pts=0, pts_count=0)
queue.put_nowait((update, user_map, chat_map))
for update in other_updates:
queue.put_nowait((update, user_map, chat_map))
while queue.qsize() > QUEUE_HIGH_WATER: # noqa: ASYNC110
await asyncio.sleep(QUEUE_WAIT_SECONDS)
return len(new_messages) + len(other_updates)
def _channel_hints(other_updates: list, chats: list) -> dict[int, raw.base.Chat]:
by_id = {chat.id: chat for chat in chats}
return {
utils.get_channel_id(update.channel_id): by_id[update.channel_id]
for update in other_updates
if isinstance(update, raw.types.UpdateChannelTooLong)
and update.channel_id in by_id
}
async def _walk_box(
client: PyroClient, ctx: CaptureContext, cursor: UpdateCursor
) -> BoxWalk:
walk = BoxWalk(cursor)
for _ in range(BOX_SLICES):
diff = await client.invoke(
raw.functions.updates.GetDifference(
pts=walk.cursor.pts, date=walk.cursor.date, qts=walk.cursor.qts
)
)
if isinstance(diff, raw.types.updates.DifferenceEmpty):
walk.done = True
return walk
if isinstance(diff, raw.types.updates.DifferenceTooLong):
logger.warning(
f"[yellow]Account {ctx.account_id}: too much missed to catch up "
f"(pts {walk.cursor.pts} to {diff.pts}), that gap stays[/]"
)
walk.done = True
return walk
walk.updates += await _dispatch(
client, diff.new_messages, diff.other_updates, diff.users, diff.chats
)
walk.channels.update(_channel_hints(diff.other_updates, diff.chats))
final = isinstance(diff, raw.types.updates.Difference)
state = diff.state if final else diff.intermediate_state
moved = UpdateCursor(state.pts, state.qts, state.date or walk.cursor.date)
if moved == walk.cursor and not final:
logger.warning(
f"[yellow]Account {ctx.account_id}: catch-up stopped advancing "
f"at pts={moved.pts}[/]"
)
return walk
walk.cursor = moved
await repository.save_cursor(ctx.pool, ctx.account_id, moved)
if final:
walk.done = True
return walk
return walk
async def _walk_channel(client: PyroClient, peer_id: int, pts: int) -> int:
peer = await client.resolve_peer(peer_id)
if not isinstance(peer, raw.types.InputPeerChannel):
return 0
channel = raw.types.InputChannel(
channel_id=peer.channel_id, access_hash=peer.access_hash
)
updates = 0
for _ in range(CHANNEL_SLICES):
diff = await client.invoke(
raw.functions.updates.GetChannelDifference(
channel=channel,
filter=raw.types.ChannelMessagesFilterEmpty(),
pts=pts,
limit=CHANNEL_LIMIT,
)
)
if isinstance(diff, raw.types.updates.ChannelDifferenceEmpty):
return updates
if isinstance(diff, raw.types.updates.ChannelDifferenceTooLong):
logger.info(f"Channel {peer_id}: too much missed to catch up")
return updates
updates += await _dispatch(
client, diff.new_messages, diff.other_updates, diff.users, diff.chats
)
if diff.final:
return updates
pts = diff.pts
return updates
def _captured_channels(
ctx: CaptureContext, hints: dict[int, raw.base.Chat], saved: dict[int, int]
) -> list[int]:
wanted: list[tuple[bool, int]] = []
for peer_id, chat in hints.items():
if peer_id not in saved:
continue
meta = meta_from_peer(
raw.types.PeerChannel(channel_id=chat.id),
{chat.id: chat},
{},
ctx.contacts.ids,
)
toggles = ctx.resolve(meta)
if toggles.messages or toggles.track_edits_deletes:
wanted.append((meta.kind is ChatKind.CHANNEL, peer_id))
return [peer_id for _, peer_id in sorted(wanted)[:MAX_CHANNELS]]
async def _walk_channels(
client: PyroClient, ctx: CaptureContext, walk: BoxWalk, saved: dict[int, int]
) -> int:
peer_ids = _captured_channels(ctx, walk.channels, saved)
updates = 0
for peer_id in peer_ids:
try:
updates += await _walk_channel(client, peer_id, saved[peer_id])
except (FloodWait, FloodPremiumWait) as exc:
logger.warning(
f"[yellow]Account {ctx.account_id}: channel catch-up stopped "
f"by a flood wait:[/] {exc}"
)
break
except RPCError as exc:
logger.warning(
f"[yellow]Account {ctx.account_id}: channel {peer_id} "
f"catch-up failed:[/] {exc}"
)
skipped = len(walk.channels) - len(peer_ids)
if skipped:
logger.info(f"Account {ctx.account_id}: {skipped} channel gap(s) left as is")
return updates
async def catch_up(client: PyroClient, saved: SavedState) -> None:
ctx = client.capture
if ctx is None:
return
try:
pending = await repository.load_cursor(ctx.pool, ctx.account_id)
cursor = pending or saved.box
if cursor is None:
logger.info(f"Account {ctx.account_id}: no saved update state to catch up")
return
if time.time() - cursor.date > MAX_AGE_SECONDS:
logger.warning(
f"[yellow]Account {ctx.account_id}: last update seen too long ago "
f"(pts={cursor.pts}), not catching up[/]"
)
await repository.clear_cursor(ctx.pool, ctx.account_id)
return
await repository.save_cursor(ctx.pool, ctx.account_id, cursor)
logger.info(
f"Account {ctx.account_id}: catching up from pts={cursor.pts}"
f"{' (resumed)' if pending else ''}"
)
walk = await _walk_box(client, ctx, cursor)
if not walk.done:
logger.warning(
f"[yellow]Account {ctx.account_id}: catch-up unfinished at "
f"pts={walk.cursor.pts},[/] {walk.updates} updates replayed, "
f"resumes on the next start"
)
return
from_channels = await _walk_channels(client, ctx, walk, saved.channels)
await repository.clear_cursor(ctx.pool, ctx.account_id)
logger.info(
f"[green]Account {ctx.account_id}: caught up,[/] {walk.updates} updates "
f"from the main box, {from_channels} from channels"
)
except Exception:
logger.exception(
f"[red]Account {ctx.account_id}: catch-up failed,[/] "
f"resumes on the next start"
)
@@ -7,7 +7,23 @@ INSERT INTO chat_history
(account_id, chat_id, message_id, event, title, photo_unique_id, actor_id, (account_id, chat_id, message_id, event, title, photo_unique_id, actor_id,
ts, raw) ts, raw)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
ON CONFLICT (account_id, chat_id, message_id) DO NOTHING ON CONFLICT DO NOTHING
"""
_INSERT_CHAT_META = """
INSERT INTO chat_history
(account_id, chat_id, message_id, event, title, photo_unique_id, ts, raw)
SELECT $1, $2, $3, 'meta', $4::text, $5::text, $6, $7::jsonb
WHERE ($4::text, $5::text) IS DISTINCT FROM (
(SELECT title FROM chat_history
WHERE account_id = $1 AND chat_id = $2 AND event IN ('meta', 'title')
ORDER BY ts DESC LIMIT 1),
(SELECT photo_unique_id FROM chat_history
WHERE account_id = $1 AND chat_id = $2
AND event IN ('meta', 'photo', 'photo_deleted')
ORDER BY ts DESC LIMIT 1)
)
ON CONFLICT DO NOTHING
""" """
_INSERT_MEMBERSHIP = """ _INSERT_MEMBERSHIP = """
@@ -19,13 +35,14 @@ ON CONFLICT (account_id, chat_id, message_id, user_id) DO NOTHING
_LATEST_TITLE = """ _LATEST_TITLE = """
SELECT title FROM chat_history SELECT title FROM chat_history
WHERE account_id = $1 AND chat_id = $2 AND title IS NOT NULL WHERE account_id = $1 AND chat_id = $2 AND event IN ('meta', 'title')
ORDER BY ts DESC LIMIT 1 ORDER BY ts DESC LIMIT 1
""" """
_LATEST_PHOTO = """ _LATEST_PHOTO = """
SELECT photo_unique_id FROM chat_history SELECT photo_unique_id FROM chat_history
WHERE account_id = $1 AND chat_id = $2 AND photo_unique_id IS NOT NULL WHERE account_id = $1 AND chat_id = $2
AND event IN ('meta', 'photo', 'photo_deleted')
ORDER BY ts DESC LIMIT 1 ORDER BY ts DESC LIMIT 1
""" """
@@ -64,6 +81,29 @@ async def insert_chat_history( # noqa: PLR0913
) )
async def record_chat_meta( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
message_id: int,
title: str | None,
photo_unique_id: str | None,
ts: datetime,
raw: str,
) -> bool:
status = await pool.execute(
_INSERT_CHAT_META,
account_id,
chat_id,
message_id,
title,
photo_unique_id,
ts,
raw,
)
return status == "INSERT 0 1"
async def insert_membership( # noqa: PLR0913 async def insert_membership( # noqa: PLR0913
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int, account_id: int,
+30 -16
View File
@@ -9,11 +9,12 @@ 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.env import env from utils.listen import Listener
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:
@@ -38,25 +39,20 @@ 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
while True:
try: try:
await handler(ctx) await handler(ctx)
except (FloodWait, FloodPremiumWait) as exc: except (FloodWait, FloodPremiumWait) as exc:
await repository.bump_flood_wait(self.pool, ctx.job_id)
wait = exc.value if isinstance(exc.value, int) else 1 wait = exc.value if isinstance(exc.value, int) else 1
await repository.defer_flood_wait(self.pool, ctx.job_id, wait)
logger.warning( logger.warning(
f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind})[/]" f"[yellow]FloodWait {wait}s on job {ctx.job_id} ({job.kind}), "
f"deferred[/]"
) )
await asyncio.sleep(wait)
except Exception as exc: except Exception as exc:
logger.exception(f"Job {ctx.job_id} ({job.kind}) failed") logger.exception(f"Job {ctx.job_id} ({job.kind}) failed")
await repository.finish( await repository.finish(self.pool, ctx.job_id, JobStatus.FAILED, str(exc))
self.pool, ctx.job_id, JobStatus.FAILED, str(exc)
)
return
else: else:
await repository.finish(self.pool, ctx.job_id, JobStatus.DONE) 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:
@@ -66,23 +62,41 @@ 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).[/]")
conn = await asyncpg.connect(dsn=env.db.connection_url) listener = Listener(
await conn.add_listener(JOBS_CHANGED_CHANNEL, lambda *_: self._wake.set()) f"jobs {self.account_id}",
{JOBS_CHANGED_CHANNEL: lambda _payload: self._wake.set()},
self._wake.set,
)
await listener.start()
logger.info(f"[green]Job consumer running for account {self.account_id}.[/]") logger.info(f"[green]Job consumer running for account {self.account_id}.[/]")
try: try:
while True: while True:
await self.drain()
self._wake.clear() self._wake.clear()
await self.drain()
with contextlib.suppress(TimeoutError): with contextlib.suppress(TimeoutError):
await asyncio.wait_for( await asyncio.wait_for(
self._wake.wait(), timeout=POLL_INTERVAL_SECONDS self._wake.wait(), timeout=await self._idle_seconds()
) )
finally: finally:
with contextlib.suppress(Exception): await listener.stop()
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,6 +5,7 @@ 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,
@@ -18,6 +19,7 @@ __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,17 +1,11 @@
from datetime import UTC, datetime
from pyrogram import Client from pyrogram import Client
from pyrogram.errors import BadRequest, Forbidden from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import User from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history
from userbot.modules.jobs.context import JobContext from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level from userbot.modules.profiles.snapshots import save_group, save_user
from userbot.modules.profiles.repository import write_profile
from userbot.modules.raw_json import raw_json
MEMBER_CAP = 200 MEMBER_CAP = 200
@@ -25,43 +19,17 @@ LIMIT 100
""" """
async def _save_user(ctx: CaptureContext, user: User) -> None:
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, user.id, "peer", photo_unique_id, photo_file_id
)
async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None: 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)
photo = chat.photo if chat is not None:
photo_unique_id = photo.big_photo_unique_id if photo else None await save_group(ctx, chat)
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
@@ -87,7 +55,7 @@ async def _enrich_senders(client: Client, ctx: CaptureContext, chat_id: int) ->
if not ids: if not ids:
return return
for user in await _fetch_users(client, ids): for user in await _fetch_users(client, ids):
await _save_user(ctx, user) await save_user(ctx, user)
@register("enrich_chat") @register("enrich_chat")
@@ -1,6 +1,13 @@
from pyrogram import Client from pyrogram import Client
from pyrogram.errors import FileIdInvalid, FileReferenceExpired, FileReferenceInvalid from pyrogram.errors import (
from pyrogram.types import Photo ChannelInvalid,
ChannelPrivate,
FileIdInvalid,
FileReferenceExpired,
FileReferenceInvalid,
PeerIdInvalid,
)
from pyrogram.types import ChatPhoto
from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded
from userbot.modules.download import download_bytes from userbot.modules.download import download_bytes
@@ -8,19 +15,20 @@ from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register from userbot.modules.jobs.registry import register
STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid) STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid)
UNKNOWN_OWNER = (PeerIdInvalid, ChannelInvalid, ChannelPrivate)
async def _fresh_file_id(client: Client, owner_id: int, unique_id: str) -> str | None: async def _fresh_file_id(client: Client, owner_id: int, unique_id: str) -> str | None:
try:
chat = await client.get_chat(owner_id) chat = await client.get_chat(owner_id)
photo = chat.photo except UNKNOWN_OWNER:
return None
photo = chat.photo if chat else None
if photo and photo.big_photo_unique_id == unique_id: if photo and photo.big_photo_unique_id == unique_id:
return photo.big_file_id return photo.big_file_id
photos = client.get_chat_photos(owner_id) async for item in client.get_chat_photos(owner_id):
if photos is None: if isinstance(item, ChatPhoto) and item.big_photo_unique_id == unique_id:
return None return item.big_file_id
async for item in photos:
if isinstance(item, Photo) and item.file_unique_id == unique_id:
return item.file_id
return None return None
@@ -21,7 +21,9 @@ async def fetch_media(ctx: JobContext) -> None:
msg = f"message {chat_id}/{message_id} is unavailable" msg = f"message {chat_id}/{message_id} is unavailable"
raise RuntimeError(msg) raise RuntimeError(msg)
toggles = CaptureToggles(media=True, self_destruct_media=True) toggles = CaptureToggles(media=True, self_destruct_media=True)
ok = await capture_media(client, message, capture, chat_id, message_id, toggles) ok = await capture_media(
client, message, capture, chat_id, message_id, toggles, force=True
)
if not ok: if not ok:
msg = f"media download failed for {chat_id}/{message_id}" msg = f"media download failed for {chat_id}/{message_id}"
raise RuntimeError(msg) raise RuntimeError(msg)
@@ -0,0 +1,124 @@
from datetime import UTC, datetime, timedelta
from pyrogram import Client, raw
from pyrogram.errors import (
ChannelInvalid,
ChannelPrivate,
ChatTooBig,
FloodPremiumWait,
FloodWait,
MessageIdInvalid,
MessageNotReadYet,
MessageTooOld,
MsgIdInvalid,
MsgTooOld,
PeerIdInvalid,
RPCError,
UserPrivacyRestricted,
YourPrivacyRestricted,
)
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.read_receipts.repository import save_fetch, save_reads
from utils.jobs import enqueue
from utils.logging import logger
from utils.policy.models import ChatKind
from utils.read.peers import get_peers
from utils.read.read_receipts import read_info
RETRY = timedelta(seconds=60)
LATER = timedelta(days=1)
Refusal = tuple[tuple[type[Exception], ...], str, timedelta]
REFUSALS: tuple[Refusal, ...] = (
((MessageNotReadYet,), "not_read_yet", RETRY),
((UserPrivacyRestricted,), "user_privacy", RETRY),
((YourPrivacyRestricted,), "your_privacy", LATER),
((ChatTooBig,), "chat_too_big", LATER),
((MsgTooOld, MessageTooOld), "too_old", LATER),
(
(
PeerIdInvalid,
ChannelInvalid,
ChannelPrivate,
MessageIdInvalid,
MsgIdInvalid,
KeyError,
ValueError,
),
"unavailable",
RETRY,
),
)
def _refusal(exc: Exception) -> tuple[str, timedelta]:
for kinds, outcome, retry in REFUSALS:
if isinstance(exc, kinds):
return outcome, retry
return "error", RETRY
def _at(timestamp: int) -> datetime:
return datetime.fromtimestamp(timestamp, UTC)
async def ask_telegram(
client: Client, chat_id: int, message_id: int, *, group: bool
) -> list[tuple[int, datetime]]:
peer = await client.resolve_peer(chat_id)
if peer is None:
msg = f"unknown peer {chat_id}"
raise ValueError(msg)
if group:
participants = await client.invoke(
raw.functions.messages.GetMessageReadParticipants(
peer=peer, msg_id=message_id
)
)
return [(item.user_id, _at(item.date)) for item in participants]
outbox = await client.invoke(
raw.functions.messages.GetOutboxReadDate(peer=peer, msg_id=message_id)
)
return [(chat_id, _at(outbox.date))]
async def _enrich_unknown(ctx: JobContext, chat_id: int, reader_ids: list[int]) -> None:
known = {
peer.peer_id for peer in await get_peers(ctx.pool, ctx.account_id, reader_ids)
}
if set(reader_ids) - known:
await enqueue(ctx.pool, ctx.account_id, "enrich_chat", {"chat_id": chat_id})
@register("fetch_read_info")
async def fetch_read_info(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
chat_id = ctx.job.params["chat_id"]
message_id = ctx.job.params["message_id"]
info = await read_info(ctx.pool, ctx.account_id, chat_id, message_id)
if info is None or not info.can_fetch:
return
group = info.chat_kind == ChatKind.GROUP
try:
reads = await ask_telegram(client, chat_id, message_id, group=group)
except (FloodWait, FloodPremiumWait) as exc:
wait = exc.value if isinstance(exc.value, int) else 0
retry = max(RETRY, timedelta(seconds=wait))
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, "flood", retry)
return
except (RPCError, KeyError, ValueError) as exc:
outcome, retry = _refusal(exc)
if outcome == "error":
logger.warning(f"[yellow]Read info {chat_id}/{message_id}: {exc!r}[/]")
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, outcome, retry)
return
source = "participants" if group else "outbox_date"
await save_reads(ctx.pool, ctx.account_id, chat_id, message_id, source, reads)
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, "ok", RETRY)
if group and reads:
await _enrich_unknown(ctx, chat_id, [reader_id for reader_id, _ in reads])
@@ -46,7 +46,7 @@ async def _save_found(
source = _source(peer, users, chats) source = _source(peer, users, chats)
if source is None: if source is None:
return None return None
chat = Chat._parse_chat(client, source) # noqa: SLF001 chat = await Chat._parse_chat(client, source) # noqa: SLF001
if chat is None or chat.id is None: if chat is None or chat.id is None:
return None return None
await save_chat(ctx, chat) await save_chat(ctx, chat)
@@ -1,11 +1,8 @@
from pyrogram.types import User from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.jobs.context import JobContext from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level from userbot.modules.profiles.snapshots import save_user
from userbot.modules.profiles.repository import write_profile
from userbot.modules.raw_json import raw_json
@register("sync_contacts") @register("sync_contacts")
@@ -21,17 +18,7 @@ async def sync_contacts(ctx: JobContext) -> None:
for user in contacts: for user in contacts:
if not isinstance(user, User): if not isinstance(user, User):
continue continue
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user) await save_user(capture, user)
await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool,
ctx.account_id,
user.id,
"peer",
photo_unique_id,
photo_file_id,
)
processed += 1 processed += 1
await capture.contacts.refresh() await capture.contacts.refresh()
await ctx.report_progress({"processed": processed, "done": True}) await ctx.report_progress({"processed": processed, "done": True})
@@ -2,14 +2,10 @@ from pyrogram import Client
from pyrogram.errors import BadRequest, Forbidden from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import User from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
from userbot.modules.jobs.context import JobContext from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level from userbot.modules.profiles.snapshots import save_group, save_private, save_user
from userbot.modules.profiles.repository import write_profile
from userbot.modules.profiles.snapshots import save_group, save_private
from userbot.modules.raw_json import raw_json
from utils.logging import logger from utils.logging import logger
SAVE_EVERY = 100 SAVE_EVERY = 100
@@ -30,21 +26,8 @@ async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) ->
continue continue
users = result if isinstance(result, list) else [result] users = result if isinstance(result, list) else [result]
for user in users: for user in users:
if not isinstance(user, User): if isinstance(user, User):
continue await save_user(ctx, user)
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
await write_profile(
ctx.pool, ctx.account_id, user.id, fields, raw_json(user)
)
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")
+14 -3
View File
@@ -32,6 +32,7 @@ async def claim_next(pool: asyncpg.Pool, account_id: int) -> Job | None:
"updated_at = now(), attempts = attempts + 1 " "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,
@@ -76,11 +77,21 @@ async def report_progress(
) )
async def bump_flood_wait(pool: asyncpg.Pool, job_id: int) -> None: async def defer_flood_wait(pool: asyncpg.Pool, job_id: int, seconds: int) -> None:
await pool.execute( await pool.execute(
"UPDATE jobs SET flood_waits = flood_waits + 1, updated_at = now() " "UPDATE jobs SET status = 'pending', flood_waits = flood_waits + 1, "
"WHERE id = $1", "run_after = now() + make_interval(secs => $2), updated_at = now() "
"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,6 +7,14 @@ 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
""" """
@@ -106,6 +106,8 @@ async def capture_media( # noqa: PLR0913
chat_id: int, chat_id: int,
message_id: int, message_id: int,
toggles: CaptureToggles, toggles: CaptureToggles,
*,
force: bool = False,
) -> bool: ) -> bool:
kind, obj = media_object(message) kind, obj = media_object(message)
if obj is None: if obj is None:
@@ -117,6 +119,7 @@ async def capture_media( # noqa: PLR0913
mime = getattr(obj, "mime_type", None) mime = getattr(obj, "mime_type", None)
storage_key: str | None = None storage_key: str | None = None
downloaded = False downloaded = False
evicted = False
flood: FloodWait | FloodPremiumWait | None = None flood: FloodWait | FloodPremiumWait | None = None
if want: if want:
existing = await repository.current_media( existing = await repository.current_media(
@@ -131,7 +134,7 @@ async def capture_media( # noqa: PLR0913
storage_key = existing["storage_key"] storage_key = existing["storage_key"]
file_size = existing["file_size"] file_size = existing["file_size"]
downloaded = True downloaded = True
else: elif force or not await repository.is_evicted(ctx.pool, unique_id):
target = _download_target(message) target = _download_target(message)
try: try:
data = await _download(client, target, chat_id, message_id) data = await _download(client, target, chat_id, message_id)
@@ -142,6 +145,8 @@ async def capture_media( # noqa: PLR0913
storage_key = ctx.storage.put(data) storage_key = ctx.storage.put(data)
file_size = len(data) file_size = len(data)
downloaded = True downloaded = True
else:
evicted = True
await repository.insert_media( await repository.insert_media(
ctx.pool, ctx.pool,
ctx.account_id, ctx.account_id,
@@ -154,7 +159,14 @@ async def capture_media( # noqa: PLR0913
ttl, ttl,
unique_id, unique_id,
downloaded=downloaded, downloaded=downloaded,
evicted=evicted,
) )
if (
force
and storage_key is not None
and await repository.unevict(ctx.pool, unique_id, storage_key, file_size)
):
ctx.storage.purge(storage_key)
if flood is not None: if flood is not None:
raise flood raise flood
return downloaded or not want return downloaded or not want
+11 -5
View File
@@ -1,4 +1,4 @@
from dataclasses import dataclass from dataclasses import dataclass, field
from pyrogram import Client, raw from pyrogram import Client, raw
from pyrogram.types import Chat, User from pyrogram.types import Chat, User
@@ -12,6 +12,11 @@ class ProfileFields:
phone: str | None = None phone: str | None = None
photo_unique_id: str | None = None photo_unique_id: str | None = None
is_deleted_account: bool = False is_deleted_account: bool = False
unknown: frozenset[str] = field(default=frozenset(), compare=False)
MIN_UNKNOWN = frozenset({"username", "phone"})
CHAT_UNKNOWN = frozenset({"phone", "is_deleted_account"})
def active_username(usernames: list[raw.types.Username]) -> str | None: def active_username(usernames: list[raw.types.Username]) -> str | None:
@@ -21,10 +26,10 @@ def active_username(usernames: list[raw.types.Username]) -> str | None:
return usernames[0].username if usernames else None return usernames[0].username if usernames else None
def snapshot_from_user( async def snapshot_from_user(
client: Client, raw_user: raw.types.User client: Client, raw_user: raw.types.User
) -> tuple[ProfileFields, str | None, str | None]: ) -> tuple[ProfileFields, str | None, str | None]:
user = User._parse(client, raw_user) # noqa: SLF001 user = await User._parse(client, raw_user) # noqa: SLF001
photo = user.photo if user else None photo = user.photo if user else None
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
@@ -35,6 +40,7 @@ def snapshot_from_user(
phone=user.phone_number if user else None, phone=user.phone_number if user else None,
photo_unique_id=photo_unique_id, photo_unique_id=photo_unique_id,
is_deleted_account=bool(getattr(raw_user, "deleted", False)), is_deleted_account=bool(getattr(raw_user, "deleted", False)),
unknown=MIN_UNKNOWN if getattr(raw_user, "min", False) else frozenset(),
) )
return fields, photo_file_id, photo_unique_id return fields, photo_file_id, photo_unique_id
@@ -52,6 +58,7 @@ def snapshot_from_high_level(
phone=user.phone_number, phone=user.phone_number,
photo_unique_id=photo_unique_id, photo_unique_id=photo_unique_id,
is_deleted_account=bool(user.is_deleted), is_deleted_account=bool(user.is_deleted),
unknown=MIN_UNKNOWN if user.is_min else frozenset(),
) )
return fields, photo_file_id, photo_unique_id return fields, photo_file_id, photo_unique_id
@@ -64,8 +71,7 @@ def snapshot_from_chat(chat: Chat) -> tuple[ProfileFields, str | None, str | Non
first_name=chat.first_name, first_name=chat.first_name,
last_name=chat.last_name, last_name=chat.last_name,
username=chat.username, username=chat.username,
phone=None,
photo_unique_id=photo_unique_id, photo_unique_id=photo_unique_id,
is_deleted_account=False, unknown=CHAT_UNKNOWN,
) )
return fields, photo_file_id, photo_unique_id return fields, photo_file_id, photo_unique_id
@@ -9,26 +9,46 @@ WHERE account_id = $1 AND peer_id = $2
""" """
_UPSERT_PEER = """ _UPSERT_PEER = """
INSERT INTO peers INSERT INTO peers AS p
(account_id, peer_id, first_name, last_name, username, phone, (account_id, peer_id, first_name, last_name, username, phone,
photo_unique_id, is_deleted_account, raw, updated_at) photo_unique_id, is_deleted_account, raw, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, now()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, now())
ON CONFLICT (account_id, peer_id) DO UPDATE SET ON CONFLICT (account_id, peer_id) DO UPDATE SET
first_name = EXCLUDED.first_name, first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name, last_name = EXCLUDED.last_name,
username = EXCLUDED.username, username = CASE WHEN 'username' = ANY($10::text[])
phone = EXCLUDED.phone, THEN COALESCE(EXCLUDED.username, p.username) ELSE EXCLUDED.username END,
phone = CASE WHEN 'phone' = ANY($10::text[])
THEN COALESCE(EXCLUDED.phone, p.phone) ELSE EXCLUDED.phone END,
photo_unique_id = EXCLUDED.photo_unique_id, photo_unique_id = EXCLUDED.photo_unique_id,
is_deleted_account = EXCLUDED.is_deleted_account, is_deleted_account = CASE WHEN 'is_deleted_account' = ANY($10::text[])
THEN p.is_deleted_account ELSE EXCLUDED.is_deleted_account END,
raw = EXCLUDED.raw, raw = EXCLUDED.raw,
updated_at = now() updated_at = now()
""" """
_INSERT_HISTORY = """ _INSERT_CHANGE = """
INSERT INTO peer_history INSERT INTO peer_history
(account_id, peer_id, observed_at, first_name, last_name, username, phone, (account_id, peer_id, observed_at, first_name, last_name, username, phone,
photo_unique_id, is_deleted_account, raw) photo_unique_id, is_deleted_account, raw)
VALUES ($1, $2, now(), $3, $4, $5, $6, $7, $8, $9::jsonb) SELECT account_id, peer_id, clock_timestamp(), first_name, last_name, username,
phone, photo_unique_id, is_deleted_account, raw
FROM peers p
WHERE account_id = $1 AND peer_id = $2
AND NOT EXISTS (
SELECT 1 FROM (
SELECT first_name, last_name, username, phone, photo_unique_id,
is_deleted_account
FROM peer_history
WHERE account_id = $1 AND peer_id = $2
ORDER BY observed_at DESC LIMIT 1
) latest
WHERE (latest.first_name, latest.last_name, latest.username, latest.phone,
latest.photo_unique_id, latest.is_deleted_account)
IS NOT DISTINCT FROM
(p.first_name, p.last_name, p.username, p.phone,
p.photo_unique_id, p.is_deleted_account)
)
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
""" """
@@ -49,10 +69,20 @@ async def get_peer(
) )
async def write_profile( async def write_profile( # noqa: PLR0913
pool: asyncpg.Pool, account_id: int, peer_id: int, fields: ProfileFields, raw: str pool: asyncpg.Pool,
) -> None: account_id: int,
args = ( peer_id: int,
fields: ProfileFields,
raw: str,
*,
history: bool,
) -> bool:
async with pool.acquire() as conn, conn.transaction():
await conn.execute(
_UPSERT_PEER,
account_id,
peer_id,
fields.first_name, fields.first_name,
fields.last_name, fields.last_name,
fields.username, fields.username,
@@ -60,7 +90,9 @@ async def write_profile(
fields.photo_unique_id, fields.photo_unique_id,
fields.is_deleted_account, fields.is_deleted_account,
raw, raw,
sorted(fields.unknown),
) )
async with pool.acquire() as conn, conn.transaction(): if not history:
await conn.execute(_UPSERT_PEER, account_id, peer_id, *args) return False
await conn.execute(_INSERT_HISTORY, account_id, peer_id, *args) status = await conn.execute(_INSERT_CHANGE, account_id, peer_id)
return status == "INSERT 0 1"
@@ -1,19 +1,46 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from pyrogram.types import Chat from pyrogram.enums import ChatType
from pyrogram.types import Chat, User
from userbot.modules.avatars import note_avatar from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history from userbot.modules.groups.repository import record_chat_meta
from userbot.modules.profiles.parse import snapshot_from_chat from userbot.modules.profiles.parse import snapshot_from_chat, snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile from userbot.modules.profiles.repository import write_profile
from userbot.modules.raw_json import raw_json from userbot.modules.raw_json import raw_json
async def save_user(ctx: CaptureContext, user: User) -> None:
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields):
return
await write_profile(
ctx.pool,
ctx.account_id,
user.id,
fields,
raw_json(user),
history=ctx.resolve_peer(user.id, is_bot=bool(user.is_bot)).profile_history,
)
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, user.id, "peer", photo_unique_id, photo_file_id
)
async def save_private(ctx: CaptureContext, chat: Chat) -> bool: async def save_private(ctx: CaptureContext, chat: Chat) -> bool:
chat_id = chat.id or 0 chat_id = chat.id or 0
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat) fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, raw_json(chat)) is_bot = chat.type is ChatType.BOT
await write_profile(
ctx.pool,
ctx.account_id,
chat_id,
fields,
raw_json(chat),
history=ctx.resolve_peer(chat_id, is_bot=is_bot).profile_history,
)
if photo_file_id and photo_unique_id: 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
@@ -26,15 +53,13 @@ async def save_group(ctx: CaptureContext, chat: Chat) -> None:
photo = chat.photo photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None photo_file_id = photo.big_file_id if photo else None
await insert_chat_history( await record_chat_meta(
ctx.pool, ctx.pool,
ctx.account_id, ctx.account_id,
chat_id, chat_id,
0, 0,
"meta",
chat.title, chat.title,
photo_unique_id, photo_unique_id,
None,
datetime.now(UTC), datetime.now(UTC),
raw_json(chat), raw_json(chat),
) )
+17 -2
View File
@@ -1,8 +1,23 @@
import json
from typing import Any
from pyrogram.raw.core import TLObject from pyrogram.raw.core import TLObject
from pyrogram.types import Object from pyrogram.types import Contact, Object
EMPTY_JSON = "{}" EMPTY_JSON = "{}"
PHONE_KEY = "phone_number"
def _default(obj: Any) -> Any: # noqa: ANN401
if isinstance(obj, TLObject):
return TLObject.default(obj)
data = Object.default(obj)
if isinstance(obj, Contact) and isinstance(data, dict) and PHONE_KEY in data:
data[PHONE_KEY] = obj.phone_number
return data
def raw_json(obj: Object | TLObject | None) -> str: def raw_json(obj: Object | TLObject | None) -> str:
return EMPTY_JSON if obj is None else str(obj) if obj is None:
return EMPTY_JSON
return json.dumps(obj, indent=4, default=_default, ensure_ascii=False)
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timedelta
import asyncpg import asyncpg
@@ -9,6 +9,26 @@ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (account_id, chat_id, message_id, ts) DO NOTHING 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,
@@ -34,3 +54,33 @@ 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
)
@@ -6,7 +6,7 @@ _UPSERT_STORY = """
INSERT INTO stories INSERT INTO stories
(account_id, peer_id, story_id, date, expire_date, caption, media_kind, (account_id, peer_id, story_id, date, expire_date, caption, media_kind,
storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at) storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, now()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, false, $13::jsonb, now())
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
date = EXCLUDED.date, date = EXCLUDED.date,
expire_date = EXCLUDED.expire_date, expire_date = EXCLUDED.expire_date,
@@ -17,10 +17,17 @@ ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
downloaded = stories.downloaded OR EXCLUDED.downloaded, downloaded = stories.downloaded OR EXCLUDED.downloaded,
views = EXCLUDED.views, views = EXCLUDED.views,
pinned = EXCLUDED.pinned, pinned = EXCLUDED.pinned,
deleted = EXCLUDED.deleted, deleted = false,
raw = EXCLUDED.raw raw = EXCLUDED.raw
""" """
_MARK_DELETED = """
INSERT INTO stories
(account_id, peer_id, story_id, downloaded, pinned, deleted, raw, observed_at)
VALUES ($1, $2, $3, false, false, true, '{}'::jsonb, now())
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET deleted = true
"""
async def is_downloaded( async def is_downloaded(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
@@ -51,7 +58,6 @@ async def upsert_story( # noqa: PLR0913
raw: str, raw: str,
*, *,
pinned: bool, pinned: bool,
deleted: bool,
downloaded: bool, downloaded: bool,
) -> None: ) -> None:
await pool.execute( await pool.execute(
@@ -68,6 +74,11 @@ async def upsert_story( # noqa: PLR0913
downloaded, downloaded,
views, views,
pinned, pinned,
deleted,
raw, raw,
) )
async def mark_deleted(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
) -> None:
await pool.execute(_MARK_DELETED, account_id, peer_id, story_id)
@@ -17,13 +17,18 @@ def story_peer_id(story: Story) -> int:
async def save_story(client: Client, capture: CaptureContext, story: Story) -> None: async def save_story(client: Client, capture: CaptureContext, story: Story) -> None:
peer_id = story_peer_id(story) peer_id = story_peer_id(story)
if story.deleted:
await repository.mark_deleted(
capture.pool, capture.account_id, peer_id, story.id
)
return
storage_key: str | None = None storage_key: str | None = None
file_size: int | None = None file_size: int | None = None
downloaded = False downloaded = False
stored = await repository.is_downloaded( stored = await repository.is_downloaded(
capture.pool, capture.account_id, peer_id, story.id capture.pool, capture.account_id, peer_id, story.id
) )
if not (stored or story.deleted or story.media is None): if not (stored or story.media is None):
data = await download_bytes(client, story) data = await download_bytes(client, story)
if data is not None: if data is not None:
storage_key = capture.storage.put(data) storage_key = capture.storage.put(data)
@@ -43,6 +48,5 @@ async def save_story(client: Client, capture: CaptureContext, story: Story) -> N
story.views, story.views,
raw_json(story.raw), raw_json(story.raw),
pinned=bool(story.pinned), pinned=bool(story.pinned),
deleted=bool(story.deleted),
downloaded=downloaded, downloaded=downloaded,
) )
Whitespace-only changes.
@@ -0,0 +1,16 @@
import asyncpg
_HEARTBEAT = """
WITH extended AS (
UPDATE capture_uptime SET seen_at = now()
WHERE account_id = $1 AND seen_at >= now() - interval '150 seconds'
RETURNING account_id
)
INSERT INTO capture_uptime (account_id, started_at, seen_at)
SELECT $1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM extended)
"""
async def heartbeat(pool: asyncpg.Pool, account_id: int) -> None:
await pool.execute(_HEARTBEAT, account_id)
+156 -34
View File
@@ -1,60 +1,182 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from userbot.modules.watches import repository
from userbot.modules.watches.evaluator import ( from userbot.modules.watches.evaluator import (
KIND_KEYWORD, Rules,
KIND_PEER_ONLINE, Seen,
build_rules,
match_chat_scope,
match_keyword, match_keyword,
match_peer_online, match_new_message,
) )
from utils.logging import logger from utils.logging import logger
from utils.read import watches as watches_read from utils.read import watches as watches_read
from utils.read.accounts import self_user_id
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Coroutine
import asyncpg import asyncpg
from utils.read.models import WatchView ONLINE_QUIET = timedelta(minutes=15)
PREVIEW_CHARS = 500
def _message_payload(event: str, seen: Seen) -> dict[str, Any]:
payload: dict[str, Any] = {
"event": event,
"chat_id": seen.chat_id,
"message_id": seen.message_id,
"sender_id": seen.sender_id,
"text": (seen.text or "")[:PREVIEW_CHARS] or None,
}
if seen.date is not None:
payload["date"] = seen.date.isoformat()
return payload
class WatchCache: class WatchCache:
def __init__(self, pool: asyncpg.Pool, account_id: int) -> None: def __init__(self, pool: asyncpg.Pool, account_id: int) -> None:
self._pool = pool self._pool = pool
self._account_id = account_id self._account_id = account_id
self.watches: list[WatchView] = [] self.rules = Rules()
self._online: set[int] = set() self._self_id: int | None = None
self._last_seen: dict[int, datetime] = {}
async def refresh(self) -> None: async def refresh(self) -> None:
rows = await watches_read.list_watches(self._pool, self._account_id) rows = await watches_read.list_watches(self._pool, self._account_id)
self.watches = [watch for watch in rows if watch.enabled] self.rules = build_rules(rows)
logger.info(f"[green]Watches cached:[/] {len(self.watches)}") self._self_id = await self_user_id(self._pool, self._account_id)
logger.info(f"[green]Watches cached:[/] {len(self.rules)}")
async def on_text(self, chat_id: int, message_id: int, text: str | None) -> None: def _own(self, seen: Seen) -> bool:
if not text: return seen.outgoing or (
return self._self_id is not None and seen.sender_id == self._self_id
for watch in self.watches:
if watch.kind == KIND_KEYWORD and match_keyword(
text, chat_id, watch.params
):
await watches_read.insert_alert(
self._pool,
self._account_id,
watch.id,
{"chat_id": chat_id, "message_id": message_id},
dedup_key=f"{chat_id}:{message_id}",
) )
async def on_status(self, peer_id: int, *, is_online: bool) -> None: async def _guard(self, work: Coroutine[Any, Any, None]) -> None:
if not is_online: try:
self._online.discard(peer_id) await work
return except Exception:
if peer_id in self._online: logger.exception("[red]Watch evaluation failed[/]")
return
self._online.add(peer_id) async def _fire(self, watch_id: int, payload: dict[str, Any], key: str) -> None:
for watch in self.watches:
if watch.kind == KIND_PEER_ONLINE and match_peer_online(
peer_id, watch.params
):
await watches_read.insert_alert( await watches_read.insert_alert(
self._pool, self._account_id, watch.id, {"peer_id": peer_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:
if self._own(seen):
return
await self._guard(self._on_message(seen))
async def _on_message(self, seen: Seen) -> None:
await self._keyword("message", seen)
for watch_id, params in self.rules.new_message:
if match_new_message(params, seen):
await self._fire(
watch_id,
_message_payload("message", seen),
f"{seen.chat_id}:{seen.message_id}",
)
async def on_edit(self, seen: Seen) -> None:
if self._own(seen):
return
await self._guard(self._on_edit(seen))
async def _on_edit(self, seen: Seen) -> None:
await self._keyword("edit", seen)
if seen.edit_date is None:
return
stamp = int(seen.edit_date.timestamp())
for watch_id, params in self.rules.edited:
if match_chat_scope(params, seen):
await self._fire(
watch_id,
_message_payload("edit", seen),
f"{seen.chat_id}:{seen.message_id}:{stamp}",
)
async def on_transcript(self, chat_id: int, message_id: int, text: str) -> None:
if self.rules.keyword:
await self._guard(self._on_transcript(chat_id, message_id, text))
async def _on_transcript(self, chat_id: int, message_id: int, text: str) -> None:
sender_id = await repository.message_sender(
self._pool, self._account_id, chat_id, message_id
)
seen = Seen(chat_id, message_id, sender_id, text)
if not self._own(seen):
await self._keyword("transcript", seen)
async def on_deleted(self, chat_id: int | None, message_ids: list[int]) -> None:
if self.rules.deleted and message_ids:
await self._guard(self._on_deleted(chat_id, message_ids))
async def _on_deleted(self, chat_id: int | None, message_ids: list[int]) -> None:
rows = await repository.deleted_messages(
self._pool, self._account_id, chat_id, message_ids
)
for row in rows:
seen = Seen(
row["chat_id"],
row["message_id"],
row["sender_id"],
row["text"],
date=row["date"],
)
if self._own(seen):
continue
for watch_id, params in self.rules.deleted:
if match_chat_scope(params, seen):
await self._fire(
watch_id,
_message_payload("delete", seen),
f"{seen.chat_id}:{seen.message_id}",
)
async def on_status(
self, peer_id: int, status: str, at: datetime, last_online: datetime | None
) -> None:
watch_ids = [
watch_id
for watch_id, params in self.rules.online
if params.peer_id == peer_id
]
if not watch_ids:
return
if status != "online":
self._last_seen[peer_id] = (last_online or at).astimezone(UTC)
return
await self._guard(self._on_online(peer_id, at, watch_ids))
async def _on_online(
self, peer_id: int, at: datetime, watch_ids: list[int]
) -> None:
stored = await repository.last_seen_before(
self._pool, self._account_id, peer_id, at
)
known = [value for value in (stored, self._last_seen.get(peer_id)) if value]
previous = max(known, default=None)
self._last_seen[peer_id] = at
if previous is not None and at - previous < ONLINE_QUIET:
return
session = int((previous or at).timestamp())
payload: dict[str, Any] = {"event": "online", "peer_id": peer_id}
if previous is not None:
payload["since"] = previous.isoformat()
for watch_id in watch_ids:
await self._fire(watch_id, payload, f"{peer_id}:{session}")
@@ -1,24 +1,104 @@
import re import re
from typing import Any from dataclasses import dataclass, field
from datetime import datetime
KIND_KEYWORD = "keyword" from utils.logging import logger
KIND_PEER_ONLINE = "peer_online" from utils.read.models import WatchView
from utils.watch_rules import (
MAX_TEXT,
DeletedParams,
EditedParams,
KeywordParams,
NewMessageParams,
PeerOnlineParams,
WatchParamsError,
compile_pattern,
parse_params,
)
def match_keyword(text: str, chat_id: int, params: dict[str, Any]) -> bool: @dataclass(frozen=True, slots=True)
target_chat = params.get("chat_id") class Seen:
if target_chat is not None and target_chat != chat_id: chat_id: int
return False message_id: int
pattern = params.get("pattern") sender_id: int | None
if not pattern: text: str | None
return False outgoing: bool = False
if params.get("regex"): date: datetime | None = None
edit_date: datetime | None = None
@dataclass(frozen=True, slots=True)
class KeywordRule:
watch_id: int
params: KeywordParams
regex: re.Pattern[str] | None
@dataclass(slots=True)
class Rules:
keyword: list[KeywordRule] = field(default_factory=list)
new_message: list[tuple[int, NewMessageParams]] = field(default_factory=list)
deleted: list[tuple[int, DeletedParams]] = field(default_factory=list)
edited: list[tuple[int, EditedParams]] = field(default_factory=list)
online: list[tuple[int, PeerOnlineParams]] = field(default_factory=list)
def __len__(self) -> int:
return (
len(self.keyword)
+ len(self.new_message)
+ len(self.deleted)
+ len(self.edited)
+ len(self.online)
)
def build_rules(watches: list[WatchView]) -> Rules:
rules = Rules()
for watch in watches:
if not watch.enabled:
continue
try: try:
return re.search(pattern, text) is not None params = parse_params(watch.kind, watch.params)
except re.error: except WatchParamsError as exc:
logger.warning(f"[yellow]Watch {watch.id} skipped:[/] {exc}")
continue
match params:
case KeywordParams():
regex = compile_pattern(params.pattern) if params.regex else None
rules.keyword.append(KeywordRule(watch.id, params, regex))
case NewMessageParams():
rules.new_message.append((watch.id, params))
case DeletedParams():
rules.deleted.append((watch.id, params))
case EditedParams():
rules.edited.append((watch.id, params))
case PeerOnlineParams():
rules.online.append((watch.id, params))
return rules
def _in_scope(chat_id: int | None, sender_id: int | None, seen: Seen) -> bool:
if chat_id is not None and chat_id != seen.chat_id:
return False return False
return pattern.casefold() in text.casefold() return sender_id is None or sender_id == seen.sender_id
def match_peer_online(peer_id: int, params: dict[str, Any]) -> bool: def match_keyword(rule: KeywordRule, seen: Seen) -> bool:
return params.get("peer_id") == peer_id params = rule.params
if not seen.text or not _in_scope(params.chat_id, params.sender_id, seen):
return False
sample = seen.text[:MAX_TEXT]
if rule.regex is not None:
return rule.regex.search(sample) is not None
return params.pattern.casefold() in sample.casefold()
def match_new_message(params: NewMessageParams, seen: Seen) -> bool:
return _in_scope(params.chat_id, params.sender_id, seen)
def match_chat_scope(params: DeletedParams | EditedParams, seen: Seen) -> bool:
if params.chat_id is None:
return seen.chat_id > 0
return params.chat_id == seen.chat_id
@@ -0,0 +1,48 @@
from datetime import datetime
import asyncpg
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
_DELETED = """
SELECT DISTINCT ON (chat_id, message_id) chat_id, message_id, sender_id, text, date
FROM messages
WHERE account_id = $1 AND message_id = ANY($2::bigint[])
AND deleted_at IS NOT NULL AND {scope}
ORDER BY chat_id, message_id, date DESC
"""
_SENDER = """
SELECT sender_id FROM messages
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
ORDER BY date DESC LIMIT 1
"""
_LAST_SEEN = """
SELECT CASE WHEN status = 'online' THEN ts ELSE coalesce(last_online_date, ts) END
FROM presence
WHERE account_id = $1 AND peer_id = $2 AND ts < $3
ORDER BY ts DESC LIMIT 1
"""
async def deleted_messages(
pool: asyncpg.Pool, account_id: int, chat_id: int | None, message_ids: list[int]
) -> list[asyncpg.Record]:
if chat_id is None:
query = _DELETED.format(scope="chat_id > $3")
return await pool.fetch(query, account_id, message_ids, CHANNEL_ID_THRESHOLD)
query = _DELETED.format(scope="chat_id = $3")
return await pool.fetch(query, account_id, message_ids, chat_id)
async def message_sender(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> int | None:
return await pool.fetchval(_SENDER, account_id, chat_id, message_id)
async def last_seen_before(
pool: asyncpg.Pool, account_id: int, peer_id: int, at: datetime
) -> datetime | None:
return await pool.fetchval(_LAST_SEEN, account_id, peer_id, at)
@@ -0,0 +1,22 @@
from datetime import UTC, datetime
from pyrogram.types import Message
from userbot.modules.capture.message import sender_id
from userbot.modules.watches.evaluator import Seen
def utc(moment: datetime | None) -> datetime | None:
return moment.astimezone(UTC) if moment else None
def seen_message(chat_id: int, message: Message) -> Seen:
return Seen(
chat_id=chat_id,
message_id=message.id,
sender_id=sender_id(message),
text=message.text or message.caption,
outgoing=bool(message.outgoing),
date=utc(message.date),
edit_date=utc(message.edit_date),
)
+67 -30
View File
@@ -10,11 +10,14 @@ import asyncpg
import uvloop import uvloop
from dependencies.container import container from dependencies.container import container
from userbot import PyroClient
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.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,
@@ -28,12 +31,14 @@ 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
consumer_task: asyncio.Task tasks: list[asyncio.Task]
device_model: str | None device_model: str | None
@@ -54,6 +59,7 @@ 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
@@ -86,6 +92,8 @@ 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
) )
@@ -100,14 +108,20 @@ class AccountRegistry:
client, self._pool, self._storage, account_id client, self._pool, self._storage, account_id
) )
except Exception: except Exception:
logger.exception(f"[red]Failed to start session:[/] {session_name}") logger.exception(
f"[red]Failed to start session:[/] {session_name}, "
f"retrying in {SESSION_RETRY_INTERVAL}s"
)
with contextlib.suppress(Exception): 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)
self._running[session_name] = RunningAccount( saved = self._saved.pop(session_name)
client, asyncio.create_task(consumer.run()), device_model tasks = [
) asyncio.create_task(consumer.run()),
asyncio.create_task(catch_up(client, saved)),
]
self._running[session_name] = RunningAccount(client, tasks, device_model)
logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})") 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", {})
@@ -116,7 +130,8 @@ 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
await _cancel(account.consumer_task) for task in account.tasks:
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}")
@@ -124,7 +139,8 @@ 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:
await _cancel(account.consumer_task) for task in account.tasks:
await _cancel(task)
client = account.client client = account.client
else: else:
client = PyroClient( client = PyroClient(
@@ -148,9 +164,25 @@ async def _flush_sessions(registry: AccountRegistry) -> None:
await client.storage.save() await client.storage.save()
async def _listen_changes( async def _retry_sessions(registry: AccountRegistry) -> None:
registry: AccountRegistry, tasks: set[asyncio.Task] while True:
) -> asyncpg.Connection: await asyncio.sleep(SESSION_RETRY_INTERVAL)
try:
await registry.sync()
except Exception:
logger.exception("[red]Session sync failed[/]")
async def _keep_uptime(registry: AccountRegistry, pool: asyncpg.Pool) -> None:
while True:
for client in registry.clients:
if client.capture is not None and client.is_connected:
with contextlib.suppress(Exception):
await uptime.heartbeat(pool, client.capture.account_id)
await asyncio.sleep(UPTIME_INTERVAL)
def _change_listener(registry: AccountRegistry, tasks: set[asyncio.Task]) -> Listener:
def spawn(coro: Coroutine[Any, Any, None]) -> None: def spawn(coro: Coroutine[Any, Any, None]) -> None:
task = asyncio.create_task(coro) task = asyncio.create_task(coro)
tasks.add(task) tasks.add(task)
@@ -163,26 +195,29 @@ async def _listen_changes(
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( def on_policy(_payload: str) -> None:
_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( def on_watch(_payload: str) -> None:
_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( def on_accounts(_payload: str) -> None:
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
) -> None:
spawn(registry.sync()) spawn(registry.sync())
conn = await asyncpg.connect(dsn=env.db.connection_url) def on_reconnect() -> None:
await conn.add_listener("policy_changed", on_policy) on_policy("")
await conn.add_listener(WATCHES_CHANGED_CHANNEL, on_watch) on_watch("")
await conn.add_listener(ACCOUNTS_CHANGED_CHANNEL, on_accounts) 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:
@@ -191,22 +226,24 @@ async def runner() -> None:
registry = AccountRegistry(pool, storage) registry = AccountRegistry(pool, storage)
tasks: set[asyncio.Task] = set() tasks: set[asyncio.Task] = set()
listen_conn: asyncpg.Connection | None = None listener = _change_listener(registry, tasks)
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.[/]")
listen_conn = await _listen_changes(registry, tasks) await listener.start()
logger.info("[green]Userbot running.[/]") logger.info("[green]Userbot running.[/]")
await stop.wait() await stop.wait()
finally: finally:
await _cancel(flush) await _cancel(flush)
if listen_conn is not None: await _cancel(retry)
with contextlib.suppress(Exception): await _cancel(beat)
await listen_conn.close() await listener.stop()
await registry.close() await registry.close()
await container.close() await container.close()
+80 -4
View File
@@ -2,8 +2,17 @@ from datetime import datetime
from enum import StrEnum from enum import StrEnum
from typing import Any from typing import Any
from sqlalchemy import BigInteger, Column, DateTime, LargeBinary, Text, func from sqlalchemy import (
from sqlalchemy.dialects.postgresql import JSONB BigInteger,
Column,
DateTime,
ForeignKey,
Integer,
LargeBinary,
Text,
func,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
@@ -203,6 +212,9 @@ 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):
@@ -344,7 +356,7 @@ class ChatHistory(SQLModel, table=True):
title: str | None = None title: str | None = None
photo_unique_id: str | None = None photo_unique_id: str | None = None
actor_id: int | None = Field(default=None, sa_column=Column(BigInteger)) actor_id: int | None = Field(default=None, sa_column=Column(BigInteger))
ts: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False)) ts: datetime = Field(sa_column=Column(DateTime(timezone=True), primary_key=True))
raw: dict[str, Any] = Field( raw: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False) default_factory=dict, sa_column=Column(JSONB, nullable=False)
) )
@@ -442,6 +454,7 @@ class Watch(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
account_id: int account_id: int
kind: str kind: str
name: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
params: dict[str, Any] = Field( params: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False) default_factory=dict, sa_column=Column(JSONB, nullable=False)
) )
@@ -466,7 +479,11 @@ class Alert(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
account_id: int account_id: int
watch_id: int = Field(foreign_key="watches.id") watch_id: int = Field(
sa_column=Column(
Integer, ForeignKey("watches.id", ondelete="CASCADE"), nullable=False
)
)
ts: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False)) ts: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False))
payload: dict[str, Any] = Field( payload: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False) default_factory=dict, sa_column=Column(JSONB, nullable=False)
@@ -543,3 +560,62 @@ class FileShareHit(SQLModel, table=True):
ip: str | None = None ip: str | None = None
user_agent: str | None = None user_agent: str | None = None
counted: bool = False counted: bool = False
class Webhook(SQLModel, table=True):
__tablename__ = "webhooks"
id: int | None = Field(default=None, primary_key=True)
account_id: int
url: str = Field(sa_column=Column(Text, nullable=False))
secret: str = Field(sa_column=Column(Text, nullable=False))
enabled: bool = True
watch_ids: list[int] | None = Field(
default=None, sa_column=Column(ARRAY(Integer), nullable=True)
)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
class WebhookDelivery(SQLModel, table=True):
__tablename__ = "webhook_deliveries"
id: int | None = Field(default=None, sa_column=Column(BigInteger, primary_key=True))
webhook_id: int = Field(
sa_column=Column(
Integer, ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False
)
)
alert_id: int | None = Field(
default=None,
sa_column=Column(
Integer, ForeignKey("alerts.id", ondelete="CASCADE"), nullable=True
),
)
event: str = Field(sa_column=Column(Text, nullable=False))
status: str = Field(default="pending", sa_column=Column(Text, nullable=False))
attempts: int = 0
next_attempt_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
response_code: int | None = None
last_error: str | None = Field(default=None, sa_column=Column(Text))
body: str | None = Field(default=None, sa_column=Column(Text))
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
finished_at: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True))
)
+2
View File
@@ -41,6 +41,7 @@ class ApiSettings(BaseSettings):
host: str = "0.0.0.0" # noqa: S104 host: str = "0.0.0.0" # noqa: S104
port: int = 8080 port: int = 8080
static_dir: str = "static" static_dir: str = "static"
public_url: str | None = None
class AuthSettings(BaseSettings): class AuthSettings(BaseSettings):
@@ -56,6 +57,7 @@ class LogSettings(BaseSettings):
level: str = "INFO" level: str = "INFO"
level_external: str = "WARNING" level_external: str = "WARNING"
show_time: bool = False show_time: bool = False
show_locals: bool = False
console_width: int = 150 console_width: int = 150
+10 -2
View File
@@ -4,6 +4,7 @@ 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"]
@@ -22,8 +23,15 @@ 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 not None: if message_ids is None:
payload["message_ids"] = message_ids await _notify(pool, payload)
return
for start in range(0, len(message_ids), MESSAGE_IDS_PER_EVENT):
chunk = message_ids[start : start + MESSAGE_IDS_PER_EVENT]
await _notify(pool, {**payload, "message_ids": chunk})
async def _notify(pool: asyncpg.Pool, payload: dict[str, object]) -> None:
await pool.execute( 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)
) )
Loaded 100 of 371 files, more files were not shown because too many files have changed in this diff. Show more