Compare commits

..
74 Commits
Author SHA1 Message Date
renovate Bot dd33b14d7d chore(deps): update dependency fastapi to v0.161.1 (dev) (#27)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-26 04:02:40 +00:00
renovate Bot e21e99afae chore(deps): update dependency alembic to v1.20.1 (dev) (#26)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-25 04:02:23 +00:00
renovate Bot 597c6e82dd chore(deps): update dependency pydantic-settings to v2.16.1 (dev) (#25)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-24 04:02:56 +00:00
renovate Bot 9191ef9ad4 chore(deps): update dependency fastapi to v0.146.0 (dev) (#24)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-22 04:02:55 +00:00
renovate Bot 7697ba3e3c chore(deps): update dependency fastmcp to v4.1.1 (dev) (#23)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-21 04:02:25 +00:00
renovate Bot e3559da2a7 chore(deps): update code dependencies (dev) (#21)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-18 04:02:20 +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
hh 283eab1eb0 feat(mcp): download any message file via share link 2026-10-08 21:22:59 +02:00
hh 7f4d500302 chore(infra): update template to v0.5.0 2026-10-08 21:22:59 +02:00
hh cf48d2db3e chore(frontend): update template to v0.4.2 2026-10-08 21:22:59 +02:00
hh 114058e6ae chore(backend): update template to v0.4.0 2026-10-08 21:22:59 +02:00
renovate Bot eb8187ff6e chore(deps): update dependency ultracite to v7.12.2 (dev) (#12)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-03 06:02:55 +02:00
renovate Bot 53dc805fc3 chore(deps): update dependency ultracite to v7.12.1 (dev) (#11)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-01 06:03:12 +02:00
renovate Bot ed8489616e chore(deps): update dependency @biomejs/biome to v2.5.14 (dev) (#10)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-09-20 06:03:12 +02:00
hh b60815d042 fix(watcher): pick an unfinished backlog back up without restarting the bot 2026-09-19 16:19:46 +00:00
hh 468f72ed11 fix(watcher): resume the bot backlog from a saved cursor instead of restarting it 2026-09-19 16:18:40 +00:00
hh 59d3f214e8 chore(renovate): hold typescript on 5.x for svelte-check 2026-09-19 00:33:22 +02:00
hh 34dcbaac2b build(frontend): pin typescript to 5.x and decode tgs stickers with pako toText 2026-09-19 00:21:15 +02:00
hh c8e5cff871 style(frontend): apply ultracite 7.12 formatting and lint rules 2026-09-19 00:21:07 +02:00
hh fc4758aa0b fix(watcher): getDifference needs a date, and only a completed drain counts 2026-09-18 18:42:38 +02:00
hh b0ed041c2e feat(watcher,api,frontend): watched bots become accounts and drain the update backlog 2026-09-18 18:41:20 +02:00
hh 4bd031f21e feat(watcher,api,frontend): archive who writes to watched bots over mtproto 2026-09-18 15:05:01 +02:00
renovate Bot 089a31db8d chore(deps): update dependency ultracite to v7.12.0 (dev) (#9)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-09-18 06:02:46 +02:00
renovate Bot 81ebbdc93e chore(deps): update code dependencies (dev) (#8)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-09-15 23:56:37 +02:00
hh a25f79543a fix(frontend): regenerate bun.lock after dependency bumps 2026-09-15 10:52:23 +00:00
renovate Bot c61ecfe8f2 chore(deps): pin ghcr.io/astral-sh/uv docker tag to 531f855 (#1)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-09-14 06:03:16 +02:00
hh 3d0b15bd2c Merge pull request 'chore(deps): update dependency typescript to v7' (#6) from renovate/typescript-7.x into main
Reviewed-on: #6
2026-09-10 20:00:14 +02:00
hh 0e54786672 Merge pull request 'fix(deps): update dependency pako to v3' (#7) from renovate/pako-3.x into main
Reviewed-on: #7
2026-09-10 20:00:06 +02:00
renovate Bot 5778611a38 fix(deps): update dependency pako to v3
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-09-10 17:58:54 +00:00
renovate Bot b138533d88 chore(deps): update dependency typescript to v7
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-09-10 17:58:51 +00:00
hh 0b6cbac945 chore(infra): update template to v0.4.0 2026-09-10 19:07:01 +02:00
hh ff80141785 refactor(renovate): use shared preset instead of a copy 2026-09-10 17:58:36 +02:00
renovate Bot d206f7e5ca chore(deps): pin oven/bun docker tag to d888c0a (#2)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-09-10 17:44:26 +02:00
hh 4ebe6de60b chore(renovate): add Renovate config 2026-09-10 15:03:44 +00:00
hh 02fb567c1f chore(backend): update template to v0.3.3 2026-09-09 01:17:16 +02:00
hh ccbc167005 chore(*): adopt project templates 2026-09-09 01:17:12 +02:00
hh 5ec0212af5 fix(userbot): mark deletions per row to stay under timescale decompression limit 2026-09-06 21:30:51 +02:00
hh ab59ef4525 feat(api,mcp): explain account_id mixed up with a telegram user id 2026-09-06 21:30:51 +02:00
hh 676680319b fix(userbot): encode absent pyrogram raw payloads as empty json 2026-09-06 21:26:13 +02:00
255 changed files with 9485 additions and 2380 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
{ {
"hooks": [ "hooks": [
{ {
"command": "bun fix --skip=correctness/noUnusedImports", "command": "cd frontend && bun run fix --skip=correctness/noUnusedImports",
"type": "command" "type": "command"
} }
], ],
+17
View File
@@ -0,0 +1,17 @@
_commit: v0.6.0
_src_path: https://git.kotikot.com/templates/backend-python.git
author_email: h@kotikot.com
author_name: h
backend_services:
- api
- kurigram_userbot
database: postgres
dynamic_config: false
install_deps: false
layout: part
project_description: A read-only Telegram client that keeps everything
project_name: beavergram
project_slug: beavergram
serve_spa: true
use_llm: false
use_redis: false
+5
View File
@@ -0,0 +1,5 @@
_commit: v0.1.4
_src_path: https://git.kotikot.com/templates/caddy.git
project_slug: beavergram
routes:
- api
+9
View File
@@ -0,0 +1,9 @@
_commit: v0.7.0
_src_path: https://git.kotikot.com/templates/frontend-svelte.git
install_deps: false
layout: part
linter: biome
project_name: beavergram
project_slug: beavergram
serve: api
ui_library: none
+12
View File
@@ -0,0 +1,12 @@
_commit: v0.6.0
_src_path: https://git.kotikot.com/templates/infra.git
init_git: false
layout: parts
mcp_servers:
- context7
- svelte
parts:
- backend
- frontend
project_description: A read-only Telegram client that keeps everything
project_name: beavergram
+2 -16
View File
@@ -1,16 +1,2 @@
COMPOSE_PROFILES=db,userbot,api COMPOSE_FILE=docker-compose.yml:backend/docker-compose.override.yml:frontend/docker-compose.override.yml
RUN_ENVIRONMENT=prod COMPOSE_PROFILES=external,services
# Leave empty for *.localhost.
FRONTEND_DEV_HOST=
DB__HOST=postgres
DB__PORT=5432
DB__USER=beavergram
DB__PASSWORD=beavergram
DB__DB_NAME=beavergram
DB__MIN_POOL_SIZE=5
DB__MAX_POOL_SIZE=20
DB__SCRIPTS_CONNECTION_URL=postgresql://beavergram:beavergram@localhost:5433/beavergram
STORAGE__ROOT=./storage
+8 -3
View File
@@ -1,6 +1,11 @@
.idea
docker-compose.override.yml
.env .env
.env.*
!.env.example
docker-compose.override.yml
.DS_Store
.idea
docs docs
storage storage
backend/sessions
# Local playground
/t/
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"mcpServers": { "mcpServers": {
"svelte": {
"type": "http",
"url": "https://mcp.svelte.dev/mcp"
},
"context7": { "context7": {
"type": "http", "type": "http",
"url": "https://mcp.context7.com/mcp" "url": "https://mcp.context7.com/mcp"
},
"svelte": {
"type": "http",
"url": "https://mcp.svelte.dev/mcp"
} }
} }
} }
+18 -22
View File
@@ -1,31 +1,27 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.15
hooks:
- id: ruff-check
types_or: [ python, pyi ]
args: [ --fix ]
- id: ruff-format
types_or: [ python, pyi ]
- repo: local - repo: local
hooks: hooks:
- id: ultracite - id: backend-fmt
name: ultracite name: backend fmt
entry: bash -c 'cd frontend && bun x ultracite fix' entry: make -C backend fmt
language: system
files: ^backend/
pass_filenames: false
- id: backend-check
name: backend check
entry: make -C backend check
language: system
files: ^backend/
pass_filenames: false
- id: frontend-fmt
name: frontend fmt
entry: make -C frontend fmt
language: system language: system
files: ^frontend/ files: ^frontend/
pass_filenames: false pass_filenames: false
- id: frontend-check
- id: svelte-check name: frontend check
name: svelte check entry: make -C frontend check
entry: bash -c 'cd frontend && bun check'
language: system language: system
files: ^frontend/ files: ^frontend/
pass_filenames: false pass_filenames: false
- id: ty
name: ty check
entry: uvx ty check
language: python
types_or: [ python, pyi ]
+3 -2
View File
@@ -6,10 +6,11 @@
- **Reusability**: Create reusable components and functions - **Reusability**: Create reusable components and functions
- **Less Code = Less Debt**: Minimize code footprint - **Less Code = Less Debt**: Minimize code footprint
- **NEVER write comments** - code should be self-documenting - **NEVER write comments** - code should be self-documenting
- **Docstrings ONLY if explicitly asked**
## Checking commands ## Checking commands
After writing code, always run: After writing code, always run:
```shell ```shell
bun check # svelte-check make fmt
bun fix # biome linter make check
``` ```
+27 -23
View File
@@ -1,30 +1,34 @@
.PHONY: recreate down restart rebuild deploy migrate session-create frontend PARTS := $(patsubst %/Makefile,%,$(wildcard */Makefile))
.PHONY: env recreate rebuild restart logs down fmt check build
env:
@fresh=; test -f .env || { awk 'FNR == 1 && NR > 1 { print "" } 1' .env.example $(addsuffix /.env.example,$(PARTS)) > .env; fresh=1; }; \
for part in $(PARTS); do \
override=$$part/docker-compose.override.yml; \
test -f $$override && continue; \
cp $$override.example $$override; \
for port in $$(sed -n 's/.*127\.0\.0\.1:\([0-9]*\):.*/\1/p' $$override | sort -u); do \
random=$$((20000 + $$(od -An -N2 -tu2 /dev/urandom) % 40000)); \
sed "s/127\.0\.0\.1:$$port:/127.0.0.1:$$random:/" $$override > $$override.tmp && mv $$override.tmp $$override; \
test -z "$$fresh" || { sed "s|localhost:$$port/|localhost:$$random/|" .env > .env.tmp && mv .env.tmp .env; }; \
done; \
done
recreate: recreate:
docker compose up -d --force-recreate docker compose up -d --force-recreate
rebuild: build
docker compose up -d
restart:
docker compose restart
logs:
docker compose logs -f --tail=100
down: down:
docker compose down docker compose down
restart: fmt check build:
$(MAKE) recreate @for part in $(PARTS); do $(MAKE) -C $$part $@ || exit 1; done
rebuild:
docker compose build
docker compose up -d
frontend:
docker compose run --rm --no-deps frontend-dev sh -c "bun install && bun run build"
deploy:
$(MAKE) frontend
$(MAKE) rebuild
migrate:
docker compose --profile db --profile migrate run --rm migrator $(filter-out $@,$(MAKECMDGOALS))
session-create:
cd backend && uv run python scripts/session/create.py
%:
@:
+12 -11
View File
@@ -12,34 +12,35 @@ Useful on its own: a Telegram where nothing disappears. In the [beaver](https://
- **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 chats and rules that fire alerts, both yours to write in the web client.
- **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API.
Nothing is ever deleted from the archive. Nothing is ever deleted from the archive.
## Parts ## Parts
- `backend/` - `userbot` (kurigram, the capture loop and jobs) 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, search, stories, presence graphs.
- `docker-compose.yml` - Postgres with TimescaleDB, `userbot`, `api`; profiles `db,userbot,api`. - `docker-compose.yml` - includes `backend/` (Postgres with TimescaleDB, `userbot`, `api`) and `frontend/` (the dev server); profiles `external` and `services`.
## MCP ## MCP
Read-only: `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be. Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be.
## Run ## Run
```sh ```sh
cp .env.example .env # DB__*, STORAGE__ROOT, profiles make env # .env and docker-compose.override.yml from the examples
cp backend/.env.example backend/.env # TG__*, API__*, AUTH__TOKEN make -C backend session # log the account in once; the session file stays in backend/sessions
make session-create # log the account in once; the session file stays in backend/sessions make -C backend migrate # the schema
make migrate upgrade head make recreate # build and start the services in COMPOSE_PROFILES
make deploy # build the frontend, build and start the containers
``` ```
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`.
## Develop ## Develop
```sh ```sh
cd backend && uv sync && uv run ruff check && uvx ty check src make fmt
cd frontend && bun install && bun run dev && bun run check make check
make -C frontend dev
``` ```
+7
View File
@@ -0,0 +1,7 @@
.venv
.env
__pycache__
*.pyc
.ruff_cache
.pytest_cache
sessions
+27 -9
View File
@@ -1,14 +1,32 @@
TG__SESSION_NAME=beavergram RUN_ENVIRONMENT=prod
TG__SESSIONS_DIR=sessions TZ=UTC
API__HOST=0.0.0.0
API__PORT=8080
AUTH__TOKEN=<AUTH__TOKEN>
STORAGE__SHARD_DEPTH=2
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__PORT=5432
DB__USER=beavergram
DB__PASSWORD=beavergram
DB__DB_NAME=beavergram
DB__MIN_POOL_SIZE=5
DB__MAX_POOL_SIZE=20
DB__SCRIPTS_CONNECTION_URL=postgresql://beavergram:beavergram@localhost:5432/beavergram
TG__SESSION_NAME=beavergram
TG__SESSIONS_DIR=sessions
BOTS__SESSIONS_DIR=sessions/bots
BOTS__BACKFILL_CHATS=0
BOTS__BACKFILL_PER_CHAT=0
API__HOST=0.0.0.0
API__PORT=8080
AUTH__TOKEN=
STORAGE__ROOT=./storage
STORAGE__SHARD_DEPTH=2
+8
View File
@@ -0,0 +1,8 @@
## Checking commands
After writing code, always run from `backend/`:
```shell
make fmt
make check
```
Mind startup imports: `make check` runs `scripts/check_imports.py`.
+6 -5
View File
@@ -1,4 +1,6 @@
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -7,10 +9,6 @@ ENV PATH="/app/.venv/bin:$PATH" \
UV_COMPILE_BYTECODE=1 \ UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy UV_LINK_MODE=copy
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev RUN uv sync --frozen --no-install-project --no-dev
@@ -20,4 +18,7 @@ RUN uv sync --frozen --no-dev
COPY alembic.ini ./ COPY alembic.ini ./
COPY migrations ./migrations COPY migrations ./migrations
COPY scripts ./scripts
ENTRYPOINT ["python", "-m"] ENTRYPOINT ["python", "-m"]
CMD ["api"]
+28
View File
@@ -0,0 +1,28 @@
COMPOSE := cd .. && docker compose
.PHONY: fmt check build migrate revision session
fmt:
uv run ruff format
uv run ruff check --fix
check:
uv run ruff format --check
uv run ruff check
uv run ty check
uv run python scripts/check_imports.py
build:
$(COMPOSE) build api
migrate:
$(COMPOSE) run --rm migrator $(or $(filter-out $@,$(MAKECMDGOALS)),upgrade head)
revision:
$(COMPOSE) run --rm migrator revision --autogenerate -m "$(m)"
%:
@:
session:
uv run python scripts/session/create.py
@@ -0,0 +1,17 @@
services:
api:
networks:
default: {}
caddy:
aliases:
- beavergram-api
# ports:
# - "127.0.0.1:8080:${API__PORT}"
postgres:
# ports:
# - "127.0.0.1:5432:${DB__PORT}"
networks:
caddy:
external: true
+133
View File
@@ -0,0 +1,133 @@
services:
api:
build: backend
image: beavergram/backend
profiles: [api, services]
restart: unless-stopped
env_file:
- path: .env
required: false
- path: backend/.env
required: false
environment:
RUN_ENVIRONMENT: prod
TZ: ${TZ:-UTC}
STORAGE__ROOT: /app/storage
volumes:
- ./backend/src:/app/src
- ./backend/scripts:/app/scripts
- ./backend/sessions:/app/sessions
- ./frontend/build:/app/static:ro
- ${STORAGE__ROOT:-./storage}:/app/storage
command: [api]
depends_on:
postgres:
condition: service_healthy
required: false
networks:
default:
database:
userbot:
build: backend
image: beavergram/backend
profiles: [userbot, services]
restart: unless-stopped
env_file:
- path: .env
required: false
- path: backend/.env
required: false
environment:
RUN_ENVIRONMENT: prod
TZ: ${TZ:-UTC}
STORAGE__ROOT: /app/storage
volumes:
- ./backend/src:/app/src
- ./backend/scripts:/app/scripts
- ./backend/sessions:/app/sessions
- ${STORAGE__ROOT:-./storage}:/app/storage
command: [userbot]
depends_on:
postgres:
condition: service_healthy
required: false
networks:
database:
watcher:
build: backend
image: beavergram/backend
profiles: [watcher, services]
restart: unless-stopped
env_file:
- path: .env
required: false
- path: backend/.env
required: false
environment:
RUN_ENVIRONMENT: prod
TZ: ${TZ:-UTC}
volumes:
- ./backend/src:/app/src
- ./backend/sessions:/app/sessions
command: [watcher]
depends_on:
postgres:
condition: service_healthy
required: false
networks:
database:
postgres:
image: timescale/timescaledb:2.30.2-pg17@sha256:b346edcdb51a1fd6020e3965e0bd1c9f3406fa6d5fbce1e28f4852587ef934e2
profiles: [postgres, external]
restart: unless-stopped
environment:
POSTGRES_USER: ${DB__USER:-beavergram}
POSTGRES_PASSWORD: ${DB__PASSWORD:-beavergram}
POSTGRES_DB: ${DB__DB_NAME:-beavergram}
PGPORT: ${DB__PORT:-5432}
volumes:
- pgdata:/var/lib/postgresql/data
- ./backend/migrations/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB__USER:-beavergram}"]
interval: 5s
timeout: 5s
retries: 10
networks:
database:
aliases:
- ${DB__HOST:-postgres}
migrator:
build: backend
image: beavergram/backend
profiles: [migrate]
env_file:
- path: .env
required: false
- path: backend/.env
required: false
environment:
RUN_ENVIRONMENT: prod
TZ: ${TZ:-UTC}
volumes:
- ./backend/src:/app/src
- ./backend/migrations:/app/migrations
- ./backend/alembic.ini:/app/alembic.ini
entrypoint: [alembic]
command: [upgrade, head]
depends_on:
postgres:
condition: service_healthy
required: false
networks:
database:
volumes:
pgdata:
networks:
database:
+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,25 @@
"""retry bot backlog
Revision ID: a7f3d2b81c04
Revises: e2c9a41f7b68
Create Date: 2026-09-18 17:30:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "a7f3d2b81c04"
down_revision: str | None = "e2c9a41f7b68"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""No drain has succeeded yet, so let every bot try the queue once more."""
op.execute("UPDATE watched_bots SET backfilled_at = NULL")
def downgrade() -> None:
pass
@@ -0,0 +1,34 @@
"""bot backlog cursor
Revision ID: b6d1f4a82e57
Revises: a7f3d2b81c04
Create Date: 2026-09-19 10:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "b6d1f4a82e57"
down_revision: str | None = "a7f3d2b81c04"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"ALTER TABLE watched_bots "
"ADD COLUMN backfill_pts integer, "
"ADD COLUMN backfill_qts integer, "
"ADD COLUMN backfill_date integer"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE watched_bots "
"DROP COLUMN IF EXISTS backfill_pts, "
"DROP COLUMN IF EXISTS backfill_qts, "
"DROP COLUMN IF EXISTS backfill_date"
)
@@ -0,0 +1,85 @@
"""bot watcher
Revision ID: c8f3b6a2d571
Revises: a6d2f8b41c9e
Create Date: 2026-09-18 12:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "c8f3b6a2d571"
down_revision: str | None = "a6d2f8b41c9e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_BOTS = """
CREATE TABLE watched_bots (
bot_id bigint PRIMARY KEY,
username text,
title text,
enabled boolean NOT NULL DEFAULT true,
status text NOT NULL DEFAULT 'pending',
status_detail text,
backfilled_at timestamptz,
checked_at timestamptz,
added_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
"""
_UPDATES = """
CREATE TABLE bot_updates (
bot_id bigint NOT NULL,
kind text NOT NULL,
chat_id bigint NOT NULL,
item_id bigint NOT NULL,
ts timestamptz NOT NULL,
source text NOT NULL,
outgoing boolean NOT NULL DEFAULT false,
sender_id bigint,
sender_username text,
sender_name text,
sender_is_bot boolean,
chat_type text,
chat_title text,
text text,
media_kind text,
edited_at timestamptz,
received_at timestamptz NOT NULL DEFAULT now(),
raw jsonb NOT NULL,
PRIMARY KEY (bot_id, kind, chat_id, item_id, ts)
)
"""
def upgrade() -> None:
op.execute(_BOTS)
op.execute(_UPDATES)
op.execute(
"SELECT create_hypertable('bot_updates', by_range('ts', INTERVAL '1 month'))"
)
op.execute(
"ALTER TABLE bot_updates SET ("
"timescaledb.enable_columnstore = true, "
"timescaledb.segmentby = 'bot_id', "
"timescaledb.orderby = 'ts DESC')"
)
op.execute(
"CALL add_columnstore_policy('bot_updates', after => INTERVAL '30 days')"
)
op.execute("CREATE INDEX ix_bot_updates_recent ON bot_updates (bot_id, ts DESC)")
op.execute(
"CREATE INDEX ix_bot_updates_sender ON bot_updates "
"(bot_id, sender_id, ts DESC) WHERE NOT outgoing"
)
op.execute(
"CREATE INDEX ix_bot_updates_chat ON bot_updates (bot_id, chat_id, ts DESC)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS bot_updates")
op.execute("DROP TABLE IF EXISTS watched_bots")
@@ -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,61 @@
"""bot accounts
Revision ID: e2c9a41f7b68
Revises: c8f3b6a2d571
Create Date: 2026-09-18 16:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "e2c9a41f7b68"
down_revision: str | None = "c8f3b6a2d571"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_ACCOUNTS = """
INSERT INTO accounts
(tg_user_id, label, phone, session_name, is_active, is_bot, raw)
SELECT b.bot_id,
coalesce(b.title, '@' || b.username, b.bot_id::text),
NULL,
b.bot_id::text,
b.enabled,
true,
jsonb_build_object('id', b.bot_id, 'username', b.username,
'first_name', b.title)
FROM watched_bots b
ON CONFLICT (tg_user_id) DO NOTHING
"""
_LINK = """
UPDATE watched_bots b SET account_id = a.account_id
FROM accounts a WHERE a.tg_user_id = b.bot_id
"""
_MERGE_MESSAGES = """
INSERT INTO messages
(account_id, chat_id, message_id, date, sender_id, text, raw, has_media)
SELECT b.account_id, u.chat_id, u.item_id, u.ts, u.sender_id, u.text, u.raw,
u.media_kind IS NOT NULL
FROM bot_updates u
JOIN watched_bots b ON b.bot_id = u.bot_id
WHERE u.kind = 'message' AND b.account_id IS NOT NULL
ON CONFLICT (account_id, chat_id, message_id, date) DO NOTHING
"""
def upgrade() -> None:
op.execute("ALTER TABLE accounts ADD COLUMN is_bot boolean NOT NULL DEFAULT false")
op.execute("ALTER TABLE watched_bots ADD COLUMN account_id integer")
op.execute(_ACCOUNTS)
op.execute(_LINK)
op.execute(_MERGE_MESSAGES)
op.execute("DROP TABLE IF EXISTS bot_updates")
def downgrade() -> None:
op.execute("ALTER TABLE watched_bots DROP COLUMN IF EXISTS account_id")
op.execute("ALTER TABLE accounts DROP COLUMN IF EXISTS is_bot")
+4 -3
View File
@@ -23,22 +23,23 @@ dependencies = [
] ]
[build-system] [build-system]
requires = ["uv_build>=0.11.14,<0.12.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]
module-name = ["userbot", "api", "utils", "dependencies"] module-name = ["userbot", "api", "watcher", "utils", "dependencies"]
[tool.ruff] [tool.ruff]
target-version = "py313" target-version = "py313"
[tool.ruff.lint] [tool.ruff.lint]
select = ["ALL"] select = ["ALL"]
ignore = ["CPY", "D1", "D203", "D212", "COM812", "BLE", "S101", "PT", "RUF001", "RUF002", "RUF003", "TRY301"] ignore = ["CPY", "D1", "D203", "D212", "COM812", "BLE", "S101", "PT", "PLR0917", "RUF001", "RUF002", "RUF003", "TRY301"]
unfixable = ["F401"] 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())
+14
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
@@ -18,6 +20,7 @@ from api.routers import (
annotations, annotations,
avatars, avatars,
backfill, backfill,
bots,
chats, chats,
custom_emoji, custom_emoji,
discover, discover,
@@ -32,18 +35,23 @@ from api.routers import (
search, search,
shares, shares,
social, social,
storage,
stories, stories,
watches, watches,
) )
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="/")
@@ -51,7 +59,11 @@ mcp_app = mcp.http_app(path="/")
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)
await hub.start(pool) await hub.start(pool)
purger = asyncio.create_task(
purge_loop(pool, await container.get(ContentAddressedStorage))
)
yield yield
purger.cancel()
await hub.stop() await hub.stop()
await app_.state.dishka_container.close() await app_.state.dishka_container.close()
@@ -73,6 +85,7 @@ async def health(pool: FromDishka[asyncpg.Pool]) -> dict[str, bool]:
app.include_router(accounts.router) app.include_router(accounts.router)
app.include_router(analytics.router) app.include_router(analytics.router)
app.include_router(bots.router)
app.include_router(policy.router) app.include_router(policy.router)
app.include_router(folders.router) app.include_router(folders.router)
app.include_router(backfill.router) app.include_router(backfill.router)
@@ -91,6 +104,7 @@ 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(shares.router) app.include_router(shares.router)
app.include_router(storage.router)
app.include_router(files.router) app.include_router(files.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
+113
View File
@@ -0,0 +1,113 @@
import asyncio
from pathlib import Path
from typing import Any
import asyncpg
from fastmcp.exceptions import ToolError
from fastmcp.server.dependencies import get_http_request
from fastmcp.utilities.types import Image
from mcp.types import TextContent
from utils.files import expiry_from_seconds
from utils.jobs import enqueue
from utils.read import chats, media, shares
from utils.read.models import FileShareView, MediaView
from utils.storage import ContentAddressedStorage
LINK_TTL_SECONDS = 24 * 3600
MAX_WAIT_SECONDS = 120
_POLL_SECONDS = 1.0
_INLINE_IMAGE_BYTES = 5 * 1024 * 1024
_INLINE_TEXT_BYTES = 256 * 1024
_INLINE_IMAGE_MIMES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
_TEXT_MIMES = {"application/json", "application/xml", "application/x-yaml"}
_FAILED_JOB = ("failed", "canceled")
async def _job_error(pool: asyncpg.Pool, job_id: int) -> str | None:
row = await pool.fetchrow("SELECT status, error FROM jobs WHERE id = $1", job_id)
if row is None or row["status"] in _FAILED_JOB:
return (row and row["error"]) or "download job is gone"
return None
async def fetch_media(
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
message_id: int,
wait_seconds: float,
) -> MediaView | None:
item = await media.get_message_media(pool, account_id, chat_id, message_id)
if item is None:
msg = f"message {chat_id}/{message_id} has no media"
raise ToolError(msg)
if item.downloaded and item.storage_key:
return item
job_id = await enqueue(
pool, account_id, "fetch_media", {"chat_id": chat_id, "message_id": message_id}
)
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_seconds
while loop.time() < deadline:
await asyncio.sleep(_POLL_SECONDS)
item = await media.get_message_media(pool, account_id, chat_id, message_id)
if item is not None and item.downloaded and item.storage_key:
return item
error = await _job_error(pool, job_id)
if error is not None:
msg = f"download failed: {error}"
raise ToolError(msg)
return None
def _origin() -> str:
request = get_http_request()
proto = request.headers.get("x-forwarded-proto", request.url.scheme)
return f"{proto}://{request.url.netloc}"
async def share_media(pool: asyncpg.Pool, item: MediaView, mime: str) -> FileShareView:
storage_key = item.storage_key or ""
share = await shares.find_active_share(pool, item.account_id, storage_key)
if share is None:
chat = await chats.get_chat(pool, item.account_id, item.chat_id)
share = await shares.create_share(
pool,
item.account_id,
"media",
storage_key,
item.file_name or f"media_{item.id}",
mime=mime,
file_size=item.file_size,
title=chat.title if chat else None,
chat_id=item.chat_id,
message_id=item.message_id,
expires_at=expiry_from_seconds(LINK_TTL_SECONDS),
)
return share
def _is_text(mime: str) -> bool:
return mime.startswith("text/") or mime in _TEXT_MIMES
def inline_content(storage: ContentAddressedStorage, key: str, mime: str) -> list[Any]:
size = Path(storage.url(key)).stat().st_size
if mime in _INLINE_IMAGE_MIMES and size <= _INLINE_IMAGE_BYTES:
return [Image(data=storage.get(key), format=mime.rsplit("/", maxsplit=1)[-1])]
if _is_text(mime) and size <= _INLINE_TEXT_BYTES:
text = storage.get(key).decode("utf-8", errors="replace")
return [TextContent(type="text", text=text)]
return []
def describe(item: MediaView, mime: str, share: FileShareView) -> str:
size = f"{item.file_size} bytes" if item.file_size else "size unknown"
expires = share.expires_at.isoformat() if share.expires_at else "never"
return (
f"{item.file_name} ({item.kind}, {mime}, {size})\n"
f"Download: {_origin()}/f/{share.token}/{share.url_name}\n"
f"Public link, expires {expires}; fetch it with curl/wget or any HTTP "
"client, no auth needed."
)
+79 -18
View File
@@ -4,13 +4,24 @@ from typing import Any
import asyncpg import asyncpg
from fastmcp import FastMCP from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.tools.base import ToolResult
from mcp.types import CallToolRequestParams, TextContent
from pydantic import BaseModel from pydantic import BaseModel
from api.mcp.download import (
MAX_WAIT_SECONDS,
describe,
fetch_media,
inline_content,
share_media,
)
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.jobs import enqueue from utils.files import resolve_mime
from utils.read import annotations, chats, media, peers, presence, social, watches from utils.read import annotations, chats, media, peers, presence, social, watches
from utils.read.accounts import self_user_id from utils.read.accounts import self_user_id, unknown_account_hint
from utils.read.models import DEFAULT_LIMIT, Page from utils.read.models import DEFAULT_LIMIT, Page
from utils.search.models import SearchFilters from utils.search.models import SearchFilters
from utils.search.repository import search_messages from utils.search.repository import search_messages
@@ -23,6 +34,9 @@ peer history) into Postgres and exposes it read-only over these tools.
Account scoping: Account scoping:
- Every tool takes `account_id`. It selects which archived Telegram account to - Every tool takes `account_id`. It selects which archived Telegram account to
read. Unless the user names a different one, always pass `account_id=1`. read. Unless the user names a different one, always pass `account_id=1`.
- `account_id` is beavergram's own small serial (1, 2, ...), never the archived
account's Telegram user id. Passing a Telegram id returns an error naming the
right `account_id`.
Identifiers: Identifiers:
- `chat_id` and `peer_id` are Telegram IDs (negative for groups/channels, - `chat_id` and `peer_id` are Telegram IDs (negative for groups/channels,
@@ -37,7 +51,7 @@ Typical flows:
`before_id` (first id you saw), to newer with `after_id` (last id you saw); `before_id` (first id you saw), to newer with `after_id` (last id you saw);
begin a full forward walk with `after_id=0`. Each transcript line starts begin a full forward walk with `after_id=0`. Each transcript line starts
with its `#message_id`, and photos show as `[photo #message_id]` — pass that with its `#message_id`, and photos show as `[photo #message_id]` — pass that
id to `get_media` (with `fetch=True` to download) or to annotation tools. id to `download_media` or to annotation tools.
Lines marked Lines marked
"📝 [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
@@ -46,12 +60,21 @@ Typical flows:
transcripts; supports chat/sender/date filters and regex). transcripts; supports chat/sender/date filters and regex).
- Forensics: `get_deleted_messages` and `get_message_versions` recover content - Forensics: `get_deleted_messages` and `get_message_versions` recover content
removed or edited in Telegram but kept in the archive. removed or edited in Telegram but kept in the archive.
- Media: `get_media` returns metadata; pass `fetch=True` to enqueue a lazy - Files: `download_media` gets any message attachment (photo, video, voice,
download if the file isn't stored yet. 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
the content itself for small images and text files. `get_media` returns
metadata only.
- Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and - Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and
`list_alerts` reads what those rules fired. `list_alerts` reads what those rules fired.
Writes: everything is read-only except `set_watch`, the only allowed write. Watched bots:
- a bot whose token the user added is just another archived account: it has its
own `account_id` and its chats, messages and media read through the same
tools as a user account. Nothing bot-specific to call.
Writes: everything is read-only except `set_watch` and the share links
`download_media` creates (visible and revocable in the web UI).
Paging: list/search tools accept `limit` and `offset`. Paging: list/search tools accept `limit` and `offset`.
""" """
@@ -63,6 +86,25 @@ async def _pool() -> asyncpg.Pool:
return await container.get(asyncpg.Pool) return await container.get(asyncpg.Pool)
class AccountGuard(Middleware):
"""Reject tool calls scoped to an account_id that does not exist."""
async def on_call_tool(
self,
context: MiddlewareContext[CallToolRequestParams],
call_next: CallNext[CallToolRequestParams, ToolResult],
) -> ToolResult:
account_id = (context.message.arguments or {}).get("account_id")
if isinstance(account_id, int) and not isinstance(account_id, bool):
hint = await unknown_account_hint(await _pool(), account_id)
if hint is not None:
raise ToolError(hint)
return await call_next(context)
mcp.add_middleware(AccountGuard())
def _dump(items: Sequence[BaseModel]) -> list[dict[str, Any]]: def _dump(items: Sequence[BaseModel]) -> list[dict[str, Any]]:
return [item.model_dump(mode="json") for item in items] return [item.model_dump(mode="json") for item in items]
@@ -208,21 +250,40 @@ async def get_message_versions(
@mcp.tool @mcp.tool
async def get_media( async def get_media(
account_id: int, chat_id: int, message_id: int, fetch: bool = False account_id: int, chat_id: int, message_id: int
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Get media metadata for a message; set fetch=True to enqueue lazy download.""" """Get media metadata for a message; use download_media to get the file."""
item = await media.get_message_media(await _pool(), account_id, chat_id, message_id)
return item.model_dump(mode="json") if item else None
@mcp.tool(output_schema=None)
async def download_media(
account_id: int, chat_id: int, message_id: int, wait_seconds: int = 45
) -> list[Any]:
"""Download the file attached to a message: any photo, video, voice, document.
Pulls it from Telegram first if the archive doesn't have it yet, waiting up
to `wait_seconds`. Returns a public download link (valid 24h, no auth) and
inlines small images and text files.
"""
pool = await _pool() pool = await _pool()
item = await media.get_message_media(pool, account_id, chat_id, message_id) wait = min(max(wait_seconds, 0), MAX_WAIT_SECONDS)
if item is None: item = await fetch_media(pool, account_id, chat_id, message_id, wait)
return None if item is None or item.storage_key is None:
if fetch and not item.downloaded: return [
await enqueue( TextContent(
pool, type="text",
account_id, text="Still downloading from Telegram; call download_media again.",
"fetch_media",
{"chat_id": chat_id, "message_id": message_id},
) )
return item.model_dump(mode="json") ]
mime = resolve_mime(item.kind, item.mime, item.file_name)
share = await share_media(pool, item, mime)
storage = await container.get(ContentAddressedStorage)
return [
TextContent(type="text", text=describe(item, mime, share)),
*inline_content(storage, item.storage_key, mime),
]
@mcp.tool @mcp.tool
+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
+42
View File
@@ -0,0 +1,42 @@
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from utils.read import bots
from utils.read.models import BotView
from watcher import tokens
router = APIRouter(prefix="/api/bots", tags=["bots"], route_class=DishkaRoute)
INVALID_TOKEN = "token must look like 123456:ABC-DEF..." # noqa: S105
class BotAdd(BaseModel):
token: str
@router.get("")
async def list_bots(pool: FromDishka[asyncpg.Pool]) -> list[BotView]:
return await bots.list_bots(pool)
@router.post("", status_code=201)
async def add_bot(pool: FromDishka[asyncpg.Pool], body: BotAdd) -> BotView:
bot_id = tokens.bot_id_of(body.token)
if bot_id is None:
raise HTTPException(status_code=422, detail=INVALID_TOKEN)
tokens.save(bot_id, body.token)
await bots.register_bot(pool, bot_id)
await bots.notify_bots_changed(pool)
bot = await bots.get_bot(pool, bot_id)
if bot is None:
raise HTTPException(status_code=500, detail="bot was not registered")
return bot
@router.delete("/{bot_id}", status_code=204)
async def remove_bot(pool: FromDishka[asyncpg.Pool], bot_id: int) -> None:
if not await bots.remove_bot(pool, bot_id):
raise HTTPException(status_code=404, detail="unknown bot")
await bots.notify_bots_changed(pool)
+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 -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
+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,
)
-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"]
+2 -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
@@ -30,4 +30,4 @@ async def on_deleted_messages(client: PyroClient, messages: list[Message]) -> No
) )
handlers = on_deleted_messages.handlers handlers = on_deleted_messages.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 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 sender_id
from userbot.modules.client import PyroClient
from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl from userbot.modules.media import capture_media, media_unique_id, self_destruct_ttl
from userbot.modules.raw_json import raw_json
from utils.events import notify_bg_event from utils.events import notify_bg_event
@@ -33,7 +34,7 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
message.date, message.date,
sender_id(message), sender_id(message),
message.text or message.caption, message.text or message.caption,
str(message), raw_json(message),
message.edit_date, message.edit_date,
media_unique_id(message), media_unique_id(message),
has_media=message.media is not None, has_media=message.media is not None,
@@ -48,4 +49,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]
+2 -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 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 utils.events import notify_bg_event from utils.events import notify_bg_event
@@ -36,4 +36,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]
+4 -3
View File
@@ -2,8 +2,9 @@ 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 utils.events import notify_bg_event from utils.events import notify_bg_event
@@ -20,10 +21,10 @@ async def on_user_status(client: PyroClient, user: User) -> None:
user.status.name.lower(), user.status.name.lower(),
user.last_online_date, user.last_online_date,
user.next_offline_date, user.next_offline_date,
str(user.raw), raw_json(user.raw),
) )
await ctx.watches.on_status(user.id, is_online=user.status.name.lower() == "online") 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)
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]
+9 -6
View File
@@ -2,11 +2,12 @@ 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
from userbot.modules.raw_json import raw_json
HANDLES = (raw.types.UpdateUserName, raw.types.UpdateUser, raw.types.UpdateUserPhone) HANDLES = (raw.types.UpdateUserName, raw.types.UpdateUser, raw.types.UpdateUserPhone)
@@ -20,11 +21,13 @@ 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
await write_profile(ctx.pool, ctx.account_id, update.user_id, fields, str(raw_user)) await write_profile(
ctx.pool, ctx.account_id, update.user_id, fields, raw_json(raw_user)
)
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:
await capture_avatar( await capture_avatar(
@@ -34,7 +37,7 @@ async def _handle_user(
"peer", "peer",
photo_file_id, photo_file_id,
photo_unique_id, photo_unique_id,
str(raw_user), raw_json(raw_user),
) )
@@ -65,9 +68,9 @@ async def handle(
"last_name": update.last_name or None, "last_name": update.last_name or None,
"username": active_username(update.usernames), "username": active_username(update.usernames),
}, },
str(update), raw_json(update),
) )
elif isinstance(update, raw.types.UpdateUserPhone): elif isinstance(update, raw.types.UpdateUserPhone):
await _merge_partial( await _merge_partial(
client, update.user_id, {"phone": update.phone or None}, str(update) client, update.user_id, {"phone": update.phone or None}, raw_json(update)
) )
@@ -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,9 @@ 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.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
from userbot.modules.stt.gate import safe_transcribe from userbot.modules.stt.gate import safe_transcribe
@@ -41,7 +42,7 @@ async def handle(
cand_chat_id, cand_chat_id,
datetime.now(UTC), datetime.now(UTC),
message_id, message_id,
str(update), raw_json(update),
) )
continue continue
if not untranscribed: if not untranscribed:
@@ -2,7 +2,8 @@ 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.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
@@ -23,7 +24,7 @@ async def handle(
chat_id, chat_id,
datetime.now(UTC), datetime.now(UTC),
update.max_id, update.max_id,
str(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=update.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,)
+2 -2
View File
@@ -1,6 +1,6 @@
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
@@ -11,4 +11,4 @@ async def on_story(client: PyroClient, story: Story) -> None:
await save_story(client, client.capture, story) await save_story(client, client.capture, story)
handlers = on_story.handlers handlers = on_story.handlers # ty: ignore[unresolved-attribute]
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
from userbot.modules.capture.chat_meta import chat_kind from userbot.modules.capture.chat_meta import chat_kind
from userbot.modules.profiles.parse import ProfileFields, snapshot_from_high_level from userbot.modules.profiles.parse import ProfileFields, snapshot_from_high_level
from userbot.modules.profiles.repository import get_peer, write_profile from userbot.modules.profiles.repository import get_peer, write_profile
from userbot.modules.raw_json import raw_json
from utils.policy.models import ChatKind from utils.policy.models import ChatKind
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -40,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)
@@ -59,7 +58,7 @@ async def _capture_peer(message: Message, ctx: CaptureContext) -> None:
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user) fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields): if not await ctx.peer_identity.changed(ctx.pool, ctx.account_id, user.id, fields):
return return
await write_profile(ctx.pool, ctx.account_id, user.id, fields, str(user)) await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
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
@@ -95,7 +94,7 @@ async def _capture_chat(message: Message, ctx: CaptureContext) -> None:
photo_unique_id, photo_unique_id,
None, None,
message.date, message.date,
str(message), raw_json(message),
) )
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
@@ -6,6 +6,7 @@ from userbot.modules.capture.context import CaptureContext
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, self_destruct_ttl
from userbot.modules.raw_json import raw_json
from utils.policy.models import CaptureToggles from utils.policy.models import CaptureToggles
@@ -47,7 +48,7 @@ async def capture_message(
message.date, message.date,
sender_id(message), sender_id(message),
message.text or message.caption, message.text or message.caption,
str(message), raw_json(message),
has_media=message.media is not None, has_media=message.media is not None,
is_self_destruct=self_destruct_ttl(message) is not None, is_self_destruct=self_destruct_ttl(message) is not None,
) )
@@ -68,5 +69,5 @@ async def capture_message(
links = extract_links(message) links = extract_links(message)
if links: if links:
await links_repository.insert_links( await links_repository.insert_links(
ctx.pool, ctx.account_id, chat_id, message.id, links, str(message) ctx.pool, ctx.account_id, chat_id, message.id, links, raw_json(message)
) )
@@ -30,6 +30,16 @@ ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
edited_at = now() edited_at = now()
""" """
_FIND_DELETED = """
SELECT chat_id, message_id, date FROM messages
WHERE account_id = $1 AND message_id = ANY($2::bigint[]) AND deleted_at IS NULL
"""
_MARK_DELETED = """
UPDATE messages SET deleted_at = now()
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 AND date = $4
"""
_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)
@@ -78,8 +88,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,
@@ -87,7 +97,27 @@ 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)
""" """
@@ -128,30 +158,34 @@ async def max_message_id(
) )
async def _mark_deleted(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> None:
for row in rows:
await pool.execute(
_MARK_DELETED, account_id, row["chat_id"], row["message_id"], row["date"]
)
async def mark_deleted_box( async def mark_deleted_box(
pool: asyncpg.Pool, account_id: int, message_ids: list[int] pool: asyncpg.Pool, account_id: int, message_ids: list[int]
) -> None: ) -> None:
await pool.execute( rows = await pool.fetch(
"UPDATE messages SET deleted_at = now() " f"{_FIND_DELETED} AND chat_id > $3",
"WHERE account_id = $1 AND message_id = ANY($2::bigint[]) "
"AND chat_id > $3 AND deleted_at IS NULL",
account_id, account_id,
message_ids, message_ids,
CHANNEL_ID_THRESHOLD, CHANNEL_ID_THRESHOLD,
) )
await _mark_deleted(pool, account_id, rows)
async def mark_deleted_channel( async def mark_deleted_channel(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_ids: list[int] pool: asyncpg.Pool, account_id: int, chat_id: int, message_ids: list[int]
) -> None: ) -> None:
await pool.execute( rows = await pool.fetch(
"UPDATE messages SET deleted_at = now() " f"{_FIND_DELETED} AND chat_id = $3", account_id, message_ids, chat_id
"WHERE account_id = $1 AND chat_id = $2 AND message_id = ANY($3::bigint[]) "
"AND deleted_at IS NULL",
account_id,
chat_id,
message_ids,
) )
await _mark_deleted(pool, account_id, rows)
async def add_version( # noqa: PLR0913 async def add_version( # noqa: PLR0913
@@ -215,6 +249,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(
@@ -229,6 +264,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(
@@ -245,6 +281,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,
+9 -3
View File
@@ -2,6 +2,8 @@ from typing import TYPE_CHECKING
from pyrogram import Client, enums from pyrogram import Client, enums
from utils.telegram import API_HASH, API_ID
if TYPE_CHECKING: if TYPE_CHECKING:
from userbot.modules.capture import CaptureContext from userbot.modules.capture import CaptureContext
@@ -11,19 +13,23 @@ DEVICE_MODEL_LIMIT = 32
class PyroClient(Client): class PyroClient(Client):
def __init__( def __init__( # noqa: PLR0913
self, self,
name: str, name: str,
*, *,
workdir: str = "sessions", workdir: str = "sessions",
device_model: str | None = None, device_model: str | None = None,
load_handlers: bool = True, load_handlers: bool = True,
bot_token: str | None = None,
skip_updates: bool = True,
) -> None: ) -> None:
super().__init__( super().__init__(
name, name,
workdir=workdir, workdir=workdir,
api_id=2040, api_id=API_ID,
api_hash="b18441a1ff607e10a989891a5462e627", api_hash=API_HASH,
bot_token=bot_token,
skip_updates=skip_updates,
device_model=device_model or DEVICE_MODEL, device_model=device_model or DEVICE_MODEL,
system_version="Windows 11 x64", system_version="Windows 11 x64",
app_version="7.0.8 x64", app_version="7.0.8 x64",
+13 -6
View File
@@ -5,6 +5,7 @@ from userbot.modules.avatars import capture_avatar
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
from userbot.modules.capture.message import sender_id from userbot.modules.capture.message import sender_id
from userbot.modules.groups import repository from userbot.modules.groups import repository
from userbot.modules.raw_json import raw_json
async def capture_service( async def capture_service(
@@ -28,7 +29,7 @@ async def capture_service(
None, None,
actor, actor,
ts, ts,
str(message), raw_json(message),
) )
elif service is enums.MessageServiceType.NEW_CHAT_PHOTO: elif service is enums.MessageServiceType.NEW_CHAT_PHOTO:
photo = message.new_chat_photo photo = message.new_chat_photo
@@ -43,11 +44,17 @@ async def capture_service(
unique_id, unique_id,
actor, actor,
ts, ts,
str(message), raw_json(message),
) )
if photo is not None and unique_id is not None: if photo is not None and unique_id is not None:
await capture_avatar( await capture_avatar(
client, ctx, chat_id, "chat", photo.file_id, unique_id, str(message) client,
ctx,
chat_id,
"chat",
photo.file_id,
unique_id,
raw_json(message),
) )
elif service is enums.MessageServiceType.DELETE_CHAT_PHOTO: elif service is enums.MessageServiceType.DELETE_CHAT_PHOTO:
await repository.insert_chat_history( await repository.insert_chat_history(
@@ -60,7 +67,7 @@ async def capture_service(
None, None,
actor, actor,
ts, ts,
str(message), raw_json(message),
) )
elif service is enums.MessageServiceType.NEW_CHAT_MEMBERS: elif service is enums.MessageServiceType.NEW_CHAT_MEMBERS:
for member in message.new_chat_members or []: for member in message.new_chat_members or []:
@@ -73,7 +80,7 @@ async def capture_service(
"join", "join",
actor, actor,
ts, ts,
str(message), raw_json(message),
) )
elif service is enums.MessageServiceType.LEFT_CHAT_MEMBER: elif service is enums.MessageServiceType.LEFT_CHAT_MEMBER:
member = message.left_chat_member member = message.left_chat_member
@@ -87,5 +94,5 @@ async def capture_service(
"leave", "leave",
actor, actor,
ts, ts,
str(message), raw_json(message),
) )
@@ -8,6 +8,7 @@ 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.stories.service import save_story from userbot.modules.stories.service import save_story
from utils.logging import logger
SAVE_EVERY = 10 SAVE_EVERY = 10
@@ -38,6 +39,9 @@ async def _drain(
raise raise
except RPCError: except RPCError:
continue continue
except Exception:
logger.exception(f"Failed to save story {story.id} from {name}")
continue
saved += 1 saved += 1
if saved % SAVE_EVERY == 0: if saved % SAVE_EVERY == 0:
await ctx.report_progress({"saved": saved, "source": name}) await ctx.report_progress({"saved": saved, "source": name})
@@ -11,6 +11,7 @@ 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.parse import 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
MEMBER_CAP = 200 MEMBER_CAP = 200
@@ -26,7 +27,7 @@ LIMIT 100
async def _save_user(ctx: CaptureContext, user: User) -> None: async def _save_user(ctx: CaptureContext, user: User) -> 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)
await write_profile(ctx.pool, ctx.account_id, user.id, fields, str(user)) await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
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, user.id, "peer", photo_unique_id, photo_file_id ctx.pool, ctx.account_id, user.id, "peer", photo_unique_id, photo_file_id
@@ -35,6 +36,8 @@ async def _save_user(ctx: CaptureContext, user: User) -> None:
async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None: async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None:
chat = await client.get_chat(chat_id) chat = await client.get_chat(chat_id)
if chat is None:
return
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
@@ -48,7 +51,7 @@ async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -
photo_unique_id, photo_unique_id,
None, None,
datetime.now(UTC), datetime.now(UTC),
str(chat), raw_json(chat),
) )
if photo_file_id and photo_unique_id: if photo_file_id and photo_unique_id:
await note_avatar( await note_avatar(
@@ -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)
@@ -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)
@@ -5,6 +5,7 @@ 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.parse import 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
@register("sync_contacts") @register("sync_contacts")
@@ -21,7 +22,7 @@ async def sync_contacts(ctx: JobContext) -> None:
if not isinstance(user, User): if not isinstance(user, User):
continue continue
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user) fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
await write_profile(ctx.pool, ctx.account_id, user.id, fields, str(user)) await write_profile(ctx.pool, ctx.account_id, user.id, fields, raw_json(user))
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.pool,
@@ -9,6 +9,8 @@ from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level from userbot.modules.profiles.parse import snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile from userbot.modules.profiles.repository import write_profile
from userbot.modules.profiles.snapshots import save_group, save_private from userbot.modules.profiles.snapshots import save_group, save_private
from userbot.modules.raw_json import raw_json
from utils.logging import logger
SAVE_EVERY = 100 SAVE_EVERY = 100
USERS_BATCH = 200 USERS_BATCH = 200
@@ -31,7 +33,9 @@ async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) ->
if not isinstance(user, User): if not isinstance(user, User):
continue continue
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user) fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
await write_profile(ctx.pool, ctx.account_id, user.id, fields, str(user)) await write_profile(
ctx.pool, ctx.account_id, user.id, fields, raw_json(user)
)
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.pool,
@@ -66,6 +70,8 @@ async def sync_dialogs(ctx: JobContext) -> None:
await save_group(capture, chat) await save_group(capture, chat)
except (BadRequest, Forbidden): except (BadRequest, Forbidden):
pass pass
except Exception:
logger.exception(f"Failed to snapshot dialog {chat_id}")
await ctx.pool.execute(_UPSERT_DIALOG, ctx.account_id, chat_id) await ctx.pool.execute(_UPSERT_DIALOG, ctx.account_id, chat_id)
processed += 1 processed += 1
if processed % SAVE_EVERY == 0: if processed % SAVE_EVERY == 0:
@@ -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
@@ -21,10 +21,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
@@ -7,12 +7,13 @@ from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history from userbot.modules.groups.repository import insert_chat_history
from userbot.modules.profiles.parse import snapshot_from_chat from userbot.modules.profiles.parse import snapshot_from_chat
from userbot.modules.profiles.repository import write_profile from userbot.modules.profiles.repository import write_profile
from userbot.modules.raw_json import raw_json
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, str(chat)) await write_profile(ctx.pool, ctx.account_id, chat_id, fields, raw_json(chat))
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
@@ -35,7 +36,7 @@ async def save_group(ctx: CaptureContext, chat: Chat) -> None:
photo_unique_id, photo_unique_id,
None, None,
datetime.now(UTC), datetime.now(UTC),
str(chat), raw_json(chat),
) )
if photo_file_id and photo_unique_id: if photo_file_id and photo_unique_id:
await note_avatar( await note_avatar(
+23
View File
@@ -0,0 +1,23 @@
import json
from typing import Any
from pyrogram.raw.core import TLObject
from pyrogram.types import Contact, Object
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:
if obj is None:
return EMPTY_JSON
return json.dumps(obj, indent=4, default=_default, ensure_ascii=False)
@@ -3,6 +3,7 @@ from pyrogram.types import Story
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
from userbot.modules.download import download_bytes from userbot.modules.download import download_bytes
from userbot.modules.raw_json import raw_json
from userbot.modules.stories import repository from userbot.modules.stories import repository
@@ -40,7 +41,7 @@ async def save_story(client: Client, capture: CaptureContext, story: Story) -> N
storage_key, storage_key,
file_size, file_size,
story.views, story.views,
str(story.raw), raw_json(story.raw),
pinned=bool(story.pinned), pinned=bool(story.pinned),
deleted=bool(story.deleted), deleted=bool(story.deleted),
downloaded=downloaded, downloaded=downloaded,
+1 -1
View File
@@ -10,8 +10,8 @@ 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.client import PyroClient
from userbot.modules.jobs import JobConsumer from userbot.modules.jobs import JobConsumer
from utils.env import env from utils.env import env
from utils.jobs import enqueue from utils.jobs import enqueue
+8
View File
@@ -31,6 +31,12 @@ class TelegramSettings(BaseSettings):
sessions_dir: str = "sessions" sessions_dir: str = "sessions"
class BotsSettings(BaseSettings):
sessions_dir: str = "sessions/bots"
backfill_per_chat: int = 0
backfill_chats: int = 0
class ApiSettings(BaseSettings): 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
@@ -50,12 +56,14 @@ 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
class Settings(BaseSettings): class Settings(BaseSettings):
db: DatabaseSettings = Field(default_factory=DatabaseSettings) db: DatabaseSettings = Field(default_factory=DatabaseSettings)
tg: TelegramSettings = Field(default_factory=TelegramSettings) tg: TelegramSettings = Field(default_factory=TelegramSettings)
bots: BotsSettings = Field(default_factory=BotsSettings)
api: ApiSettings = Field(default_factory=ApiSettings) api: ApiSettings = Field(default_factory=ApiSettings)
auth: AuthSettings = Field(default_factory=AuthSettings) auth: AuthSettings = Field(default_factory=AuthSettings)
storage: StorageSettings = Field(default_factory=StorageSettings) storage: StorageSettings = Field(default_factory=StorageSettings)
+6
View File
@@ -0,0 +1,6 @@
import asyncio
from importlib import import_module
async def preload(module: str) -> None:
await asyncio.to_thread(import_module, module)
+2 -2
View File
@@ -20,13 +20,13 @@ def setup_logging() -> None:
markup=True, markup=True,
rich_tracebacks=True, rich_tracebacks=True,
enable_link_path=False, enable_link_path=False,
tracebacks_show_locals=True, tracebacks_show_locals=env.log.show_locals,
omit_repeated_times=False, omit_repeated_times=False,
show_time=env.log.show_time, show_time=env.log.show_time,
) )
], ],
) )
install(console=console, show_locals=True) install(console=console, show_locals=env.log.show_locals)
logger = logging.getLogger("beavergram") logger = logging.getLogger("beavergram")
+18
View File
@@ -79,6 +79,24 @@ class FolderSpec:
is_chatlist: bool = False is_chatlist: bool = False
class PolicyParent(BaseModel):
scope_type: ScopeType
scope_id: int | None
title: str | None = None
toggles: CaptureToggles
class PolicyRuleView(PolicyRecord):
title: str | None = None
parent: PolicyParent | None = None
class PolicyScopeView(BaseModel):
title: str | None
rule: PolicyRecord | None
parent: PolicyParent | None
@dataclass(slots=True) @dataclass(slots=True)
class PolicySet: class PolicySet:
chat: dict[int, CaptureToggles] = field(default_factory=dict) chat: dict[int, CaptureToggles] = field(default_factory=dict)
+103
View File
@@ -0,0 +1,103 @@
import asyncpg
from utils.policy import repository
from utils.policy.models import (
ChatKind,
ChatMeta,
FolderSpec,
PolicyParent,
PolicyRecord,
PolicyRuleView,
PolicyScopeView,
PolicySet,
ScopeType,
)
from utils.policy.resolver import resolve_parent
from utils.read import chats
from utils.read.models import ChatListItem
def _meta(chat_id: int, chat: ChatListItem | None) -> ChatMeta:
if chat_id > 0:
kind = ChatKind.DM
elif chat is not None and chat.is_broadcast:
kind = ChatKind.CHANNEL
else:
kind = ChatKind.GROUP
return ChatMeta(
chat_id=chat_id,
kind=kind,
is_bot=bool(chat and chat.is_bot),
is_contact=chat.is_contact if chat else None,
)
def _without_chats(policies: PolicySet) -> PolicySet:
return PolicySet(folder=policies.folder, defaults=policies.defaults)
def _chat_parent(
chat_id: int,
chat: ChatListItem | None,
folders: list[FolderSpec],
policies: PolicySet,
) -> PolicyParent:
return resolve_parent(_meta(chat_id, chat), folders, _without_chats(policies))
async def list_rules(pool: asyncpg.Pool, account_id: int) -> list[PolicyRuleView]:
records = await repository.list_policies(pool, account_id)
folders = await repository.list_folders(pool, account_id)
policies = await repository.load_policy_set(pool, account_id)
chat_ids = [
record.scope_id
for record in records
if record.scope_type is ScopeType.CHAT and record.scope_id is not None
]
known = {
chat.chat_id: chat for chat in await chats.get_chats(pool, account_id, chat_ids)
}
titles = {folder.folder_id: folder.title for folder in folders}
rules: list[PolicyRuleView] = []
for record in records:
rule = PolicyRuleView(**record.model_dump())
if record.scope_type is ScopeType.FOLDER and record.scope_id is not None:
rule.title = titles.get(record.scope_id)
if record.scope_type is ScopeType.CHAT and record.scope_id is not None:
chat = known.get(record.scope_id)
rule.title = chat.title if chat else None
rule.parent = _chat_parent(record.scope_id, chat, folders, policies)
rules.append(rule)
return rules
async def _rule(
pool: asyncpg.Pool, account_id: int, scope_type: ScopeType, scope_id: int
) -> PolicyRecord | None:
return await repository.find_policy(pool, account_id, scope_type, scope_id)
async def chat_scope(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> PolicyScopeView:
folders = await repository.list_folders(pool, account_id)
policies = await repository.load_policy_set(pool, account_id)
chat = await chats.get_chat(pool, account_id, chat_id)
return PolicyScopeView(
title=chat.title if chat else None,
rule=await _rule(pool, account_id, ScopeType.CHAT, chat_id),
parent=_chat_parent(chat_id, chat, folders, policies),
)
async def folder_scope(
pool: asyncpg.Pool, account_id: int, folder_id: int
) -> PolicyScopeView | None:
folder = await repository.get_folder(pool, account_id, folder_id)
if folder is None:
return None
return PolicyScopeView(
title=folder.title,
rule=await _rule(pool, account_id, ScopeType.FOLDER, folder_id),
parent=None,
)
+23 -5
View File
@@ -1,11 +1,14 @@
from collections.abc import Iterable from collections.abc import Iterable
from utils.policy.models import ( from utils.policy.models import (
DEFAULT_SCOPE_BY_KIND,
CaptureToggles, CaptureToggles,
ChatKind, ChatKind,
ChatMeta, ChatMeta,
FolderSpec, FolderSpec,
PolicyParent,
PolicySet, PolicySet,
ScopeType,
) )
@@ -33,14 +36,29 @@ def folder_contains(folder: FolderSpec, chat: ChatMeta) -> bool:
return _category_match(folder, chat) return _category_match(folder, chat)
def resolve_parent(
chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet
) -> PolicyParent:
for folder in sorted(folders, key=lambda f: f.order_index):
toggles = policies.folder.get(folder.folder_id)
if toggles is not None and folder_contains(folder, chat):
return PolicyParent(
scope_type=ScopeType.FOLDER,
scope_id=folder.folder_id,
title=folder.title,
toggles=toggles,
)
return PolicyParent(
scope_type=DEFAULT_SCOPE_BY_KIND[chat.kind],
scope_id=None,
toggles=policies.defaults.get(chat.kind, CaptureToggles()),
)
def resolve( def resolve(
chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet
) -> CaptureToggles: ) -> CaptureToggles:
chat_override = policies.chat.get(chat.chat_id) chat_override = policies.chat.get(chat.chat_id)
if chat_override is not None: if chat_override is not None:
return chat_override return chat_override
for folder in sorted(folders, key=lambda f: f.order_index): return resolve_parent(chat, folders, policies).toggles
toggles = policies.folder.get(folder.folder_id)
if toggles is not None and folder_contains(folder, chat):
return toggles
return policies.defaults.get(chat.kind, CaptureToggles())
+37 -5
View File
@@ -7,17 +7,18 @@ from utils.read.models import AccountView
ACCOUNTS_CHANGED_CHANNEL = "accounts_changed" ACCOUNTS_CHANGED_CHANNEL = "accounts_changed"
_ACCOUNT_COLS = "account_id, label, phone, tg_user_id, is_active, device_model" _ACCOUNT_COLS = "account_id, label, phone, tg_user_id, is_active, is_bot, device_model"
_UPSERT_ACCOUNT = """ _UPSERT_ACCOUNT = """
INSERT INTO accounts INSERT INTO accounts
(tg_user_id, label, phone, session_name, is_active, raw, updated_at) (tg_user_id, label, phone, session_name, is_active, is_bot, raw, updated_at)
VALUES ($1, $2, $3, $4, TRUE, $5::jsonb, now()) VALUES ($1, $2, $3, $4, TRUE, $6, $5::jsonb, now())
ON CONFLICT (tg_user_id) DO UPDATE SET ON CONFLICT (tg_user_id) DO UPDATE SET
label = EXCLUDED.label, label = EXCLUDED.label,
phone = EXCLUDED.phone, phone = EXCLUDED.phone,
session_name = EXCLUDED.session_name, session_name = EXCLUDED.session_name,
is_active = TRUE, is_active = TRUE,
is_bot = EXCLUDED.is_bot,
raw = EXCLUDED.raw, raw = EXCLUDED.raw,
updated_at = now() updated_at = now()
RETURNING account_id RETURNING account_id
@@ -45,6 +46,35 @@ async def self_user_id(pool: asyncpg.Pool, account_id: int) -> int | None:
return tg_user_id return tg_user_id
_known_ids: set[int] = set()
def _account_label(row: asyncpg.Record) -> str:
named = f", {row['label']}" if row["label"] else ""
return f"account_id={row['account_id']} (Telegram id {row['tg_user_id']}{named})"
async def unknown_account_hint(pool: asyncpg.Pool, account_id: int) -> str | None:
"""Explain an account_id that matches no account, or None if it is valid."""
if account_id in _known_ids:
return None
rows = await pool.fetch(
"SELECT account_id, tg_user_id, label FROM accounts ORDER BY account_id"
)
_known_ids.update(row["account_id"] for row in rows)
if account_id in _known_ids:
return None
known = "; ".join(_account_label(row) for row in rows) or "none"
mistaken = next((row for row in rows if row["tg_user_id"] == account_id), None)
if mistaken is not None:
return (
f"{account_id} is a Telegram user id, not an account_id. In beavergram "
f"that account is account_id={mistaken['account_id']}. "
f"Retry with account_id={mistaken['account_id']}."
)
return f"No account with account_id={account_id}. Archived accounts: {known}."
async def list_accounts(pool: asyncpg.Pool) -> list[AccountView]: async def list_accounts(pool: asyncpg.Pool) -> list[AccountView]:
rows = await pool.fetch( rows = await pool.fetch(
f"SELECT {_ACCOUNT_COLS} FROM accounts ORDER BY account_id" # noqa: S608 f"SELECT {_ACCOUNT_COLS} FROM accounts ORDER BY account_id" # noqa: S608
@@ -60,7 +90,9 @@ async def get_account(pool: asyncpg.Pool, account_id: int) -> AccountView | None
return AccountView(**dict(row)) if row else None return AccountView(**dict(row)) if row else None
async def sync_account(pool: asyncpg.Pool, me: User, session_name: str) -> int: async def sync_account(
pool: asyncpg.Pool, me: User, session_name: str, *, is_bot: bool = False
) -> int:
raw = json.dumps( raw = json.dumps(
{ {
"id": me.id, "id": me.id,
@@ -72,7 +104,7 @@ async def sync_account(pool: asyncpg.Pool, me: User, session_name: str) -> int:
) )
label = " ".join(filter(None, [me.first_name, me.last_name])) or me.username label = " ".join(filter(None, [me.first_name, me.last_name])) or me.username
return await pool.fetchval( return await pool.fetchval(
_UPSERT_ACCOUNT, me.id, label, me.phone_number, session_name, raw _UPSERT_ACCOUNT, me.id, label, me.phone_number, session_name, raw, is_bot
) )
+157
View File
@@ -0,0 +1,157 @@
"""Registry of bot tokens the watcher holds.
Only session bookkeeping lives here: what the archive itself keeps about a
bot is an ordinary account row and ordinary messages, read through
``utils.read.accounts`` and ``utils.read.chats`` like any other account.
"""
import asyncpg
from utils.read.models import BotView
BOTS_CHANGED_CHANNEL = "watched_bots_changed"
STATUS_PENDING = "pending"
STATUS_OK = "ok"
STATUS_UNAUTHORIZED = "unauthorized"
STATUS_ERROR = "error"
STATUS_REMOVED = "removed"
_BOT_COLS = """
SELECT bot_id, account_id, username, title, enabled, status, status_detail,
backfilled_at, checked_at, added_at
FROM watched_bots
"""
_REGISTER = """
INSERT INTO watched_bots (bot_id, status) VALUES ($1, 'pending')
ON CONFLICT (bot_id) DO UPDATE SET
enabled = true, status = 'pending', status_detail = NULL, updated_at = now()
"""
_REMOVE = """
UPDATE watched_bots
SET enabled = false, status = 'removed', status_detail = NULL, updated_at = now()
WHERE bot_id = $1 AND enabled
"""
_BACKLOG_CURSOR = """
SELECT backfill_pts, backfill_qts, backfill_date
FROM watched_bots
WHERE bot_id = $1 AND backfill_pts IS NOT NULL
"""
_SAVE_BACKLOG_CURSOR = """
UPDATE watched_bots SET
backfill_pts = $2, backfill_qts = $3, backfill_date = $4, updated_at = now()
WHERE bot_id = $1
"""
_MARK_BACKFILLED = """
UPDATE watched_bots SET
backfilled_at = now(),
backfill_pts = NULL,
backfill_qts = NULL,
backfill_date = NULL,
updated_at = now()
WHERE bot_id = $1
"""
_SET_STATUS = """
UPDATE watched_bots SET
status = $2,
status_detail = $3,
username = coalesce($4, username),
title = coalesce($5, title),
checked_at = now(),
updated_at = now()
WHERE bot_id = $1
"""
async def notify_bots_changed(pool: asyncpg.Pool) -> None:
await pool.execute("SELECT pg_notify($1, '')", BOTS_CHANGED_CHANNEL)
async def list_bots(pool: asyncpg.Pool) -> list[BotView]:
rows = await pool.fetch(f"{_BOT_COLS} ORDER BY added_at")
return [BotView(**dict(row)) for row in rows]
async def get_bot(pool: asyncpg.Pool, bot_id: int) -> BotView | None:
row = await pool.fetchrow(f"{_BOT_COLS} WHERE bot_id = $1", bot_id)
return BotView(**dict(row)) if row else None
async def enabled_bot_ids(pool: asyncpg.Pool) -> set[int]:
rows = await pool.fetch(
"SELECT bot_id FROM watched_bots WHERE enabled AND status <> $1",
STATUS_UNAUTHORIZED,
)
return {row["bot_id"] for row in rows}
async def removed_bot_ids(pool: asyncpg.Pool) -> set[int]:
rows = await pool.fetch(
"SELECT bot_id FROM watched_bots WHERE NOT enabled AND status = $1",
STATUS_REMOVED,
)
return {row["bot_id"] for row in rows}
async def known_bot_ids(pool: asyncpg.Pool) -> set[int]:
rows = await pool.fetch("SELECT bot_id FROM watched_bots")
return {row["bot_id"] for row in rows}
async def register_bot(pool: asyncpg.Pool, bot_id: int) -> None:
await pool.execute(_REGISTER, bot_id)
async def remove_bot(pool: asyncpg.Pool, bot_id: int) -> bool:
return await pool.execute(_REMOVE, bot_id) != "UPDATE 0"
async def link_account(pool: asyncpg.Pool, bot_id: int, account_id: int) -> None:
await pool.execute(
"UPDATE watched_bots SET account_id = $2, updated_at = now() WHERE bot_id = $1",
bot_id,
account_id,
)
async def set_status( # noqa: PLR0913
pool: asyncpg.Pool,
bot_id: int,
status: str,
*,
detail: str | None = None,
username: str | None = None,
title: str | None = None,
) -> None:
await pool.execute(_SET_STATUS, bot_id, status, detail, username, title)
async def needs_backfill(pool: asyncpg.Pool, bot_id: int) -> bool:
return not await pool.fetchval(
"SELECT backfilled_at IS NOT NULL FROM watched_bots WHERE bot_id = $1", bot_id
)
async def backlog_cursor(
pool: asyncpg.Pool, bot_id: int
) -> tuple[int, int, int] | None:
row = await pool.fetchrow(_BACKLOG_CURSOR, bot_id)
if row is None:
return None
return row["backfill_pts"], row["backfill_qts"], row["backfill_date"]
async def save_backlog_cursor(
pool: asyncpg.Pool, bot_id: int, pts: int, qts: int, date: int
) -> None:
await pool.execute(_SAVE_BACKLOG_CURSOR, bot_id, pts, qts, date)
async def mark_backfilled(pool: asyncpg.Pool, bot_id: int) -> None:
await pool.execute(_MARK_BACKFILLED, bot_id)
+12
View File
@@ -176,6 +176,18 @@ async def list_chats(
return [_chat_item(row) for row in rows] return [_chat_item(row) for row in rows]
async def folder_chat_ids(
pool: asyncpg.Pool, account_id: int, folder: FolderSpec
) -> list[int]:
rows_sql = _CHAT_ROWS.format(ids=_ALL_IDS)
rows = await pool.fetch(
f"SELECT chat.chat_id FROM ({rows_sql}) chat WHERE {_folder_filter(1)}", # noqa: S608
account_id,
*_folder_params(folder),
)
return [row["chat_id"] for row in rows]
async def get_chat( async def get_chat(
pool: asyncpg.Pool, account_id: int, chat_id: int pool: asyncpg.Pool, account_id: int, chat_id: int
) -> ChatListItem | None: ) -> ChatListItem | None:
+3
View File
@@ -22,6 +22,7 @@ from utils.read.models import (
StickerView, StickerView,
WebPageView, WebPageView,
) )
from utils.read.rich_text import rich_text
_MEDIA_KEYS = ( _MEDIA_KEYS = (
"photo", "photo",
@@ -382,6 +383,8 @@ def build_message_view(
row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef] row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef]
) -> MessageView: ) -> MessageView:
base = _base_fields(row) base = _base_fields(row)
if base["text"] is None:
base["text"] = rich_text(raw.get("rich_message"))
via_bot = raw.get("via_bot") via_bot = raw.get("via_bot")
sticker = _sticker(raw) sticker = _sticker(raw)
try: try:
+80
View File
@@ -22,6 +22,7 @@ class AccountView(BaseModel):
phone: str | None phone: str | None
tg_user_id: int | None tg_user_id: int | None
is_active: bool is_active: bool
is_bot: bool
device_model: str | None device_model: str | None
@@ -235,6 +236,72 @@ class MediaVersionView(BaseModel):
observed_at: datetime observed_at: datetime
class StorageGroupView(BaseModel):
group: str
files: int
bytes: int
class StorageSummaryView(BaseModel):
files: int
bytes: int
heavy_files: int
heavy_bytes: int
heavy_threshold: int
trash_files: int
trash_bytes: int
groups: list[StorageGroupView]
class StorageChatView(BaseModel):
chat_id: int
files: int
bytes: int
groups: list[StorageGroupView]
class StorageFileView(BaseModel):
media_id: int
chat_id: int
message_id: int
kind: str
mime: str | None
file_name: str
file_size: int
date: datetime | None
refs: int
refetchable: bool
class TrashItemView(BaseModel):
storage_key: str
chat_id: int
message_id: int
kind: str
mime: str | None
file_name: str
file_size: int | None
trashed_at: datetime
purge_at: datetime
refs: int
refetchable: bool
class TrashOutcome(BaseModel):
files: int = 0
bytes: int = 0
keys: list[str] = []
blocked: list[int] = []
class StorageScopeView(BaseModel):
chats: int
files: int
bytes: int
kept: int
gone: int
class AvatarRef(BaseModel): class AvatarRef(BaseModel):
unique_id: str unique_id: str
storage_key: str | None storage_key: str | None
@@ -423,3 +490,16 @@ class FileShareHitView(BaseModel):
ip: str | None ip: str | None
user_agent: str | None user_agent: str | None
counted: bool counted: bool
class BotView(BaseModel):
bot_id: int
account_id: int | None
username: str | None
title: str | None
enabled: bool
status: str
status_detail: str | None
backfilled_at: datetime | None
checked_at: datetime | None
added_at: datetime
+61
View File
@@ -0,0 +1,61 @@
from typing import Any
CELL_GAP = " | "
_TEXT_KEYS = ("summary", "text", "expression", "credit")
_INLINE_KEYS = ("text", "alternative_text", "expression")
def _button(button: dict[str, Any]) -> str:
label = _inline(button.get("text"))
url = button.get("url")
return f"{label} ({url})" if isinstance(url, str) else label
def _inline(value: object) -> str:
if isinstance(value, str):
return value
if isinstance(value, list):
return "".join(_inline(item) for item in value)
if not isinstance(value, dict):
return ""
button = value.get("button")
if isinstance(button, dict):
return _button(button)
return next((_inline(value[key]) for key in _INLINE_KEYS if key in value), "")
def _children(block: dict[str, Any], key: str) -> list[Any]:
value = block.get(key)
return value if isinstance(value, list) else []
def _row(row: object) -> str:
cells = row if isinstance(row, list) else []
return CELL_GAP.join(
_inline(cell.get("text")) for cell in cells if isinstance(cell, dict)
)
def _block(block: object) -> list[str]:
if not isinstance(block, dict):
return []
lines = [_inline(block[key]) for key in _TEXT_KEYS if key in block]
lines += [_row(row) for row in _children(block, "cells")]
lines += [
_button(button)
for button in _children(block, "buttons")
if isinstance(button, dict)
]
for key in ("items", "blocks"):
for child in _children(block, key):
lines += _block(child)
lines += _block(block.get("caption"))
lines = [line for line in lines if line]
label = block.get("label")
if isinstance(label, str) and lines:
lines[0] = f"{label} {lines[0]}"
return lines
def rich_text(rich: object) -> str | None:
return "\n".join(_block(rich)) or None
+360
View File
@@ -0,0 +1,360 @@
import asyncio
from collections import defaultdict
from itertools import groupby
from operator import itemgetter
import asyncpg
from utils.files import media_file_name
from utils.policy import repository as policy_repository
from utils.read import chats
from utils.read.models import (
Page,
StorageChatView,
StorageFileView,
StorageGroupView,
StorageScopeView,
StorageSummaryView,
TrashItemView,
)
from utils.trash import RETENTION
HEAVY_BYTES = 100 * 1024 * 1024
REFS_CHECKED = 5
LOOKUP_CONCURRENCY = 8
OTHER_GROUP = "other"
GROUP_KINDS: dict[str, tuple[str, ...]] = {
"video": ("video",),
"file": ("document",),
"voice": ("video_note", "voice"),
"audio": ("audio",),
}
_KIND_GROUP = {kind: group for group, kinds in GROUP_KINDS.items() for kind in kinds}
_SUMMARY = """
WITH blobs AS (
SELECT storage_key, max(file_size) AS size, min(kind) AS kind
FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
AND ($3::bigint IS NULL OR chat_id = $3)
GROUP BY storage_key
)
SELECT kind, count(*) AS files, coalesce(sum(size), 0)::bigint AS bytes,
count(*) FILTER (WHERE size >= $2) AS heavy_files,
coalesce(sum(size) FILTER (WHERE size >= $2), 0)::bigint AS heavy_bytes
FROM blobs GROUP BY kind
"""
_TRASH_TOTAL = """
SELECT count(*) AS files, coalesce(sum(file_size), 0)::bigint AS bytes
FROM media_trash WHERE account_id = $1
"""
_FILES = """
WITH top AS MATERIALIZED (
SELECT storage_key, max(file_size) AS file_size
FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
AND ($7::bigint IS NULL OR chat_id = $7)
GROUP BY storage_key
ORDER BY 2 DESC NULLS LAST, 1
LIMIT $4 OFFSET $5
)
SELECT * FROM (
SELECT t.storage_key, t.file_size, m.id, m.chat_id, m.message_id,
m.kind, m.mime, m.ttl_seconds,
count(*) OVER (PARTITION BY t.storage_key) AS refs,
row_number() OVER (
PARTITION BY t.storage_key
ORDER BY (m.chat_id = $7) DESC NULLS LAST, m.id
) AS n
FROM top t
JOIN media m ON m.storage_key = t.storage_key
AND m.account_id = $1 AND m.downloaded
) ranked
WHERE n <= $6
ORDER BY file_size DESC NULLS LAST, storage_key, n
"""
_CHATS = """
WITH blobs AS (
SELECT chat_id, storage_key, max(file_size) AS size, min(kind) AS kind
FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
GROUP BY chat_id, storage_key
), top AS (
SELECT chat_id, coalesce(sum(size), 0)::bigint AS total
FROM blobs GROUP BY chat_id
ORDER BY 2 DESC, 1
LIMIT $4 OFFSET $5
)
SELECT t.chat_id, t.total, b.kind, count(*) AS files,
coalesce(sum(b.size), 0)::bigint AS bytes
FROM top t JOIN blobs b USING (chat_id)
GROUP BY t.chat_id, t.total, b.kind
ORDER BY t.total DESC, t.chat_id
"""
_TRASH = """
SELECT storage_key, chat_id, message_id, kind, mime, file_name, file_size,
trashed_at
FROM media_trash WHERE account_id = $1
ORDER BY file_size DESC NULLS LAST, storage_key
LIMIT $2 OFFSET $3
"""
SCOPE_BLOBS = """
WITH inside AS (
SELECT DISTINCT storage_key FROM media
WHERE account_id = $1 AND chat_id = ANY($2::bigint[])
AND downloaded AND storage_key IS NOT NULL
), blobs AS (
SELECT m.storage_key, max(m.file_size) AS file_size,
min(m.id) FILTER (
WHERE m.account_id = $1 AND m.chat_id = ANY($2::bigint[])
) AS media_id,
bool_and(m.account_id = $1 AND m.chat_id = ANY($2::bigint[])) AS own
FROM media m JOIN inside i ON i.storage_key = m.storage_key
GROUP BY m.storage_key
)
SELECT b.storage_key, b.file_size, r.chat_id, r.message_id, r.kind, r.mime,
b.own
AND NOT EXISTS (SELECT 1 FROM stories s WHERE s.storage_key = b.storage_key)
AND NOT EXISTS (SELECT 1 FROM avatars a WHERE a.storage_key = b.storage_key)
AND NOT EXISTS (
SELECT 1 FROM custom_emoji e WHERE e.storage_key = b.storage_key
) AS free
FROM blobs b JOIN media r ON r.id = b.media_id
"""
_SCOPE_GONE = """
SELECT (
SELECT count(DISTINCT m.storage_key) FROM media m
JOIN messages g ON g.account_id = m.account_id AND g.chat_id = m.chat_id
AND g.message_id = m.message_id
WHERE m.account_id = $1 AND m.chat_id = ANY($2::bigint[])
AND m.downloaded AND m.storage_key IS NOT NULL
AND g.deleted_at IS NOT NULL
) + (
SELECT count(DISTINCT storage_key) FROM media
WHERE account_id = $1 AND chat_id = ANY($2::bigint[])
AND downloaded AND storage_key IS NOT NULL AND ttl_seconds IS NOT NULL
)
"""
_TRASH_REFS = """
SELECT * FROM (
SELECT evicted_key AS storage_key, chat_id, message_id, kind, ttl_seconds,
count(*) OVER (PARTITION BY evicted_key) AS refs,
row_number() OVER (PARTITION BY evicted_key ORDER BY id) AS n
FROM media
WHERE account_id = $1 AND evicted_key = ANY($2::text[])
) ranked
WHERE n <= $3
"""
_MESSAGE = """
SELECT date, deleted_at, raw->$4::text->>'file_name' AS original_name
FROM messages
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
ORDER BY date DESC LIMIT 1
"""
def _group_filter(group: str | None) -> tuple[list[str] | None, bool]:
if group is None:
return None, True
if group == OTHER_GROUP:
return list(_KIND_GROUP), False
return list(GROUP_KINDS.get(group, ())), True
def _groups(rows: list[asyncpg.Record]) -> list[StorageGroupView]:
groups = {
name: StorageGroupView(group=name, files=0, bytes=0)
for name in (*GROUP_KINDS, OTHER_GROUP)
}
for row in rows:
group = groups[_KIND_GROUP.get(row["kind"], OTHER_GROUP)]
group.files += row["files"]
group.bytes += row["bytes"]
return list(groups.values())
async def get_summary(
pool: asyncpg.Pool, account_id: int, chat_id: int | None = None
) -> StorageSummaryView:
rows = await pool.fetch(_SUMMARY, account_id, HEAVY_BYTES, chat_id)
trash = await pool.fetchrow(_TRASH_TOTAL, account_id)
return StorageSummaryView(
files=sum(row["files"] for row in rows),
bytes=sum(row["bytes"] for row in rows),
heavy_files=sum(row["heavy_files"] for row in rows),
heavy_bytes=sum(row["heavy_bytes"] for row in rows),
heavy_threshold=HEAVY_BYTES,
trash_files=trash["files"],
trash_bytes=trash["bytes"],
groups=_groups(rows),
)
async def list_chats(
pool: asyncpg.Pool, account_id: int, group: str | None, page: Page
) -> list[StorageChatView]:
kinds, include = _group_filter(group)
rows = await pool.fetch(
_CHATS, account_id, kinds, include, page.capped_limit, page.offset
)
return [
StorageChatView(
chat_id=chat_id,
files=sum(row["files"] for row in items),
bytes=items[0]["total"],
groups=_groups(items),
)
for chat_id, items in (
(chat_id, list(chunk))
for chat_id, chunk in groupby(rows, key=itemgetter("chat_id"))
)
]
async def _messages(
pool: asyncpg.Pool, account_id: int, refs: list[asyncpg.Record]
) -> list[asyncpg.Record | None]:
gate = asyncio.Semaphore(LOOKUP_CONCURRENCY)
async def lookup(ref: asyncpg.Record) -> asyncpg.Record | None:
async with gate:
return await pool.fetchrow(
_MESSAGE, account_id, ref["chat_id"], ref["message_id"], ref["kind"]
)
return await asyncio.gather(*(lookup(ref) for ref in refs))
def _alive(ref: asyncpg.Record, message: asyncpg.Record | None) -> bool:
return (
message is not None
and message["deleted_at"] is None
and ref["ttl_seconds"] is None
)
async def list_files(
pool: asyncpg.Pool,
account_id: int,
group: str | None,
page: Page,
chat_id: int | None = None,
) -> list[StorageFileView]:
kinds, include = _group_filter(group)
refs = await pool.fetch(
_FILES,
account_id,
kinds,
include,
page.capped_limit,
page.offset,
REFS_CHECKED,
chat_id,
)
messages = await _messages(pool, account_id, refs)
files: dict[str, StorageFileView] = {}
for ref, message in zip(refs, messages, strict=True):
alive = _alive(ref, message)
known = files.get(ref["storage_key"])
inside = chat_id is None or ref["chat_id"] == chat_id
if known is not None and not (alive and inside and not known.refetchable):
known.refetchable = known.refetchable or alive
continue
files[ref["storage_key"]] = StorageFileView(
media_id=ref["id"],
chat_id=ref["chat_id"],
message_id=ref["message_id"],
kind=ref["kind"],
mime=ref["mime"],
file_name=media_file_name(
ref["kind"],
ref["mime"],
ref["message_id"],
message["original_name"] if message else None,
),
file_size=ref["file_size"] or 0,
date=message["date"] if message else None,
refs=ref["refs"],
refetchable=alive,
)
return list(files.values())
async def list_trash(
pool: asyncpg.Pool, account_id: int, page: Page
) -> list[TrashItemView]:
rows = await pool.fetch(_TRASH, account_id, page.capped_limit, page.offset)
refs = await pool.fetch(
_TRASH_REFS, account_id, [row["storage_key"] for row in rows], REFS_CHECKED
)
messages = await _messages(pool, account_id, refs)
alive: dict[str, bool] = defaultdict(bool)
counts: dict[str, int] = {}
names: dict[str, str] = {}
for ref, message in zip(refs, messages, strict=True):
key = ref["storage_key"]
alive[key] |= _alive(ref, message)
counts[key] = ref["refs"]
if message is not None and message["original_name"]:
names.setdefault(key, message["original_name"])
return [
TrashItemView(
**{**dict(row), "file_name": _trash_name(row, names)},
purge_at=row["trashed_at"] + RETENTION,
refs=counts.get(row["storage_key"], 0),
refetchable=alive.get(row["storage_key"], True),
)
for row in rows
]
def _trash_name(row: asyncpg.Record, names: dict[str, str]) -> str:
original = names.get(row["storage_key"])
if original is None:
return row["file_name"]
return media_file_name(row["kind"], row["mime"], row["message_id"], original)
async def scope_chat_ids(
pool: asyncpg.Pool, account_id: int, chat_id: int | None, folder_id: int | None
) -> list[int] | None:
if chat_id is not None:
return [chat_id]
if folder_id is None:
return None
folder = await policy_repository.get_folder(pool, account_id, folder_id)
if folder is None:
return None
return await chats.folder_chat_ids(pool, account_id, folder)
async def get_scope(
pool: asyncpg.Pool, account_id: int, chat_ids: list[int]
) -> StorageScopeView:
blobs = await pool.fetch(SCOPE_BLOBS, account_id, chat_ids)
free = [blob for blob in blobs if blob["free"]]
return StorageScopeView(
chats=len(chat_ids),
files=len(free),
bytes=sum(blob["file_size"] or 0 for blob in free),
kept=len(blobs) - len(free),
gone=await pool.fetchval(_SCOPE_GONE, account_id, chat_ids) if free else 0,
)
async def get_trash_item(pool: asyncpg.Pool, storage_key: str) -> asyncpg.Record | None:
return await pool.fetchrow(
"SELECT kind, mime, file_name FROM media_trash WHERE storage_key = $1",
storage_key,
)
+31
View File
@@ -1,6 +1,8 @@
import hashlib import hashlib
from pathlib import Path from pathlib import Path
TRASH_DIR = ".trash"
class ContentAddressedStorage: class ContentAddressedStorage:
def __init__(self, root: str | Path, shard_depth: int = 2) -> None: def __init__(self, root: str | Path, shard_depth: int = 2) -> None:
@@ -27,3 +29,32 @@ class ContentAddressedStorage:
def url(self, key: str) -> str: def url(self, key: str) -> str:
return str(self._path(key)) return str(self._path(key))
def _trash_path(self, key: str) -> Path:
return self._root / TRASH_DIR / key
def trash(self, key: str) -> None:
target = self._trash_path(key)
target.parent.mkdir(parents=True, exist_ok=True)
self._path(key).replace(target)
def restore(self, key: str) -> bool:
source = self._trash_path(key)
target = self._path(key)
if not source.exists():
return target.exists()
if target.exists():
source.unlink()
else:
target.parent.mkdir(parents=True, exist_ok=True)
source.replace(target)
return True
def purge(self, key: str) -> None:
self._trash_path(key).unlink(missing_ok=True)
def trashed(self, key: str) -> bool:
return self._trash_path(key).is_file()
def trash_url(self, key: str) -> str:
return str(self._trash_path(key))
+2
View File
@@ -0,0 +1,2 @@
API_ID = 2040
API_HASH = "b18441a1ff607e10a989891a5462e627"
+228
View File
@@ -0,0 +1,228 @@
import asyncio
import logging
from datetime import timedelta
import asyncpg
from utils.files import media_file_name
from utils.read.media import get_media
from utils.read.models import TrashOutcome
from utils.storage import ContentAddressedStorage
logger = logging.getLogger(__name__)
RETENTION = timedelta(days=7)
PURGE_INTERVAL_SECONDS = 3600
BATCH = 200
_SHARED_ELSEWHERE = """
SELECT EXISTS (SELECT 1 FROM stories WHERE storage_key = $1)
OR EXISTS (SELECT 1 FROM avatars WHERE storage_key = $1)
OR EXISTS (SELECT 1 FROM custom_emoji WHERE storage_key = $1)
"""
_EVICT = """
UPDATE media SET storage_key = NULL, downloaded = false,
evicted_at = now(), evicted_key = $2
WHERE storage_key = $1
"""
_INSERT = """
INSERT INTO media_trash
(storage_key, account_id, chat_id, message_id, kind, mime, file_name, file_size)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (storage_key) DO UPDATE SET trashed_at = now()
"""
_RESTORE = """
UPDATE media SET storage_key = $1, downloaded = true,
evicted_at = NULL, evicted_key = NULL
WHERE evicted_key = $1 OR (
evicted_at IS NOT NULL AND unique_id IN (
SELECT unique_id FROM media
WHERE evicted_key = $1 AND unique_id IS NOT NULL
)
)
"""
_FORGET_KEYS = """
UPDATE media SET evicted_key = NULL WHERE evicted_key = ANY($1::text[])
"""
_EVICT_MANY = """
UPDATE media SET evicted_key = storage_key, storage_key = NULL,
downloaded = false, evicted_at = now()
WHERE storage_key = ANY($1::text[])
"""
async def move_to_trash(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
media_ids: list[int],
) -> TrashOutcome:
outcome = TrashOutcome()
for media_id in media_ids:
media = await get_media(pool, media_id)
if media is None or media.account_id != account_id or not media.storage_key:
continue
key = media.storage_key
on_disk = storage.exists(key)
async with pool.acquire() as conn, conn.transaction():
if await conn.fetchval(_SHARED_ELSEWHERE, key):
outcome.blocked.append(media_id)
continue
await conn.execute(_EVICT, key, key if on_disk else None)
if not on_disk:
continue
await conn.execute(
_INSERT,
key,
account_id,
media.chat_id,
media.message_id,
media.kind,
media.mime,
media.file_name,
media.file_size,
)
storage.trash(key)
outcome.keys.append(key)
outcome.files += 1
outcome.bytes += media.file_size or 0
return outcome
def _move_batch(storage: ContentAddressedStorage, keys: list[str]) -> None:
moved: list[str] = []
try:
for key in keys:
if storage.exists(key):
storage.trash(key)
moved.append(key)
except OSError:
for key in moved:
storage.restore(key)
raise
async def trash_blobs(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
blobs: list[asyncpg.Record],
) -> TrashOutcome:
outcome = TrashOutcome()
for start in range(0, len(blobs), BATCH):
batch = blobs[start : start + BATCH]
keys = [blob["storage_key"] for blob in batch]
async with pool.acquire() as conn, conn.transaction():
await conn.execute(_EVICT_MANY, keys)
await conn.executemany(
_INSERT,
[
(
blob["storage_key"],
account_id,
blob["chat_id"],
blob["message_id"],
blob["kind"],
blob["mime"],
media_file_name(blob["kind"], blob["mime"], blob["message_id"]),
blob["file_size"],
)
for blob in batch
],
)
await asyncio.to_thread(_move_batch, storage, keys)
outcome.keys.extend(keys)
outcome.files += len(batch)
outcome.bytes += sum(blob["file_size"] or 0 for blob in batch)
return outcome
async def restore(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
keys: list[str] | None,
) -> TrashOutcome:
if keys is None:
rows = await pool.fetch(
"SELECT storage_key FROM media_trash WHERE account_id = $1", account_id
)
keys = [row["storage_key"] for row in rows]
outcome = TrashOutcome()
for key in keys:
async with pool.acquire() as conn, conn.transaction():
row = await conn.fetchrow(
"DELETE FROM media_trash WHERE storage_key = $1 AND account_id = $2 "
"RETURNING file_size",
key,
account_id,
)
if row is None:
continue
if not storage.restore(key):
await conn.execute(_FORGET_KEYS, [key])
continue
await conn.execute(_RESTORE, key)
outcome.files += 1
outcome.bytes += row["file_size"] or 0
return outcome
async def _purge(
pool: asyncpg.Pool, storage: ContentAddressedStorage, rows: list[asyncpg.Record]
) -> TrashOutcome:
keys = [row["storage_key"] for row in rows]
for key in keys:
storage.purge(key)
async with pool.acquire() as conn, conn.transaction():
await conn.execute(
"DELETE FROM media_trash WHERE storage_key = ANY($1::text[])", keys
)
await conn.execute(_FORGET_KEYS, keys)
return TrashOutcome(
files=len(rows), bytes=sum(row["file_size"] or 0 for row in rows)
)
async def purge(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
keys: list[str] | None,
) -> TrashOutcome:
rows = await pool.fetch(
"SELECT storage_key, file_size FROM media_trash WHERE account_id = $1 "
"AND ($2::text[] IS NULL OR storage_key = ANY($2))",
account_id,
keys,
)
return await _purge(pool, storage, rows)
async def purge_expired(
pool: asyncpg.Pool, storage: ContentAddressedStorage
) -> TrashOutcome:
rows = await pool.fetch(
"SELECT storage_key, file_size FROM media_trash "
"WHERE trashed_at < now() - $1::interval",
RETENTION,
)
return await _purge(pool, storage, rows)
async def purge_loop(pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None:
while True:
try:
outcome = await purge_expired(pool, storage)
if outcome.files:
logger.info(
"Trash purged: %s files, %s bytes", outcome.files, outcome.bytes
)
except Exception:
logger.exception("Trash purge failed")
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
View File
Whitespace-only changes.
+4
View File
@@ -0,0 +1,4 @@
from .runner import main
if __name__ == "__main__":
main()
+33
View File
@@ -0,0 +1,33 @@
"""A capture context for a bot session.
Same context the userbot capture path expects, minus the two caches a bot
may not fill: ``messages.getDialogFilters`` and ``contacts.getContacts`` are
both refused for bots, so folders and contacts simply stay empty and policy
falls back to the per-account defaults.
"""
import asyncpg
from pyrogram import Client
from userbot.modules.capture.context import CaptureContext
from userbot.modules.contacts import ContactCache
from userbot.modules.folders import FolderCache
from utils.storage import ContentAddressedStorage
async def build_bot_capture_context(
client: Client,
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
) -> CaptureContext:
ctx = CaptureContext(
account_id,
pool,
storage,
FolderCache(client, pool, account_id),
ContactCache(client),
)
await ctx.reload_policies()
await ctx.watches.refresh()
return ctx
+137
View File
@@ -0,0 +1,137 @@
"""Pull the updates Telegram still holds for a bot that nobody consumed.
A bot's undelivered updates outlive the session they were meant for: the
same queue Bot API ``getUpdates`` drains is reachable over MTProto through
``updates.getDifference``. A fresh login normally starts from
``updates.getState``, which throws that queue away - this walks it instead.
"""
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pyrogram import raw, types
from pyrogram.errors import RPCError
from userbot.modules.capture import capture_message
from userbot.modules.capture.chat_meta import meta_from_chat
from userbot.modules.capture.context import CaptureContext
from userbot.modules.client import PyroClient
from utils.logging import logger
EARLIEST_PTS = 1
EARLIEST_QTS = 1
@dataclass(frozen=True)
class DrainCursor:
pts: int
qts: int
date: int
@dataclass(frozen=True)
class DrainResult:
cursor: DrainCursor
stored: int
done: bool
SaveCursor = Callable[[DrainCursor], Awaitable[None]]
def first_cursor() -> DrainCursor:
return DrainCursor(EARLIEST_PTS, EARLIEST_QTS, int(time.time()))
def _advance(cursor: DrainCursor, state: raw.types.updates.State) -> DrainCursor:
return DrainCursor(state.pts, state.qts, state.date or cursor.date)
async def _store(
client: PyroClient, ctx: CaptureContext, message: types.Message
) -> int:
if message.empty or message.chat is None or message.date is None:
return 0
meta = meta_from_chat(message.chat, ctx.contacts.ids)
toggles = ctx.resolve(meta)
if not toggles.messages:
return 0
await capture_message(client, message, ctx, toggles)
return 1
async def _parse_all(
client: PyroClient, new_messages: list, users: list, chats: list
) -> list[types.Message]:
user_map = {u.id: u for u in users}
chat_map = {c.id: c for c in chats}
parsed = []
for raw_message in new_messages:
if isinstance(raw_message, raw.types.MessageEmpty):
continue
parsed.append(
await types.Message._parse(client, raw_message, user_map, chat_map) # noqa: SLF001
)
return parsed
async def drain_backlog(
client: PyroClient, account_id: int, cursor: DrainCursor, save: SaveCursor
) -> DrainResult:
"""Walk the bot's pending queue into the archive, starting from ``cursor``.
``save`` is handed every cursor the walk moves past, so an interrupted
walk resumes where it stopped instead of starting over. ``done`` is true
only when Telegram has nothing older left to hand out.
"""
ctx = client.capture
if ctx is None:
return DrainResult(cursor, 0, done=False)
stored = 0
while True:
try:
diff = await client.invoke(
raw.functions.updates.GetDifference(
pts=cursor.pts, date=cursor.date, qts=cursor.qts
)
)
except RPCError as exc:
logger.warning(
f"[yellow]Account {account_id}: getDifference failed:[/] {exc}"
)
return DrainResult(cursor, stored, done=False)
if isinstance(diff, raw.types.updates.DifferenceEmpty):
logger.info(f"Account {account_id}: backlog empty at pts={cursor.pts}")
return DrainResult(cursor, stored, done=True)
if isinstance(diff, raw.types.updates.DifferenceTooLong):
logger.info(
f"Account {account_id}: backlog too long, server pts={diff.pts}; "
f"nothing older is retrievable"
)
return DrainResult(cursor, stored, done=True)
for message in await _parse_all(
client, diff.new_messages, diff.users, diff.chats
):
stored += await _store(client, ctx, message)
if isinstance(diff, raw.types.updates.Difference):
cursor = _advance(cursor, diff.state)
await save(cursor)
logger.info(
f"[green]Account {account_id}: backlog reached its end[/] "
f"at pts={cursor.pts}"
)
return DrainResult(cursor, stored, done=True)
moved = _advance(cursor, diff.intermediate_state)
if moved == cursor:
logger.warning(
f"[yellow]Account {account_id}: backlog stopped advancing[/] "
f"at pts={cursor.pts}"
)
return DrainResult(cursor, stored, done=False)
cursor = moved
await save(cursor)
+238
View File
@@ -0,0 +1,238 @@
import asyncio
import contextlib
from dataclasses import dataclass
import asyncpg
from pyrogram import Client, enums
from pyrogram.errors import RPCError
from pyrogram.handlers import (
ChatMemberUpdatedHandler,
EditedMessageHandler,
MessageHandler,
)
from userbot.modules.capture import capture_message
from userbot.modules.capture.chat_meta import meta_from_chat
from userbot.modules.client import PyroClient
from userbot.modules.jobs import JobConsumer
from utils.events import notify_bg_event
from utils.jobs import enqueue
from utils.logging import logger
from utils.read import accounts, bots
from utils.storage import ContentAddressedStorage
from watcher import tokens
from watcher.context import build_bot_capture_context
from watcher.drain import DrainCursor, drain_backlog, first_cursor
DEVICE_MODEL = "Beavergram Bot"
TOKEN_MISSING = "Токен не найден на диске" # noqa: S105
INVALID_TOKEN_ERRORS = ("ACCESS_TOKEN_INVALID", "ACCESS_TOKEN_EXPIRED")
BACKLOG_ATTEMPTS = 3
BACKLOG_RETRY_PAUSE = 60
@dataclass
class RunningBot:
client: PyroClient
account_id: int
tasks: list[asyncio.Task]
backlog: asyncio.Task | None = None
async def _cancel(task: asyncio.Task) -> None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
def _handlers(pool: asyncpg.Pool) -> tuple:
async def on_message(client: Client, message) -> None: # noqa: ANN001
ctx = getattr(client, "capture", None)
if ctx is None or message.empty or message.chat is None or message.date is None:
return
meta = meta_from_chat(message.chat, ctx.contacts.ids)
await ctx.watches.on_text(
meta.chat_id, message.id, message.text or message.caption
)
toggles = ctx.resolve(meta)
if not toggles.messages:
return
await capture_message(client, message, ctx, toggles)
await notify_bg_event(
ctx.pool,
"message",
ctx.account_id,
chat_id=meta.chat_id,
message_id=message.id,
)
if message.chat.type is not enums.ChatType.PRIVATE:
await enqueue(
pool, ctx.account_id, "enrich_chat", {"chat_id": meta.chat_id}
)
async def on_member(client: Client, update) -> None: # noqa: ANN001
ctx = getattr(client, "capture", None)
if ctx is None or update.chat is None:
return
await enqueue(pool, ctx.account_id, "enrich_chat", {"chat_id": update.chat.id})
return (
MessageHandler(on_message),
EditedMessageHandler(on_message),
ChatMemberUpdatedHandler(on_member),
)
def _is_invalid_token(exc: RPCError) -> bool:
return any(marker in str(exc) for marker in INVALID_TOKEN_ERRORS)
class BotRegistry:
def __init__(self, pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None:
self._pool = pool
self._storage = storage
self._running: dict[int, RunningBot] = {}
self._lock = asyncio.Lock()
@property
def running(self) -> list[int]:
return list(self._running)
async def sync(self) -> None:
async with self._lock:
await self._adopt_dropped_in()
enabled = await bots.enabled_bot_ids(self._pool)
for bot_id in set(self._running) - enabled:
await self._stop(bot_id)
for bot_id in enabled - set(self._running):
await self._start(bot_id)
for bot_id in enabled & set(self._running):
await self._resume_backlog(bot_id)
for bot_id in await bots.removed_bot_ids(self._pool):
tokens.drop(bot_id)
async def close(self) -> None:
for bot_id in list(self._running):
await self._stop(bot_id)
async def _adopt_dropped_in(self) -> None:
unknown = tokens.stored_bot_ids() - await bots.known_bot_ids(self._pool)
for bot_id in unknown:
await bots.register_bot(self._pool, bot_id)
logger.info(f"[green]Bot token adopted from disk:[/] {bot_id}")
def _client(self, bot_id: int, token: str) -> PyroClient:
return PyroClient(
str(bot_id),
workdir=str(tokens.sessions_dir()),
device_model=DEVICE_MODEL,
load_handlers=False,
bot_token=token,
skip_updates=False,
)
async def _start(self, bot_id: int) -> None:
token = tokens.load(bot_id)
if token is None:
await bots.set_status(
self._pool, bot_id, bots.STATUS_UNAUTHORIZED, detail=TOKEN_MISSING
)
return
client = self._client(bot_id, token)
for handler in _handlers(self._pool):
client.add_handler(handler)
try:
await client.start()
me = client.me
if me is None:
msg = "bot session is not authorized"
raise RuntimeError(msg)
except RPCError as exc:
status = (
bots.STATUS_UNAUTHORIZED
if _is_invalid_token(exc)
else bots.STATUS_ERROR
)
await bots.set_status(self._pool, bot_id, status, detail=str(exc))
logger.warning(f"[yellow]Bot {bot_id} failed to start:[/] {exc}")
with contextlib.suppress(Exception):
await client.stop()
return
except Exception as exc:
await bots.set_status(
self._pool, bot_id, bots.STATUS_ERROR, detail=str(exc)
)
logger.exception(f"[red]Bot {bot_id} failed to start[/]")
with contextlib.suppress(Exception):
await client.stop()
return
account_id = await accounts.sync_account(
self._pool, me, str(bot_id), is_bot=True
)
client.capture = await build_bot_capture_context(
client, self._pool, self._storage, account_id
)
await bots.link_account(self._pool, bot_id, account_id)
await bots.set_status(
self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name
)
await accounts.notify_accounts_changed(self._pool)
consumer = JobConsumer(client, self._pool, account_id)
self._running[bot_id] = RunningBot(
client, account_id, [asyncio.create_task(consumer.run())]
)
await self._resume_backlog(bot_id)
logger.info(
f"[green]Bot account {account_id} watched:[/] @{me.username} ({bot_id})"
)
async def _resume_backlog(self, bot_id: int) -> None:
running = self._running[bot_id]
if running.backlog is not None and not running.backlog.done():
return
if not await bots.needs_backfill(self._pool, bot_id):
return
running.backlog = asyncio.create_task(
self._backlog(running.client, bot_id, running.account_id)
)
async def _backlog(self, client: PyroClient, bot_id: int, account_id: int) -> None:
async def save(at: DrainCursor) -> None:
await bots.save_backlog_cursor(self._pool, bot_id, at.pts, at.qts, at.date)
saved = await bots.backlog_cursor(self._pool, bot_id)
cursor = DrainCursor(*saved) if saved else first_cursor()
stored = 0
for attempt in range(BACKLOG_ATTEMPTS):
if attempt:
await asyncio.sleep(BACKLOG_RETRY_PAUSE)
walk = await drain_backlog(client, account_id, cursor, save)
stored += walk.stored
cursor = walk.cursor
if walk.done:
await bots.mark_backfilled(self._pool, bot_id)
logger.info(
f"[green]Account {account_id}: backlog complete,[/] "
f"{stored} messages stored"
)
break
else:
logger.warning(
f"[yellow]Account {account_id}: backlog unfinished at "
f"pts={cursor.pts},[/] {stored} messages stored, resumes on restart"
)
if stored:
await accounts.notify_accounts_changed(self._pool)
async def _stop(self, bot_id: int) -> None:
running = self._running.pop(bot_id, None)
if running is None:
return
for task in (*running.tasks, running.backlog):
if task is not None:
await _cancel(task)
with contextlib.suppress(Exception):
await running.client.stop()
logger.info(f"[yellow]Bot unwatched:[/] {bot_id}")
+74
View File
@@ -0,0 +1,74 @@
import asyncio
import contextlib
import signal
import asyncpg
import uvloop
from dependencies.container import container
from utils.env import env
from utils.logging import logger, setup_logging
from utils.read.bots import BOTS_CHANGED_CHANNEL
from utils.storage import ContentAddressedStorage
from watcher.registry import BotRegistry
setup_logging()
RETRY_INTERVAL = 300
async def _retry_loop(registry: BotRegistry) -> None:
while True:
await asyncio.sleep(RETRY_INTERVAL)
with contextlib.suppress(Exception):
await registry.sync()
async def _listen_changes(
registry: BotRegistry, tasks: set[asyncio.Task]
) -> asyncpg.Connection:
def on_change(
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
) -> None:
task = asyncio.create_task(registry.sync())
tasks.add(task)
task.add_done_callback(tasks.discard)
conn = await asyncpg.connect(dsn=env.db.connection_url)
await conn.add_listener(BOTS_CHANGED_CHANNEL, on_change)
return conn
async def runner() -> None:
pool = await container.get(asyncpg.Pool)
storage = await container.get(ContentAddressedStorage)
registry = BotRegistry(pool, storage)
tasks: set[asyncio.Task] = set()
listen_conn: asyncpg.Connection | None = None
stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
retry = asyncio.create_task(_retry_loop(registry))
try:
await registry.sync()
if not registry.running:
logger.warning("[yellow]No bots yet. Add a bot token in the web UI.[/]")
listen_conn = await _listen_changes(registry, tasks)
logger.info("[green]Bot watcher running.[/]")
await stop.wait()
finally:
retry.cancel()
with contextlib.suppress(asyncio.CancelledError):
await retry
if listen_conn is not None:
with contextlib.suppress(Exception):
await listen_conn.close()
await registry.close()
await container.close()
def main() -> None:
uvloop.install()
logger.info("Starting bot watcher...")
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(runner())
logger.info("[red]Bot watcher stopped.[/]")
+42
View File
@@ -0,0 +1,42 @@
from pathlib import Path
from utils.env import env
TOKEN_SUFFIX = ".token" # noqa: S105
SESSION_SUFFIX = ".session"
TOKEN_MODE = 0o600
def sessions_dir() -> Path:
path = Path(env.bots.sessions_dir)
path.mkdir(parents=True, exist_ok=True)
return path
def bot_id_of(token: str) -> int | None:
head, _, secret = token.strip().partition(":")
return int(head) if head.isdigit() and secret else None
def save(bot_id: int, token: str) -> None:
path = sessions_dir() / f"{bot_id}{TOKEN_SUFFIX}"
path.write_text(token.strip(), encoding="utf-8")
path.chmod(TOKEN_MODE)
def load(bot_id: int) -> str | None:
path = sessions_dir() / f"{bot_id}{TOKEN_SUFFIX}"
return path.read_text(encoding="utf-8").strip() if path.is_file() else None
def drop(bot_id: int) -> None:
for suffix in (TOKEN_SUFFIX, SESSION_SUFFIX):
(sessions_dir() / f"{bot_id}{suffix}").unlink(missing_ok=True)
def stored_bot_ids() -> set[int]:
return {
int(path.stem)
for path in sessions_dir().glob(f"*{TOKEN_SUFFIX}")
if path.stem.isdigit()
}
+249 -163
View File
@@ -1,6 +1,11 @@
version = 1 version = 1
revision = 3 revision = 5
requires-python = ">=3.13" requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version == '3.14.*'",
"python_full_version < '3.14'",
]
[[package]] [[package]]
name = "aiofile" name = "aiofile"
@@ -16,16 +21,16 @@ wheels = [
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.18.4" version = "1.20.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "mako" }, { name = "mako" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } sdist = { url = "https://files.pythonhosted.org/packages/93/1c/00909e0fbebad2121d916fdcab044cae988146680e9ee4387ba25fef8747/alembic-1.20.1.tar.gz", hash = "sha256:83490def1f723413db5096591f7609ac52544805be93849170088ed04f5a2e0b", size = 2103576, upload-time = "2026-10-22T03:24:35.93Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, { url = "https://files.pythonhosted.org/packages/11/3c/8740f5b4e1bd57333e4f84265b9b6363da4f7cf70f5622dd1e2fde7a0bd5/alembic-1.20.1-py3-none-any.whl", hash = "sha256:9970712cfb81ea26c8658d378aaa5b8f6ff9146e5bb111d70497c164016cf598", size = 270355, upload-time = "2026-10-22T03:24:37.667Z" },
] ]
[[package]] [[package]]
@@ -60,34 +65,55 @@ wheels = [
[[package]] [[package]]
name = "asyncpg" name = "asyncpg"
version = "0.31.0" version = "0.32.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } sdist = { url = "https://files.pythonhosted.org/packages/80/4e/59dc964f962f09e3ed472e5d2d3ba670a41a2be25080dc62ab3db507ff5e/asyncpg-0.32.0.tar.gz", hash = "sha256:45e64e56714d888330b884aad1dfb363d0bf43fb343e3d1a8968525f3bade478", size = 1075156, upload-time = "2026-10-06T20:32:40.251Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, { url = "https://files.pythonhosted.org/packages/6a/ee/b6b5870b51e004880d9a216313ea7d4f180961c5869f32e58e8cb9b71e96/asyncpg-0.32.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c032869fd9c3c9fd1a86ad67e53f63906159068087c2674dd1e19be3cffff571", size = 683362, upload-time = "2026-10-06T20:31:08.078Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, { url = "https://files.pythonhosted.org/packages/d8/8b/1f450742bc6eab0c015cae26aef94fac2ff29433e3f18a019126c3912c49/asyncpg-0.32.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:0c764dce865b41878396e736d4d2c6c6ce3a8e1b61d1f6bb292e30d265ae7ca6", size = 706652, upload-time = "2026-10-06T20:31:09.524Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, { url = "https://files.pythonhosted.org/packages/05/dc/13f3c0ef7e867bafdccd470e5cfae1f2fd9a7085c771546bd4b94018e043/asyncpg-0.32.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:925ce1cc54419d468bfb77632d91e5e2be5be0fdf9d43680c68fe7cedf87051a", size = 3698244, upload-time = "2026-10-06T20:31:10.894Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, { url = "https://files.pythonhosted.org/packages/1f/64/b00ef3fc0d861c28a1937f08d2c7f6e6119c152b414d50fa800c3aee83b5/asyncpg-0.32.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cec40b66a36b14921c155db78631cd96ed00e225fdf38dd5532e9aef350a498", size = 3801314, upload-time = "2026-10-06T20:31:12.964Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, { url = "https://files.pythonhosted.org/packages/de/1b/215067d97a13206ce1565da920ddbefe5a1e5f89903e6de862fdd0a034a1/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fba43a9a230ce4d2b4593b761b8e03630c613c282b24566e27c7f53695273b1", size = 3598650, upload-time = "2026-10-06T20:31:14.797Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, { url = "https://files.pythonhosted.org/packages/37/45/2bfcb5c9b04df3f17fd367647c9f3ee9fe64ea0612b509a6b1832afcedae/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a8f7fa8304f757e23cccb8ffef6a6fce0b6320ffc565a884ee3cd0dfad1ac5", size = 3762739, upload-time = "2026-10-06T20:31:17.186Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, { url = "https://files.pythonhosted.org/packages/08/45/e6b37756e6c8979fe070e9821654244f38319493f5b0589e549d9a40c001/asyncpg-0.32.0-cp313-cp313-win32.whl", hash = "sha256:d809399022e244eb86bb532a4ae9a45746e0f6dc5154fd6aa2f6ad63fa3f5373", size = 551065, upload-time = "2026-10-06T20:31:18.812Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, { url = "https://files.pythonhosted.org/packages/ee/46/0a4e92f4310da644b28595b22ef2fff1ffd3dab84953dc8b4c5eef72b764/asyncpg-0.32.0-cp313-cp313-win_amd64.whl", hash = "sha256:38640b106705fef8b0f46cdb5fd9dcf6a638eed5cadb0f441714a21405ca8a0a", size = 625571, upload-time = "2026-10-06T20:31:20.571Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, { url = "https://files.pythonhosted.org/packages/35/f4/48ed4b580b99b1fabc480c707229bb8f1e4ba0f5b24a50822b339efe1e48/asyncpg-0.32.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78145adedfe51dc2fda623e6602cf816dabc2eafcff693bd50484321a1c9034", size = 576342, upload-time = "2026-10-06T20:31:22.29Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, { url = "https://files.pythonhosted.org/packages/25/25/a30ca6417f9142c6a63a7caf5f33717902b2d0ca8a8ff8fc72c6cc2fa77d/asyncpg-0.32.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ac18d9ee7a8ca70aed276f79b249d9f37e4d55e3525db1002b5f0b62ddec4f5", size = 691699, upload-time = "2026-10-06T20:31:24.168Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, { url = "https://files.pythonhosted.org/packages/c1/b5/59f10f2381a073c199cd868fce0d8f7aa448b08412de4dc4dbe4118bcee9/asyncpg-0.32.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:e1120ef2ae3a5e514c9ea9fce83519ba692710ea5f38434eadbbf12789073dfe", size = 715194, upload-time = "2026-10-06T20:31:25.969Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, { url = "https://files.pythonhosted.org/packages/54/59/79a5aebd58250bedefa6dcd43b22b037d9cf0054ceb4c718c53ebf04e63f/asyncpg-0.32.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fa68acb42f22436597016e5d7feef7b0b5c49b4c56aece3fdb3ba0da2326cb2", size = 3729978, upload-time = "2026-10-06T20:31:27.541Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, { url = "https://files.pythonhosted.org/packages/68/db/fc91b503b3ec66cf242d83c799388285ea5f0ee238435d53dd9c1a8648a9/asyncpg-0.32.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63417b8f7369c54f6754c1fbd5a2968fbe632ff55bfbedd56a0177b6a96bd251", size = 3794539, upload-time = "2026-10-06T20:31:29.617Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, { url = "https://files.pythonhosted.org/packages/40/bd/7359320499fdb2733206191b8fd15b7ec602656cbc1444bff7a8c66a365c/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c6366841a792d0a4d16991de240a8053b7c4772a18a5f27fa6fad09c0e359fb", size = 3632884, upload-time = "2026-10-06T20:31:31.298Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, { url = "https://files.pythonhosted.org/packages/18/75/dd3c3dd99f1db55b9736d23a44da29501f07f852bf4df91507f37b156fb1/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3ef1dfd11919280e011ffd1c873323c5088a94fd2c3f77946a5250cf306e2eb", size = 3764931, upload-time = "2026-10-06T20:31:32.916Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, { url = "https://files.pythonhosted.org/packages/38/4f/161b275759725a774d170a383c1208996865ebad50d6891e60d35461a3e6/asyncpg-0.32.0-cp314-cp314-win32.whl", hash = "sha256:77cf9d7023f063ae6f9e443077b55af0dc1807dd9afff1ae656b93ee0cddedc9", size = 557690, upload-time = "2026-10-06T20:31:34.856Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, { url = "https://files.pythonhosted.org/packages/b5/03/880d0db1faedf8b740a57a7ba50e115651a0f05c5905140195813879b086/asyncpg-0.32.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f87452025b47ce80dcc3a0be2b5d1f8aab5deec2516d266f1643d4e53cc40d5", size = 634859, upload-time = "2026-10-06T20:31:36.512Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, { url = "https://files.pythonhosted.org/packages/79/bb/2e86b462a2a2a795eaa7838266db019876b8e7a12c465b903517a4e87fd0/asyncpg-0.32.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0e4508a3d62b0f42d7a99c030c364050b11e75f61c9dd4861e5fdda7cb60636", size = 594013, upload-time = "2026-10-06T20:31:37.91Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, { url = "https://files.pythonhosted.org/packages/20/1d/5369c4438496e654121cbda75be2e8043d1fcae3552b856d44011a19b723/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:afec11e0b9c001e69966becacd2f948cc8949b4916ec4c0f4dc9b52e47de4528", size = 743832, upload-time = "2026-10-06T20:31:39.261Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, { url = "https://files.pythonhosted.org/packages/60/b0/4b92582c2339a164275a6418ccaeeb0453b72f2e0d7003702379cb50e852/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:418d266a553e932bf961bb43bfd610ee6c5425fb1b9a599a5828fd12bae8f5c4", size = 769568, upload-time = "2026-10-06T20:31:40.691Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, { url = "https://files.pythonhosted.org/packages/3d/88/919d9ff7ca3c3b96aa404b88b6a53e142b4422623c5ee5a69c4b733240ce/asyncpg-0.32.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1666e1b747ebbc75c87cb31972704ae8a3ca15b950f94456e97d26781c67d10", size = 3948962, upload-time = "2026-10-06T20:31:42.456Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, { url = "https://files.pythonhosted.org/packages/27/8b/e9f412ae9a3e3f0eb23415249e8d5933e7aeb01068b4083fc86714043d1f/asyncpg-0.32.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83510bb25d38f0415e155aa3a7af78621369891f5ecd8730d012d9cb26143ffc", size = 3874815, upload-time = "2026-10-06T20:31:44.094Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, { url = "https://files.pythonhosted.org/packages/08/71/24364e9ff7bb9860548452513f295306b12f5b24e8fb0b78f1605c443946/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:87957755d11639cf248c6aaa094eee9d150f07065866d1710c9427e02dfc0790", size = 3762465, upload-time = "2026-10-06T20:31:45.908Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, { url = "https://files.pythonhosted.org/packages/2e/e1/33cb7e805ec6806b196473e2c7a2ba9d5af3ad2928930aa06359c8eeef87/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:764227423bf30a3001d3da6df90e82d30a2a097d762e4ee5fa074236eda262f4", size = 3797285, upload-time = "2026-10-06T20:31:47.53Z" },
{ url = "https://files.pythonhosted.org/packages/be/e7/85eb86d6040725f5c191fd6af9f10769c60ed971634b47f4b4bcab293d44/asyncpg-0.32.0-cp314-cp314t-win32.whl", hash = "sha256:f2342b1f3e87b2096320a77edcbb830fbd23b1d4d4842c57567764430b95e4fc", size = 594006, upload-time = "2026-10-06T20:31:49.197Z" },
{ url = "https://files.pythonhosted.org/packages/f9/aa/ea75defe55718457bcf41cde42248db5bbee65fce8c6f0a0e43d9eca1723/asyncpg-0.32.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5c3a48908cb0a02393e5bdab7fa92aefd700f2a93212bf91f04aa9657b4f554d", size = 674647, upload-time = "2026-10-06T20:31:50.547Z" },
{ url = "https://files.pythonhosted.org/packages/0d/0b/078d362872c6c72dd5d11c214dde8dac65b1c87ece96fd2fc2f786a8f66c/asyncpg-0.32.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f8eadd207c26850a2e15f3c2a1096b5d051ea6758a26f2f3e65ce16f84297ed8", size = 624589, upload-time = "2026-10-06T20:31:52.291Z" },
{ url = "https://files.pythonhosted.org/packages/5c/83/e0145d19197b965438693179c88dd99cfc69bc1bf954815f44762ab88843/asyncpg-0.32.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58975b1a51a100c4716ebf22f84c249d27140f7b9385b64ad9b676836f1db9ab", size = 689708, upload-time = "2026-10-06T20:31:55.809Z" },
{ url = "https://files.pythonhosted.org/packages/2f/13/f394919a59f104288b1b17fb6c7a3ac4738b8c555690a63caf603f91ca83/asyncpg-0.32.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:6b95fc2ebdb4af072bfa8b64c6d0397b49242d17bef1c0337857904f9267dab2", size = 714408, upload-time = "2026-10-06T20:31:57.504Z" },
{ url = "https://files.pythonhosted.org/packages/9b/3d/1123cf41bff78fdfd80e6fd143cc86bf1ef2875af8f5d8742c03f471e913/asyncpg-0.32.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a759f98c5652443db501b20041aeee548e9a04fe7ae939067321acd207218447", size = 3733440, upload-time = "2026-10-06T20:31:59.308Z" },
{ url = "https://files.pythonhosted.org/packages/de/24/ff4b045e85d7bdf6f61f67c285800abd6e82f26319671d7f0dfadadc1aa0/asyncpg-0.32.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ceea1064500d0d7a46c092cdbe9752064c23b720ab0e0bff83d1030fffe7a50a", size = 3824312, upload-time = "2026-10-06T20:32:01.021Z" },
{ url = "https://files.pythonhosted.org/packages/12/63/1ec7eb6e20f7e8ae120a41aad9669044cce964f39773baf644897a046aee/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:543f02790d086244c7cdc849e4b671b6c2048be0242b78d943494da6e80c0001", size = 3637212, upload-time = "2026-10-06T20:32:02.699Z" },
{ url = "https://files.pythonhosted.org/packages/79/68/528e362eb5adbc1a7defe4c5f157756a031346d3efa9920467b245e4ce41/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f24d20a68f0e37ca6fc490388e7eeb48abab3da0dbf06248135ed6179f5f521d", size = 3791355, upload-time = "2026-10-06T20:32:04.415Z" },
{ url = "https://files.pythonhosted.org/packages/38/e3/22f443f456bf93d1806f43a820da8ee463dfe9b93a9d77a3f00fedcdaad6/asyncpg-0.32.0-cp315-cp315-win32.whl", hash = "sha256:110f72d33c8b944ab421ca383db0b8849cfeb861547fee6cbb61f65a6bcd0985", size = 557457, upload-time = "2026-10-06T20:32:06.52Z" },
{ url = "https://files.pythonhosted.org/packages/54/d5/ccb76555a333f543c4d6ad6422b616efc0811dbbde5054fda071e249c7bf/asyncpg-0.32.0-cp315-cp315-win_amd64.whl", hash = "sha256:6d1d1cd1348ebb9b204b5f56f977c5d4380674c25cc094064bf32bd9c3b7273d", size = 635573, upload-time = "2026-10-06T20:32:08.197Z" },
{ url = "https://files.pythonhosted.org/packages/38/70/dff17e837ba0eb4347bb33da33f54df87230d3d176793d4bb2ad7786b1b8/asyncpg-0.32.0-cp315-cp315-win_arm64.whl", hash = "sha256:cd5d16b3a5db37c1e6e445e362952b4af569f85f94e162f947bfa8ea25a45fa5", size = 594218, upload-time = "2026-10-06T20:32:09.717Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b8/c5506dbde0cfb213963210fd0c80e60036ddaaa883ac0d3c55d05a10ebe8/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4ea1a72a00fe705b68a9727c3d538c4c56690af9bb1cbbf3c089f5d3ddcccea0", size = 741693, upload-time = "2026-10-06T20:32:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/23/98/9f998c651aa5d66b59ab6c13da71a15d74ccb1ddc4d65290ea5e2e5aedc1/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:ed3ae4c3659aea1fb0e3a6c1061fc4c64d9b7a2a8f4a27443dc43d74fa84cf03", size = 768101, upload-time = "2026-10-06T20:32:12.948Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ce/d8c63a71e908f5d80de1a3a057c8407aaea07cf19980d4b24ab624943c99/asyncpg-0.32.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db69b9cf879bddeea41210c80b8c8877bfe2709e2bee9d18d5a5c00e7eb75972", size = 3940715, upload-time = "2026-10-06T20:32:14.544Z" },
{ url = "https://files.pythonhosted.org/packages/b9/a5/5d2b17682e297e39206eda1dfe0120fc239e84d3440b39ff7c9cc7ec83db/asyncpg-0.32.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bee7bb5394bf55fc3bf4144625c33f298949961acdb1e0d67e60f958ac9a2e6", size = 3907504, upload-time = "2026-10-06T20:32:16.212Z" },
{ url = "https://files.pythonhosted.org/packages/b1/80/38ec7277f31f26267a0a0547d0997d936850d05007d1e0e1041bf8070e1d/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d74eabd68e68861333e3fcb92b520a2a851f6485abf4b723887590399d4980c1", size = 3750324, upload-time = "2026-10-06T20:32:18.061Z" },
{ url = "https://files.pythonhosted.org/packages/dc/74/089e80eda7d543a49875687a84121e2ad61a7c69698963623ee77372c4e9/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6af2af292a93d5ef800007c8f8f66b85af2a49b49e4b56a10685a0dc24a6af83", size = 3826457, upload-time = "2026-10-06T20:32:19.757Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3c/38104e60cda6131977f95b634d45536ddc1cde53ef8bc765f9056e3e17ee/asyncpg-0.32.0-cp315-cp315t-win32.whl", hash = "sha256:d148cb6a9081ed999ca3cd0d95fb9eaf79bf17d885bba93c83de52273d2fe0af", size = 592437, upload-time = "2026-10-06T20:32:21.668Z" },
{ url = "https://files.pythonhosted.org/packages/95/09/85cba249db0910708826ea428b32a4a05630df993621c369bdb8d42c73c5/asyncpg-0.32.0-cp315-cp315t-win_amd64.whl", hash = "sha256:e101801b4124e905da0732cf2b0d838f682a9ea5273d7cced3d54bdbe744e6f7", size = 672417, upload-time = "2026-10-06T20:32:23.147Z" },
{ url = "https://files.pythonhosted.org/packages/38/11/ec5f7f306dd361aa9558f002cbb6acfa1e9ba32fa59b8f53135fbdfa14f1/asyncpg-0.32.0-cp315-cp315t-win_arm64.whl", hash = "sha256:3bbf08c08e31f43be858255614518e78cdfb343571e557e818e9fe736334f4c8", size = 622767, upload-time = "2026-10-06T20:32:24.64Z" },
] ]
[[package]] [[package]]
@@ -153,11 +179,27 @@ requires-dist = [
name = "beartype" name = "beartype"
version = "0.22.9" version = "0.22.9"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.14.*'",
"python_full_version < '3.14'",
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
] ]
[[package]]
name = "beartype"
version = "0.23.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.15'",
]
sdist = { url = "https://files.pythonhosted.org/packages/74/b7/6f162c0116815b45de8ac81cde5aaacfc67cce4b573fb2e194c3a8c623eb/beartype-0.23.0.tar.gz", hash = "sha256:9d915ce043a2262ee3eb110d1c1c528179f06eb4145e375a750f72ddbdf40fa3", size = 1992744, upload-time = "2026-10-10T07:07:41.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/ce/64d48f01d88370a78dc0b3e484e8424cd5c87d5a0ac34c1ac93ad8d24275/beartype-0.23.0-py3-none-any.whl", hash = "sha256:a88b85be516c064d4de2b42045a258a43be97bce5cbc18b777f332027c0979f7", size = 1581485, upload-time = "2026-10-10T07:07:39.543Z" },
]
[[package]] [[package]]
name = "cachetools" name = "cachetools"
version = "7.1.4" version = "7.1.4"
@@ -186,11 +228,11 @@ wheels = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.5.20" version = "2026.10.19"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
] ]
[[package]] [[package]]
@@ -378,37 +420,39 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.136.3" version = "0.162.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
{ name = "opentelemetry-api" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "starlette" }, { name = "starlette" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } sdist = { url = "https://files.pythonhosted.org/packages/e9/eb/2cf8c80abf09b2800aef0a83b41066fbfda8ee5495b7a08b9d1af40f7046/fastapi-0.162.0.tar.gz", hash = "sha256:6a22c1e2e6e32e0633ee7e170f00009a29b8228b16feb219ee42f32fd8d88a8f", size = 527867, upload-time = "2026-10-23T18:47:33.264Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, { url = "https://files.pythonhosted.org/packages/b4/4f/3f2c25c9b43970b5e9a08beb5e04776247edea684ddc0930f4f02f0bb6cb/fastapi-0.162.0-py3-none-any.whl", hash = "sha256:3785963de305dd09912426cb81f2d230fce98ab3521cdfe97265c8d14983128c", size = 148912, upload-time = "2026-10-23T18:47:31.734Z" },
] ]
[[package]] [[package]]
name = "fastmcp" name = "fastmcp"
version = "3.3.1" version = "4.1.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "fastmcp-slim", extra = ["client", "server"] }, { name = "fastmcp-slim", extra = ["client", "server"] },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/3b/a9/5c5a01b6abd5346bf60b97cfd29e4a86661940c27dd562bfcda07fd03519/fastmcp-3.3.1.tar.gz", hash = "sha256:979362ea557de42a5f40342563c7e4b236bcc8e7cd192715f50030695d1a71cd", size = 28681699, upload-time = "2026-05-15T15:50:39.673Z" } sdist = { url = "https://files.pythonhosted.org/packages/ea/f4/79169f9a2419e1f541b3b39b1bda1c57569b4c7c1ac4b606750c81471ad1/fastmcp-4.1.3.tar.gz", hash = "sha256:a1f79e9db929a1603c75331df603f74530fc236aa711e0ee2e185821c6d9888d", size = 42755717, upload-time = "2026-10-20T22:32:28.005Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/11/6b1bdada6ccfe647d615ae63f9106f8136aec17971e9361546af01c7d38e/fastmcp-3.3.1-py3-none-any.whl", hash = "sha256:862440c5c4d281363a5995eee59d77f0f7cac1f18869038729cecf03b02fc522", size = 7903, upload-time = "2026-05-15T15:50:36.424Z" }, { url = "https://files.pythonhosted.org/packages/97/7f/27c501d98b5681ca4f6c21a7d741b3e732e962c5e4c3b4db6dd1ffdf4fd8/fastmcp-4.1.3-py3-none-any.whl", hash = "sha256:b983d8acc44ab1143e0c86fd6da843f25043ee0aeb64ab983b36c527bd1d33e9", size = 8153, upload-time = "2026-10-20T22:32:22.881Z" },
] ]
[[package]] [[package]]
name = "fastmcp-slim" name = "fastmcp-slim"
version = "3.3.1" version = "4.1.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "mcp-types" },
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@@ -416,26 +460,31 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d1/a0/627103e517e1d0d6f1eec633d5662d13e776f01b45ad188e4f5f7478b438/fastmcp_slim-3.3.1.tar.gz", hash = "sha256:0957835fc59452e143ab2f4b7836d2d2df9b2d9958408edc79ba8b56232b2a88", size = 567007, upload-time = "2026-05-15T15:50:10.426Z" } sdist = { url = "https://files.pythonhosted.org/packages/81/86/01705c74106297c021e5f8a22ed051adb43061cc779cf543dc05a4d47aba/fastmcp_slim-4.1.3.tar.gz", hash = "sha256:d95277db0979e4cbfa84528cf9016ae079494b41ed8e88cc909fee8233db6035", size = 749467, upload-time = "2026-10-20T22:30:58.047Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/ee/97047f4cc2d7b1d46670d08d8ad01a96e7a748cc01c0b4b351ad8eddbc7a/fastmcp_slim-3.3.1-py3-none-any.whl", hash = "sha256:6cf1c2d77e3adb0d409d6825ed6b0b2a999062973e00b8eea03bd48bf9b4c043", size = 738644, upload-time = "2026-05-15T15:50:08.336Z" }, { url = "https://files.pythonhosted.org/packages/4e/ff/a7feaeb130a2003700789ce32e933b4c2ae00fc7c264642e57ab12e4684f/fastmcp_slim-4.1.3-py3-none-any.whl", hash = "sha256:81af8699331c33d438caf85b6947f36df6e2f04118e6f318ca00374fd9afea9a", size = 931046, upload-time = "2026-10-20T22:30:56.182Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
client = [ client = [
{ name = "authlib" }, { name = "authlib" },
{ name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "exceptiongroup" }, { name = "exceptiongroup" },
{ name = "httpx" }, { name = "httpx2" },
{ name = "mcp" }, { name = "mcp" },
{ name = "opentelemetry-api" }, { name = "opentelemetry-api" },
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
{ name = "starlette" },
] ]
server = [ server = [
{ name = "authlib" }, { name = "authlib" },
{ name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "certifi" },
{ name = "cyclopts" }, { name = "cyclopts" },
{ name = "exceptiongroup" }, { name = "exceptiongroup" },
{ name = "griffelib" }, { name = "griffelib" },
{ name = "httpx" }, { name = "httpx2" },
{ name = "joserfc" },
{ name = "jsonref" }, { name = "jsonref" },
{ name = "jsonschema-path" }, { name = "jsonschema-path" },
{ name = "mcp" }, { name = "mcp" },
@@ -446,6 +495,7 @@ server = [
{ name = "pyperclip" }, { name = "pyperclip" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "starlette" },
{ name = "uncalled-for" }, { name = "uncalled-for" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "watchfiles" }, { name = "watchfiles" },
@@ -454,59 +504,59 @@ server = [
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.5.1" version = "3.5.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" },
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" },
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" },
{ url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" },
{ url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" },
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" },
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" },
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" },
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" },
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" },
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" },
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" },
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" },
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" },
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" },
{ url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" },
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" },
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" },
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" },
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" },
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" },
{ url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" },
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" },
{ url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" },
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" },
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" },
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" },
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" },
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" },
{ url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" },
{ url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" },
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" },
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" },
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" },
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" },
] ]
[[package]] [[package]]
@@ -528,16 +578,16 @@ wheels = [
] ]
[[package]] [[package]]
name = "httpcore" name = "httpcore2"
version = "1.0.9" version = "2.13.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "certifi" },
{ name = "h11" }, { name = "h11" },
{ name = "truststore" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" },
] ]
[[package]] [[package]]
@@ -570,36 +620,37 @@ wheels = [
] ]
[[package]] [[package]]
name = "httpx" name = "httpx2"
version = "0.28.1" version = "2.13.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "certifi" }, { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore" }, { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
{ name = "idna" }, { name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" },
] ]
[[package]] [[package]]
name = "httpx-sse" name = "httpx2-jsfetch"
version = "0.4.3" version = "1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
] ]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.17" version = "3.20"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
] ]
[[package]] [[package]]
@@ -726,15 +777,15 @@ wheels = [
[[package]] [[package]]
name = "kurigram" name = "kurigram"
version = "2.2.23" version = "2.2.26"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pyaes" }, { name = "pyaes" },
{ name = "python-socks" }, { name = "python-socks" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a1/f3/e93fe48fa937cb681c4a8581f4d75da9e9054e35efedaecc9490fb585b45/kurigram-2.2.23.tar.gz", hash = "sha256:b8ccc23cab2875b25134a01f7c632dd64de334804b60c8cf19f20eb3b2ec5c58", size = 589349, upload-time = "2026-05-21T15:12:37.533Z" } sdist = { url = "https://files.pythonhosted.org/packages/c1/48/65efaaaaf24dc62d6f6ef046843952ff17b5358f9c7119303ceee6d53657/kurigram-2.2.26.tar.gz", hash = "sha256:83b64fd7ab37122d920b1e7c0e221b599d7c52dd5d0d24c79956f01273020c49", size = 748176, upload-time = "2026-09-12T17:45:08.406Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/1b/00ca5e8a39dbf7083e8ad37bf0e3062f3126f78ee3f94cda78d77e5883ff/kurigram-2.2.23-py3-none-any.whl", hash = "sha256:3a5806c76acd0f8fdd9cd1e2f65f84c61f8b2650689c7481c5035a8ad26022d9", size = 5690474, upload-time = "2026-05-21T15:12:35.319Z" }, { url = "https://files.pythonhosted.org/packages/2b/e0/a00020c21725550c9309e6e03787acf7236de2f284c8d9bc57644ae2160a/kurigram-2.2.26-py3-none-any.whl", hash = "sha256:f53cee6119a579b74620f45b5f83d543cd3bd510a8a6a1b4b3852e21603241e9", size = 6044442, upload-time = "2026-09-12T17:45:06.456Z" },
] ]
[[package]] [[package]]
@@ -815,15 +866,15 @@ wheels = [
[[package]] [[package]]
name = "mcp" name = "mcp"
version = "1.27.2" version = "2.3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
{ name = "httpx" }, { name = "httpx2" },
{ name = "httpx-sse" },
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "mcp-types" },
{ name = "opentelemetry-api" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] }, { name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "pywin32", marker = "sys_platform == 'win32'" },
@@ -833,9 +884,22 @@ dependencies = [
{ name = "typing-inspection" }, { name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" },
]
[[package]]
name = "mcp-types"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" },
] ]
[[package]] [[package]]
@@ -858,26 +922,26 @@ wheels = [
[[package]] [[package]]
name = "openapi-pydantic" name = "openapi-pydantic"
version = "0.5.1" version = "0.6.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } sdist = { url = "https://files.pythonhosted.org/packages/2b/32/0c9bd3e4e847cd6117b64dbef4cf810faa9cdbd6689323b811569cc8a1b8/openapi_pydantic-0.6.0.tar.gz", hash = "sha256:11f3ac6ad41521fc156381ec587246b0c0ecea523bd9565ed74d3e7fac9d91cd", size = 1667049, upload-time = "2026-10-02T12:20:00.508Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, { url = "https://files.pythonhosted.org/packages/31/57/9d3eb823a03c33e9d4c810e6a3276e3a6dc820a7fdc0961db2c04e613817/openapi_pydantic-0.6.0-py3-none-any.whl", hash = "sha256:8bd6d548a250da37db96f461bcd52b3468e8834ad7d6db14b00a6b2ebbd163a3", size = 96931, upload-time = "2026-10-02T12:19:58.917Z" },
] ]
[[package]] [[package]]
name = "opentelemetry-api" name = "opentelemetry-api"
version = "1.42.1" version = "1.45.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
] ]
[[package]] [[package]]
@@ -912,7 +976,8 @@ name = "py-key-value-aio"
version = "0.4.5" version = "0.4.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "beartype" }, { name = "beartype", version = "0.22.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
{ name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" }
@@ -1025,16 +1090,17 @@ wheels = [
[[package]] [[package]]
name = "pydantic-settings" name = "pydantic-settings"
version = "2.14.1" version = "2.16.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } sdist = { url = "https://files.pythonhosted.org/packages/05/ca/7817b4bf0c4b15c6a6ab8cdf4cb96c268335a8ca2f525de93b6ebe4c7d28/pydantic_settings-2.16.1.tar.gz", hash = "sha256:aff602a1886338c2df3d1e958e2193a8dc33cec75b91f202e83fd98484b4b0da", size = 303436, upload-time = "2026-10-20T11:37:19.024Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, { url = "https://files.pythonhosted.org/packages/3c/74/fdf926afe145d8629c4b2ff57f4ea1d64ed7abc4d84d97463b2517a2547e/pydantic_settings-2.16.1-py3-none-any.whl", hash = "sha256:30f5313340da4e8f1b440d1910b92a5bc3ccbb6b4ba70ec2b8395bc921f418f5", size = 80771, upload-time = "2026-10-20T11:37:17.298Z" },
] ]
[[package]] [[package]]
@@ -1337,16 +1403,16 @@ wheels = [
[[package]] [[package]]
name = "sqlmodel" name = "sqlmodel"
version = "0.0.38" version = "0.0.48"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" } sdist = { url = "https://files.pythonhosted.org/packages/dd/67/b2c0b771c89c023262dd8fb6ba2cdc6a004e5ff75dd5763051b38ccd133a/sqlmodel-0.0.48.tar.gz", hash = "sha256:5582e87e845e23bb1179a7d8b11a4f5e441b4494528a41ee2fe1d129ffe91543", size = 99329, upload-time = "2026-10-06T21:44:36.391Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" }, { url = "https://files.pythonhosted.org/packages/4b/76/6f8221eda15f28471c3887e5a75a62cd7338d20ac03e1e78a110a127d61e/sqlmodel-0.0.48-py3-none-any.whl", hash = "sha256:8d389bf735b03a17508e93e888c13a30ef1ddca22dd8e57add12317401e4112e", size = 31468, upload-time = "2026-10-06T21:44:35.326Z" },
] ]
[[package]] [[package]]
@@ -1380,6 +1446,15 @@ version = "1.2.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/59/7cf5ced989e3139a791d5452d58cb8994de589576b80f9267ba76d794f6c/TgCrypto-1.2.5.tar.gz", hash = "sha256:9bc2cac6fb9a12ef5b08f3dd500174fe374d89b660cce981f57e3138559cb682", size = 37280, upload-time = "2022-11-11T19:55:11.436Z" } sdist = { url = "https://files.pythonhosted.org/packages/33/59/7cf5ced989e3139a791d5452d58cb8994de589576b80f9267ba76d794f6c/TgCrypto-1.2.5.tar.gz", hash = "sha256:9bc2cac6fb9a12ef5b08f3dd500174fe374d89b660cce981f57e3138559cb682", size = 37280, upload-time = "2022-11-11T19:55:11.436Z" }
[[package]]
name = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"
@@ -1403,29 +1478,28 @@ wheels = [
[[package]] [[package]]
name = "uncalled-for" name = "uncalled-for"
version = "0.3.2" version = "0.4.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } sdist = { url = "https://files.pythonhosted.org/packages/6c/54/4651e4c5fe84300f8e03c0390aaad41bcb9c7b81d7783542a3ee690b40ee/uncalled_for-0.4.1.tar.gz", hash = "sha256:6412b19d1b1e7d431981fee01f8b47be7802f48328a48cbe9603acee757ec553", size = 56971, upload-time = "2026-10-09T12:54:34.941Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, { url = "https://files.pythonhosted.org/packages/bf/a6/ba763e3812e572de7291676c3a41adfac1ef1209a6acda6bae055970f127/uncalled_for-0.4.1-py3-none-any.whl", hash = "sha256:b611b38879d9fe5088297e7bb28f2ddd12d2d73dad933d93da107fb63eb4058e", size = 14889, upload-time = "2026-10-09T12:54:33.596Z" },
] ]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.48.0" version = "0.54.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "h11" }, { name = "h11" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
standard = [ standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" }, { name = "httptools" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "pyyaml" }, { name = "pyyaml" },
@@ -1436,28 +1510,40 @@ standard = [
[[package]] [[package]]
name = "uvloop" name = "uvloop"
version = "0.22.1" version = "0.23.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, { url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, { url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, { url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, { url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, { url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, { url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, { url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, { url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, { url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, { url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, { url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, { url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, { url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, { url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, { url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, { url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, { url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, { url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
{ url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
{ url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
{ url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
{ url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
{ url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
{ url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
{ url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
] ]
[[package]] [[package]]
-1
View File
@@ -1,4 +1,3 @@
BEAVERGRAM_DOMAIN=beavergram.localhost BEAVERGRAM_DOMAIN=beavergram.localhost
BEAVERGRAM_DEV_DOMAIN=dev.beavergram.localhost BEAVERGRAM_DEV_DOMAIN=dev.beavergram.localhost
CLOUDFLARE_API_TOKEN= CLOUDFLARE_API_TOKEN=
+1 -1
View File
@@ -1,2 +1,2 @@
Caddyfile
.env .env
/Caddyfile
+2 -7
View File
@@ -1,6 +1,5 @@
{ {
admin off admin localhost:2019
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
log { log {
format console format console
@@ -12,8 +11,4 @@
} }
} }
(compress) { import /etc/caddy/projects.d/*/site.caddy
encode zstd gzip
}
import /etc/caddy/projects.d/*.caddy
+3
View File
@@ -0,0 +1,3 @@
# caddy
`site.caddy` routes `{$BEAVERGRAM_DOMAIN}` to the project over the external `caddy` network. In a shared Caddy, mount this directory as `/etc/caddy/projects.d/beavergram:ro` and set `BEAVERGRAM_DOMAIN`. Keep it portable: domain only from the env, no `tls`, snippets prefixed with `beavergram_`.
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- .env - .env
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro - ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./site.caddy:/etc/caddy/projects.d/beavergram.caddy:ro - .:/etc/caddy/projects.d/beavergram:ro
- caddy_data:/data - caddy_data:/data
networks: networks:
+1 -1
View File
@@ -5,5 +5,5 @@
{$BEAVERGRAM_DEV_DOMAIN} { {$BEAVERGRAM_DEV_DOMAIN} {
encode zstd gzip encode zstd gzip
reverse_proxy beavergram-frontend:5173 reverse_proxy beavergram-frontend:3000
} }
-26
View File
@@ -1,26 +0,0 @@
services:
# postgres:
# ports:
# - "127.0.0.1:5432:5432"
api:
# ports:
# - "127.0.0.1:8080:8080"
networks:
default: {}
caddy:
aliases:
- beavergram-api
frontend-dev:
# ports:
# - "127.0.0.1:5173:5173"
networks:
default: { }
caddy:
aliases:
- beavergram-frontend
networks:
caddy:
external: true
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff. Show more